From 6e2d5f9fdf4e4206c2f3e24692ef2bdb15938730 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 28 Aug 2026 09:45:51 -0700 Subject: [PATCH 1/2] fix(cli): give a reference stub a recovery path A stub whose canonical file is absent sent the reader to a path that does not exist and said nothing more. Both stub builders now name the command that restores it, in this build's own invocation rather than a hardcoded `npx @taskless/cli`, and `init` rather than a bare run so it installs without a TTY. A stub already on disk is rewritten once. Fixes #200 --- .agents/skills/taskless/SKILL.md | 2 + .changeset/stub-recovery-instruction.md | 35 ++++++++++ .../stub-recovery-instruction/proposal.md | 58 +++++++++++++++++ .../specs/cli-init/spec.md | 38 +++++++++++ .../stub-recovery-instruction/tasks.md | 22 +++++++ packages/cli/src/install/canonical.ts | 64 +++++++++++++++++- packages/cli/src/install/install.ts | 9 +++ packages/cli/test/apply-install-plan.test.ts | 50 ++++++++++++++ packages/cli/test/canonical-store.test.ts | 65 +++++++++++++++++++ 9 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 .changeset/stub-recovery-instruction.md create mode 100644 openspec/changes/stub-recovery-instruction/proposal.md create mode 100644 openspec/changes/stub-recovery-instruction/specs/cli-init/spec.md create mode 100644 openspec/changes/stub-recovery-instruction/tasks.md diff --git a/.agents/skills/taskless/SKILL.md b/.agents/skills/taskless/SKILL.md index f705205b..b534f646 100644 --- a/.agents/skills/taskless/SKILL.md +++ b/.agents/skills/taskless/SKILL.md @@ -25,3 +25,5 @@ metadata: This is a Taskless reference stub. The canonical skill is defined at `.taskless/skills/taskless/SKILL.md`. Read `.taskless/skills/taskless/SKILL.md` and follow its instructions. + +If `.taskless/skills/taskless/SKILL.md` does not exist, run `npx @taskless/cli init` from the project root to restore it, then read it. diff --git a/.changeset/stub-recovery-instruction.md b/.changeset/stub-recovery-instruction.md new file mode 100644 index 00000000..52ded902 --- /dev/null +++ b/.changeset/stub-recovery-instruction.md @@ -0,0 +1,35 @@ +--- +"@taskless/cli": patch +--- + +Tell the reader how to restore a canonical file a reference stub cannot find. + +The stub written into `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` was +two sentences: this is a stub, read `.taskless/skills//SKILL.md`. When +that canonical file is not on disk, the agent does not fail to find a skill. It +finds the skill, follows it to a path that does not exist, and the stub says +nothing about what to do next. Command stubs had the identical shape and the +identical dead end. + +Two ordinary situations produce it. An install writes untracked files, so a +worktree created before they are committed has the stub and not the canonical +file, which is how it was first hit. And a project that ignores +`.taskless/skills/` commits the stub and never the canonical file, permanently. +Nothing in the CLI causes the second case: `addToGitignore` is only ever called +with `.env.local.json`, `/sgconfig.yml`, and `.run/`. A repository that builds +the CLI makes that choice for itself, and this one does. + +Both stubs now carry one more line naming the command that restores the file. +The command is `init` rather than a bare run, because a bare invocation +installs only from a TTY; without one it prints a preamble and hands off to +`agent`, which is precisely the context an agent reading a stub is in. And it +is this build's invocation rather than a hardcoded `npx @taskless/cli`, passed +through the same rewrite canonical content uses, so a `dev` or `self` build +names its own binary and a nightly names the nightly instead of sending someone +to install the released package over it. + +Stub frontmatter still carries no version, and nothing added here varies per +release, so the footprint outside `.taskless` moves once and then holds. A stub +already on disk is rewritten once by the next install, detected on a fragment +of the sentence that is the same in every build so a prod install and a local +one do not rewrite each other's stubs on every run. diff --git a/openspec/changes/stub-recovery-instruction/proposal.md b/openspec/changes/stub-recovery-instruction/proposal.md new file mode 100644 index 00000000..ab062772 --- /dev/null +++ b/openspec/changes/stub-recovery-instruction/proposal.md @@ -0,0 +1,58 @@ +## Why + +A reference stub is the only Taskless file most agents ever discover. Its body +is two sentences: this is a stub, read the canonical file. When the canonical +file is not on disk, the agent does not fail to find a skill. It finds the +skill, follows it to a path that does not exist, and the stub says nothing +about what to do next. + +Two ordinary situations produce that state. An install writes untracked files, +so a second worktree created before they are committed has the stub and not the +canonical file. And a project that ignores `.taskless/skills/`, as this +repository does because it builds the CLI, commits the stub and never the +canonical file, permanently. + +The recovery already exists: running the CLI reinstalls the scaffold. It is +not advertised at the one moment a reader needs it. + +## What Changes + +- **Both stub builders emit a recovery sentence.** Skill stubs and command + stubs gain one line naming the command that restores the canonical file. +- **The command is the build's own.** The sentence is written in the published + `npx @taskless/cli` form and passed through the same invocation rewrite that + canonical content uses, so a `dev`/`self` build names its own binary and a + nightly names the nightly rather than the released package. +- **`init`, not a bare run.** A bare invocation installs only from a TTY; in a + non-interactive context it prints a preamble and hands off to `agent`, which + is exactly the context an agent reading a stub is in. `init` installs in + both. +- **A one-time rewrite for stubs already on disk.** Install rewrites a stub + whose body predates the instruction, detected on a build-independent + fragment so a prod build and a `dev` build never rewrite each other's stubs. + +Stub content still carries nothing that varies per release, so the footprint +outside `.taskless` moves once and then stays put. + +**Delivery is a single PR.** Two builders, one install predicate, tests, and a +spec delta. + +## Capabilities + +### Modified Capabilities + +- `cli-init`: a reference stub tells the reader how to restore a canonical file + that is missing, rather than ending at a path that does not exist. + +## Impact + +- **Modified**: `packages/cli/src/install/canonical.ts` (the recovery sentence + and the migration predicate), `packages/cli/src/install/install.ts` + (`referenceNeedsRewrite`), `packages/cli/test/canonical-store.test.ts`, + `packages/cli/test/apply-install-plan.test.ts`. +- **Modified**: `.agents/skills/taskless/SKILL.md`, this repository's own + committed stub, which is a live instance of the second situation above. +- **Unchanged**: stub frontmatter, which still carries no version, and the + canonical store, which is unaffected. + +**Tracking:** taskless/cli#200 diff --git a/openspec/changes/stub-recovery-instruction/specs/cli-init/spec.md b/openspec/changes/stub-recovery-instruction/specs/cli-init/spec.md new file mode 100644 index 00000000..c7023b11 --- /dev/null +++ b/openspec/changes/stub-recovery-instruction/specs/cli-init/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: A reference stub says how to restore a missing canonical file + +A reference stub's body SHALL state what to do when the canonical file it points at is not present, naming a command that restores it. A stub that only delegates leaves a reader who follows it at a dead end, and the two states that produce a missing canonical file (an install whose untracked files never reached this working directory, and a project that ignores the canonical store) are both reached without anyone doing anything wrong. + +Skill stubs and command stubs SHALL carry the same instruction. + +The command named SHALL be the one that runs the build which wrote the stub, resolved the same way canonical content resolves it. A stub emitted by a development or nightly build SHALL NOT direct the reader to the released package. + +The command named SHALL install without an interactive terminal, since the reader of a stub is typically an agent in a non-interactive context. + +Stub content SHALL carry nothing that varies per release, so that adding this instruction changes the footprint outside the canonical store exactly once. + +A stub already on disk whose body predates this instruction SHALL be rewritten once by the next install, rather than waiting for its frontmatter to change. Detection of such a stub SHALL NOT depend on the build that wrote it, so that builds with different invocations do not rewrite one another's stubs. + +#### Scenario: Stub names the command that restores a missing canonical file + +- **WHEN** the CLI writes a skill stub or a command stub +- **THEN** its body SHALL say what to do if the canonical file does not exist +- **AND** SHALL name a command that restores it + +#### Scenario: A development build points at itself + +- **WHEN** a `dev` or `self` build writes a stub +- **THEN** the command named SHALL be that build's own invocation +- **AND** SHALL NOT be the released package + +#### Scenario: Stub bytes do not move with the CLI version + +- **WHEN** two installs of different CLI versions write a stub for the same skill +- **THEN** the two stubs SHALL be identical + +#### Scenario: An older stub is rewritten once + +- **WHEN** an install finds a stub whose body carries no recovery instruction +- **THEN** the install SHALL rewrite that stub +- **AND** a subsequent install SHALL leave the rewritten stub untouched diff --git a/openspec/changes/stub-recovery-instruction/tasks.md b/openspec/changes/stub-recovery-instruction/tasks.md new file mode 100644 index 00000000..72502f19 --- /dev/null +++ b/openspec/changes/stub-recovery-instruction/tasks.md @@ -0,0 +1,22 @@ +Delivery shape: **single PR**. Two string builders, one install predicate, tests, and a spec delta. No unit of this is meaningful on its own. + +## 1. Fix + +- [x] 1.1 Add the recovery sentence to `buildSkillStub` and `buildCommandStub` +- [x] 1.2 Route the invocation through `applyCliInvocation`, so a `dev`/`self`/`nightly` build names the binary that wrote the stub rather than the released package +- [x] 1.3 Name `init` rather than a bare run, which installs only from a TTY +- [x] 1.4 Add `stubPredatesRecovery` and let `referenceNeedsRewrite` rewrite such a stub once +- [x] 1.5 Update this repository's own committed stub + +## 2. Tests + +- [x] 2.1 Both stubs carry the instruction and name the build's own invocation +- [x] 2.2 Stub bytes do not move with the CLI version +- [x] 2.3 A stub predating the instruction is rewritten; a current one is not + +## 3. Verification + +- [x] 3.1 End to end: install into a scratch directory, delete the canonical file, read the stub +- [x] 3.2 `pnpm build`, `pnpm typecheck`, `pnpm lint`, `pnpm test` +- [x] 3.3 `pnpm openspec validate --all --strict` +- [x] 3.4 Changeset diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index f51f327a..e56e62c3 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -98,6 +98,64 @@ function shimMetadata(): Record { return { type: "shim" }; } +/** + * The command a reader runs to restore a canonical file that is not on disk. + * + * Written in the published `npx @taskless/cli` form and rewritten by + * {@link applyCliInvocation}, exactly as canonical content is. A stub that + * hardcoded the released package would tell someone running a `dev`/`self` + * build to fetch a different binary than the one that wrote the stub, and + * would tell a nightly user to install over their nightly. + * + * `init` rather than a bare run: a bare invocation only installs from a TTY. + * In a non-interactive context it prints a preamble and hands off to `agent`, + * which is precisely the context an agent reading this stub is in. `init` + * installs in both, falling back to a non-interactive install when there is + * no TTY. + * + * Nothing here varies per release for a prod build, where + * {@link applyCliInvocation} is a no-op, so the stub footprint outside + * `.taskless` stays byte-stable (see {@link shimMetadata}). + */ +function restoreCommand(): string { + return applyCliInvocation("npx @taskless/cli init"); +} + +/** + * The build-independent tail of the recovery sentence, shared between the + * builders and {@link stubPredatesRecovery} so the two cannot drift apart. + * + * Detection deliberately keys on this fragment rather than on the whole + * sentence: the invocation inside it differs between a prod build and a + * `dev`/`self` one, and matching on the full text would make each build treat + * the other's stub as stale and rewrite it on every install. + */ +const RECOVERY_TAIL = "to restore it, then read it."; + +/** + * The sentence that turns a missing canonical file from a dead end into a + * recoverable state. Without it a stub sends the reader to a path that may not + * exist (an install whose untracked files never reached this worktree, or a + * project that ignores `.taskless/skills/`) and says nothing about what to do + * there. See taskless/cli#200. + */ +function recoveryInstruction(canonical: string): string { + return ( + `If \`${canonical}\` does not exist, run \`${restoreCommand()}\` from the ` + + `project root ${RECOVERY_TAIL}\n` + ); +} + +/** + * Whether an existing stub was written before stubs carried a recovery + * instruction. Used by install as a one-time migration, in the same spirit as + * the `metadata.version` strip in {@link stubFrontmatterDrifted}: such a stub + * is rewritten once, after which its body is stable again. + */ +export function stubPredatesRecovery(content: string): boolean { + return !parseFrontmatter(content).content.includes(RECOVERY_TAIL); +} + /** Serialize ordered frontmatter fields into a `---`-delimited block. */ function frontmatterBlock(fields: Record): string { const yaml = stringify(fields).trimEnd(); @@ -120,7 +178,8 @@ export function buildSkillStub(meta: StubFrontmatter): string { "\n" + `This is a Taskless reference stub. The canonical skill is defined at ` + `\`${canonical}\`.\n\n` + - `Read \`${canonical}\` and follow its instructions.\n` + `Read \`${canonical}\` and follow its instructions.\n\n` + + recoveryInstruction(canonical) ); } @@ -147,7 +206,8 @@ export function buildCommandStub( `This is a Taskless reference stub. The canonical command is defined at ` + `\`${canonical}\`.\n\n` + `Read \`${canonical}\` and follow its instructions, treating the text ` + - `above as the command arguments.\n` + `above as the command arguments.\n\n` + + recoveryInstruction(canonical) ); } diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index b3152c3e..1eda8aee 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -6,6 +6,7 @@ import { buildSkillStub, isShimStub, stubFrontmatterDrifted, + stubPredatesRecovery, writeCanonicalCommand, writeCanonicalSkill, type CommandStubFrontmatter, @@ -350,6 +351,13 @@ async function unlinkIfSymlink(path: string): Promise { * That converges anything stale onto the canonical-plus-stub layout — * a missing file, a full copy left by an older install, a symlink, or a * stub whose frontmatter has drifted. + * + * A stub written before stubs carried a recovery instruction is rewritten too. + * That is the one case here where the BODY decides, and it has to: the reason + * a stub without the instruction is a problem is that its canonical target may + * be absent, and an already-installed project is exactly where that happens. + * Waiting for a `name`/`description` change to carry the fix would leave those + * projects on a dead-end stub indefinitely. */ async function referenceNeedsRewrite( path: string, @@ -365,6 +373,7 @@ async function referenceNeedsRewrite( const existing = await readFile(path, "utf8").catch(() => {}); if (existing === undefined) return true; if (!isShimStub(existing)) return true; // a full copy — convert it + if (stubPredatesRecovery(existing)) return true; // one-time body migration return stubFrontmatterDrifted(existing, meta); } diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index 855ac6ef..8b799975 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -222,6 +222,56 @@ describe("applyInstallPlan", () => { } }); + it("writes a stub whose bytes do not move with the CLI version", async () => { + // The stub footprint outside `.taskless` is deliberately version-free, so + // two installs of different CLI versions must produce identical bytes. + const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + const stubPath = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + const first = await readFile(stubPath, "utf8"); + + const other = await mkdtemp(join(tmpdir(), "taskless-apply-")); + try { + await seedTasklessDirectory(other); + await applyInstallPlan(other, plan, { cliVersion: "99.0.0" }); + const second = await readFile( + join(other, ".claude", "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(second).toBe(first); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); + + it("rewrites a stub that predates the recovery instruction", async () => { + const skill = tasklessSkill(); + const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + // A stub from an older CLI: current frontmatter, dead-end body. + const legacy = [ + "---", + "name: taskless", + `description: ${JSON.stringify(skill.description)}`, + "metadata:", + " type: shim", + "---", + "", + "Read `.taskless/skills/taskless/SKILL.md` and follow its instructions.", + "", + ].join("\n"); + await mkdir(dirname(claudeSkill), { recursive: true }); + await writeFile(claudeSkill, legacy, "utf8"); + + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + cliVersion: "0.7.0", + }); + + expect( + parseFrontmatter(await readFile(claudeSkill, "utf8")).content + ).toContain("does not exist"); + }); + it("converts a full per-tool copy into a shim stub", async () => { const skill = tasklessSkill(); const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index 1acce5ea..ae2bcb75 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -8,10 +8,15 @@ import { buildSkillStub, isShimStub, stubFrontmatterDrifted, + stubPredatesRecovery, writeCanonicalCommand, writeCanonicalSkill, } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; +import { + buildInvocation, + isProductionInvocation, +} from "../src/util/invocation"; const SENTINEL = "INLINED-CANONICAL-BODY-MARKER"; @@ -79,6 +84,23 @@ describe("buildSkillStub", () => { expect(stub).not.toContain(SENTINEL); }); + it("tells the reader how to restore a canonical file that is missing", () => { + const stub = buildSkillStub({ name: "taskless", description: "d" }); + const { content } = parseFrontmatter(stub); + expect(content).toContain("does not exist"); + // The invocation is the build's own, so a dev/self build points at its own + // binary rather than sending the reader to the published package. + expect(content).toContain(`\`${buildInvocation()} init\``); + }); + + it("embeds nothing that varies per release", () => { + const stub = buildSkillStub({ name: "taskless", description: "d" }); + // The stub lives outside `.taskless`, so its bytes must not move when only + // the CLI version does. A prod build's invocation carries no version. + expect(isProductionInvocation()).toBe(true); + expect(stub).not.toContain(__VERSION__); + }); + it("carries metadata.type shim and records no version", () => { const stub = buildSkillStub({ name: "taskless", description: "d" }); const metadata = parseFrontmatter(stub).data.metadata as { @@ -119,6 +141,16 @@ describe("buildCommandStub", () => { expect(content).toContain(".taskless/commands/tskl/tskl.md"); }); + it("carries the same recovery instruction as a skill stub", () => { + const stub = buildCommandStub( + { name: "Taskless", description: "Run any Taskless action." }, + "tskl.md" + ); + const { content } = parseFrontmatter(stub); + expect(content).toContain("does not exist"); + expect(content).toContain(`\`${buildInvocation()} init\``); + }); + it("preserves the canonical argument-hint when present", () => { const stub = buildCommandStub( { @@ -205,3 +237,36 @@ describe("stubFrontmatterDrifted", () => { expect(stubFrontmatterDrifted(legacyStub, meta)).toBe(true); }); }); + +describe("stubPredatesRecovery", () => { + it("returns false for a stub generated now", () => { + expect( + stubPredatesRecovery( + buildSkillStub({ name: "taskless", description: "d" }) + ) + ).toBe(false); + expect( + stubPredatesRecovery( + buildCommandStub({ name: "Taskless", description: "d" }, "tskl.md") + ) + ).toBe(false); + }); + + it("returns true for a stub written before the recovery instruction", () => { + const legacyStub = [ + "---", + "name: taskless", + "description: d", + "metadata:", + " type: shim", + "---", + "", + "This is a Taskless reference stub. The canonical skill is defined at", + "`.taskless/skills/taskless/SKILL.md`.", + "", + "Read `.taskless/skills/taskless/SKILL.md` and follow its instructions.", + "", + ].join("\n"); + expect(stubPredatesRecovery(legacyStub)).toBe(true); + }); +}); From 077e4f23cf99cdeeec025374321f3468c42cc7b0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 28 Aug 2026 09:53:36 -0700 Subject: [PATCH 2/2] docs(openspec): archive the stub recovery change The change ships as a single PR, so the spec, the implementation, and the archive land together. The `cli-init` delta is applied into the main spec and the change directory moves under `archive/`, keeping `main` clean on merge. --- .../proposal.md | 0 .../specs/cli-init/spec.md | 0 .../tasks.md | 0 openspec/specs/cli-init/spec.md | 37 +++++++++++++++++++ 4 files changed, 37 insertions(+) rename openspec/changes/{stub-recovery-instruction => archive/2026-08-28-stub-recovery-instruction}/proposal.md (100%) rename openspec/changes/{stub-recovery-instruction => archive/2026-08-28-stub-recovery-instruction}/specs/cli-init/spec.md (100%) rename openspec/changes/{stub-recovery-instruction => archive/2026-08-28-stub-recovery-instruction}/tasks.md (100%) diff --git a/openspec/changes/stub-recovery-instruction/proposal.md b/openspec/changes/archive/2026-08-28-stub-recovery-instruction/proposal.md similarity index 100% rename from openspec/changes/stub-recovery-instruction/proposal.md rename to openspec/changes/archive/2026-08-28-stub-recovery-instruction/proposal.md diff --git a/openspec/changes/stub-recovery-instruction/specs/cli-init/spec.md b/openspec/changes/archive/2026-08-28-stub-recovery-instruction/specs/cli-init/spec.md similarity index 100% rename from openspec/changes/stub-recovery-instruction/specs/cli-init/spec.md rename to openspec/changes/archive/2026-08-28-stub-recovery-instruction/specs/cli-init/spec.md diff --git a/openspec/changes/stub-recovery-instruction/tasks.md b/openspec/changes/archive/2026-08-28-stub-recovery-instruction/tasks.md similarity index 100% rename from openspec/changes/stub-recovery-instruction/tasks.md rename to openspec/changes/archive/2026-08-28-stub-recovery-instruction/tasks.md diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 62b05a1d..724cbf4d 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -683,3 +683,40 @@ When a prior install left a full skill or command copy in a tool directory, or l - **WHEN** `taskless update` finds `.claude/skills/taskless` recorded as a target but present on disk as a symlink - **THEN** update SHALL replace the symlink with a real reference stub file - **AND** SHALL NOT write through the symlink into another directory + +### Requirement: A reference stub says how to restore a missing canonical file + +A reference stub's body SHALL state what to do when the canonical file it points at is not present, naming a command that restores it. A stub that only delegates leaves a reader who follows it at a dead end, and the two states that produce a missing canonical file (an install whose untracked files never reached this working directory, and a project that ignores the canonical store) are both reached without anyone doing anything wrong. + +Skill stubs and command stubs SHALL carry the same instruction. + +The command named SHALL be the one that runs the build which wrote the stub, resolved the same way canonical content resolves it. A stub emitted by a development or nightly build SHALL NOT direct the reader to the released package. + +The command named SHALL install without an interactive terminal, since the reader of a stub is typically an agent in a non-interactive context. + +Stub content SHALL carry nothing that varies per release, so that adding this instruction changes the footprint outside the canonical store exactly once. + +A stub already on disk whose body predates this instruction SHALL be rewritten once by the next install, rather than waiting for its frontmatter to change. Detection of such a stub SHALL NOT depend on the build that wrote it, so that builds with different invocations do not rewrite one another's stubs. + +#### Scenario: Stub names the command that restores a missing canonical file + +- **WHEN** the CLI writes a skill stub or a command stub +- **THEN** its body SHALL say what to do if the canonical file does not exist +- **AND** SHALL name a command that restores it + +#### Scenario: A development build points at itself + +- **WHEN** a `dev` or `self` build writes a stub +- **THEN** the command named SHALL be that build's own invocation +- **AND** SHALL NOT be the released package + +#### Scenario: Stub bytes do not move with the CLI version + +- **WHEN** two installs of different CLI versions write a stub for the same skill +- **THEN** the two stubs SHALL be identical + +#### Scenario: An older stub is rewritten once + +- **WHEN** an install finds a stub whose body carries no recovery instruction +- **THEN** the install SHALL rewrite that stub +- **AND** a subsequent install SHALL leave the rewritten stub untouched