From 97cd2f7c2964ad4f663fe16151ee8515328a01f2 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 7 Sep 2026 23:15:09 -0700 Subject: [PATCH 1/2] fix(cli): stop letting a stub's own description freeze a stale CLI reference A `.claude`/`.agents` reference stub's frontmatter `description` is copied verbatim from the embedded skill/command source, and unlike the canonical `.taskless/` copy, is never passed through the build-target invocation rewrite. A description that named the CLI invocation therefore froze a stale, unpinned `npx @taskless/cli` reference into every stub forever, even on a nightly install whose canonical file correctly names the pinned `@taskless/cli-nightly@` package. Remove the invocation from the skill and command `description` fields entirely, so there is no second, unrewritten copy left to go stale. The canonical file already carries the correct per-build invocation, and a stub always defers to it. Adds a source-level guard against reintroducing a CLI package reference into any description, plus an end-to-end nightly-build test that installs and reads back the actual stub. --- .../fix-nightly-stub-package-reference.md | 15 +++ commands/tskl/tskl.md | 2 +- .../stub-description-invocation.test.ts | 110 ++++++++++++++++++ .../test/stub-description-invocation.test.ts | 63 ++++++++++ skills/taskless/SKILL.md | 2 +- 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-nightly-stub-package-reference.md create mode 100644 packages/cli/test/nightly/stub-description-invocation.test.ts create mode 100644 packages/cli/test/stub-description-invocation.test.ts diff --git a/.changeset/fix-nightly-stub-package-reference.md b/.changeset/fix-nightly-stub-package-reference.md new file mode 100644 index 00000000..15b985a3 --- /dev/null +++ b/.changeset/fix-nightly-stub-package-reference.md @@ -0,0 +1,15 @@ +--- +"@taskless/cli": patch +--- + +Fixed reference stubs (`.claude/`, `.agents/`, etc.) freezing a stale, +unpinned `npx @taskless/cli` invocation into their own frontmatter +`description` forever, even on a nightly install whose canonical +`.taskless/skills/taskless/SKILL.md` correctly names the pinned +`@taskless/cli-nightly@` package. A stub's `description` is copied +verbatim from source and, unlike canonical content, is never rewritten for +the current build target — so any CLI invocation baked into it would go +stale on the very first release that changed. The invocation is removed from +the skill and command `description` fields entirely: the canonical file +already carries the correct, per-build invocation, and a stub always defers +to it, so there is no longer a second copy that can drift. diff --git a/commands/tskl/tskl.md b/commands/tskl/tskl.md index 2ed3c308..5fdbb98b 100644 --- a/commands/tskl/tskl.md +++ b/commands/tskl/tskl.md @@ -1,6 +1,6 @@ --- name: "Taskless" -description: Run any Taskless action — create/improve/delete a rule, run check, manage auth, or wire CI. Routes via `npx @taskless/cli agent ` to fetch the canonical recipe and follow it. +description: Run any Taskless action — create/improve/delete a rule, run check, manage auth, or wire CI. Routes via `agent ` to fetch the canonical recipe and follow it. category: Taskless argument-hint: tags: diff --git a/packages/cli/test/nightly/stub-description-invocation.test.ts b/packages/cli/test/nightly/stub-description-invocation.test.ts new file mode 100644 index 00000000..c6a0feec --- /dev/null +++ b/packages/cli/test/nightly/stub-description-invocation.test.ts @@ -0,0 +1,110 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { parseFrontmatter } from "../../src/install/frontmatter"; +import { + applyInstallPlan, + buildInstallPlan, + getEmbeddedCommands, + getEmbeddedSkills, +} from "../../src/install/install"; +import { + buildInvocation, + isProductionInvocation, +} from "../../src/util/invocation"; + +/** + * End-to-end companion to `test/stub-description-invocation.test.ts`, run + * under the `nightly` vitest project (see `vite.config.ts`) so + * `__TASKLESS_CLI__` is a real pinned `@taskless/cli-nightly@` + * define, exactly as `build:nightly` produces — not something reachable under + * this repo's default prod define. + * + * taskless/cli#298: a `.claude`/`.agents` stub's frontmatter `description` is + * copied verbatim from the embedded skill/command source (see `writeSkill` / + * `writeCommand` in `src/install/install.ts`) and is NEVER passed through + * `applyCliInvocation` — only a canonical `.taskless/` file gets that rewrite. + * Before the fix, a description that named the CLI invocation would freeze a + * stale, unpinned `npx @taskless/cli` reference into every `.claude` stub + * forever, even though the canonical `.taskless/skills/taskless/SKILL.md` + * written by the SAME install correctly names this build's pinned nightly. + * + * This drives that divergence the way the issue describes it — a real + * `applyInstallPlan` under a nightly define — without installing the actual + * `@taskless/cli-nightly` package, which is blocked by a deny rule in this + * repo. + */ +describe("installing a nightly writes a stub description that names no CLI package", () => { + let cwd: string; + + beforeEach(async () => { + expect(isProductionInvocation()).toBe(false); + expect(buildInvocation()).toContain("@taskless/cli-nightly@"); + + cwd = await mkdtemp(join(tmpdir(), "taskless-nightly-stub-description-")); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: {} }), + "utf8" + ); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + const skillStubPath = () => + join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + const commandStubPath = () => + join(cwd, ".claude", "commands", "tskl", "tskl.md"); + const canonicalSkillPath = () => + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"); + + async function install(): Promise { + const skills = getEmbeddedSkills(); + const commands = getEmbeddedCommands(); + const plan = buildInstallPlan([".claude"], skills, commands); + await applyInstallPlan(cwd, plan, { cliVersion: "0.0.0-nightly.test" }); + } + + it("names no CLI package in the stub's frontmatter description", async () => { + await install(); + + const skillDescription = parseFrontmatter( + await readFile(skillStubPath(), "utf8") + ).data.description as string; + const commandDescription = parseFrontmatter( + await readFile(commandStubPath(), "utf8") + ).data.description as string; + + // Not the released package, and not this build's own pinned nightly + // either — the fix is that description names no package at all, so there + // is nothing left to go stale on a later, differently-pinned install. + expect(skillDescription).not.toMatch(/@taskless\/cli/); + expect(commandDescription).not.toMatch(/@taskless\/cli/); + }); + + it("still correctly pins the canonical file's own invocation", async () => { + await install(); + + // Contrast case: the canonical `.taskless/` copy IS rewritten per build, + // which is what makes the stub's staleness invisible without this test — + // the canonical file looks completely correct. + const canonical = await readFile(canonicalSkillPath(), "utf8"); + expect(canonical).toContain(buildInvocation()); + expect(canonical).not.toContain("npx @taskless/cli agent"); + }); + + it("a second install pinned to a different nightly leaves the stub unchanged", async () => { + await install(); + const first = await readFile(skillStubPath(), "utf8"); + + // Re-running install (same build, so same pin) must not rewrite a stub + // whose description never carried a version to begin with. + await install(); + expect(await readFile(skillStubPath(), "utf8")).toBe(first); + }); +}); diff --git a/packages/cli/test/stub-description-invocation.test.ts b/packages/cli/test/stub-description-invocation.test.ts new file mode 100644 index 00000000..b5d85503 --- /dev/null +++ b/packages/cli/test/stub-description-invocation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { getEmbeddedCommands, getEmbeddedSkills } from "../src/install/install"; + +/** + * Regression coverage for taskless/cli#298. + * + * `getEmbeddedSkills`/`getEmbeddedCommands` read `name`/`description` straight + * out of a skill or command's raw frontmatter — the exact text later copied + * verbatim into a `.claude`/`.agents`/etc. reference stub's own frontmatter + * (see `writeSkill`/`writeCommand` in `src/install/install.ts`, which pass + * `skill.description`/`command.description` through unchanged as + * `StubFrontmatter.description`). + * + * That copy is NEVER routed through `applyCliInvocation`: only a canonical + * `.taskless/` file gets that rewrite (`writeCanonicalSkill`/ + * `writeCanonicalCommand`). A stub's own frontmatter is deliberately kept + * byte-stable across releases so it does not churn on every version bump + * (see the `StubFrontmatter` doc comment in `src/install/canonical.ts`) — and + * `stubFrontmatterDrifted` only rewrites a stub when `name`/`description` + * actually change, so a CLI invocation baked into `description` freezes there + * across every later `init`, nightly or not. + * + * The fix is to never let a CLI invocation reach `description` in the first + * place — there is then no second, unrewritten copy for a stub to freeze. This + * test enforces that structurally: it fails if ANY embedded skill or command's + * `description` names the CLI, pinned or not, so a future author cannot + * reintroduce the class of bug by adding an invocation back into a + * `description:` field. + * + * Mutation check performed by hand: restoring the pinned literal + * `` `npx @taskless/cli agent route` `` inside `skills/taskless/SKILL.md`'s + * `description:` field made this test fail (both assertions below); reverting + * made it pass again. + */ +describe("skill/command descriptions never name the CLI package", () => { + // The one substring every form of the invocation shares: the released + // package (`npx @taskless/cli`) and a nightly's pinned form + // (`npx @taskless/cli-nightly@`) both contain `@taskless/cli`. A + // bare `taskless` (the product name, used all over these descriptions in + // ordinary prose — "run taskless", "wire taskless into CI") is not this bug: + // it names no package and carries no version, so it cannot go stale across + // a channel or version change the way `@taskless/cli[-nightly]` can. + const NAMES_THE_CLI_PACKAGE = /@taskless\/cli/; + + it("no embedded skill's description carries a CLI invocation", () => { + for (const skill of getEmbeddedSkills()) { + expect( + skill.description, + `skill "${skill.name}" description names the CLI package` + ).not.toMatch(NAMES_THE_CLI_PACKAGE); + } + }); + + it("no embedded command's description carries a CLI invocation", () => { + for (const command of getEmbeddedCommands()) { + expect( + command.description, + `command "${command.filename}" description names the CLI package` + ).not.toMatch(NAMES_THE_CLI_PACKAGE); + } + }); +}); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index fcd3fd45..3e9e5a03 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -17,7 +17,7 @@ description: | Also trigger on any request to add/write/create a lint or code rule, including ones that name a specific tool (eslint, ruff, biome, stylelint, ast-grep). Naming a tool ENGAGES this skill's routing flow via - `npx @taskless/cli agent route`; it does NOT suppress the skill. + `agent route`; it does NOT suppress the skill. metadata: author: taskless version: 0.11.1 From e593f2a37529abd94deb31bf94a58a78a9cbcc4e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 8 Sep 2026 09:36:30 -0700 Subject: [PATCH 2/2] fix(cli): test the cross-pin case, rename the one that wasn't it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #312: "a second install pinned to a different nightly leaves the stub unchanged" reran install() under the SAME fixed __TASKLESS_CLI__ define twice, so it was an idempotent-reinstall check, not a cross-pin one — a real different pin can't be produced from a single compile-time Vite define within one test run. Renamed that test to describe what it actually checks, and added the real cross-pin case: hand-craft a stub via buildSkillStub() with an older, differently-pinned description (as a pre-fix source file would have produced), write it to disk, then run this build's install() and assert it converges onto the current, invocation-free description rather than leaving the earlier nightly's frozen copy in place. Mutation-checked by reintroducing the stale invocation into skills/taskless/SKILL.md's description: both this new test and the existing "names no CLI package" test failed as expected. Reverted; both pass again. --- .../stub-description-invocation.test.ts | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/nightly/stub-description-invocation.test.ts b/packages/cli/test/nightly/stub-description-invocation.test.ts index c6a0feec..15f3c8aa 100644 --- a/packages/cli/test/nightly/stub-description-invocation.test.ts +++ b/packages/cli/test/nightly/stub-description-invocation.test.ts @@ -1,8 +1,9 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildSkillStub } from "../../src/install/canonical"; import { parseFrontmatter } from "../../src/install/frontmatter"; import { applyInstallPlan, @@ -98,13 +99,54 @@ describe("installing a nightly writes a stub description that names no CLI packa expect(canonical).not.toContain("npx @taskless/cli agent"); }); - it("a second install pinned to a different nightly leaves the stub unchanged", async () => { + it("a second install under the SAME pin is idempotent: nothing gets rewritten", async () => { await install(); const first = await readFile(skillStubPath(), "utf8"); // Re-running install (same build, so same pin) must not rewrite a stub - // whose description never carried a version to begin with. + // whose description never carried a version to begin with. The genuine + // cross-pin case — an EARLIER, differently pinned install's stub — is + // covered separately below, since a single compile-time define can't + // produce two different pins within one test run. await install(); expect(await readFile(skillStubPath(), "utf8")).toBe(first); }); + + it("a stub frozen by an EARLIER, differently pinned nightly converges on the next install", async () => { + // Simulates the actual defect in taskless/cli#298: a project installed an + // older nightly, pinned to a DIFFERENT version, back when a skill's + // `description` still carried a baked-in CLI invocation. That earlier + // install's stub is not reachable by calling `install()` twice under this + // file's single fixed `__TASKLESS_CLI__` define (a compile-time Vite + // define can't vary within one test run), so it is hand-crafted here with + // `buildSkillStub` instead — the same builder `writeSkill` itself calls, + // fed the OLD-style description a pre-fix source file would have produced. + await mkdir(dirname(skillStubPath()), { recursive: true }); + const frozenByEarlierNightly = buildSkillStub({ + name: "taskless", + description: + "Use for any Taskless task. Fetch recipes via " + + "`npx @taskless/cli-nightly@0.0.0-nightly.previous agent route`.", + }); + await writeFile(skillStubPath(), frozenByEarlierNightly, "utf8"); + + await install(); + + const rewritten = await readFile(skillStubPath(), "utf8"); + const rewrittenDescription = parseFrontmatter(rewritten).data + .description as string; + + // The stub actually changed — this build's install converged it rather + // than leaving the earlier nightly's frozen copy in place. + expect(rewritten).not.toBe(frozenByEarlierNightly); + // It converged onto the CURRENT source description (invocation-free), not + // merely onto some other pin. + const currentDescription = getEmbeddedSkills().find( + (s) => s.name === "taskless" + )?.description; + expect(rewrittenDescription).toBe(currentDescription); + // And, the property the whole fix establishes: no pinned or unpinned CLI + // package reference survives, from either the old install or this one. + expect(rewrittenDescription).not.toMatch(/@taskless\/cli/); + }); });