Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/lucky-moons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@taskless/cli": patch
---

Reclaim a reference stub whose recovery instruction names a CLI build you are no longer running.

Every stub outside `.taskless` ends with the line that makes a missing canonical file recoverable: "If `<path>` does not exist, run `<command>` from the project root to restore it, then read it." That command was frozen at whichever build wrote the stub. Install a nightly once and go back to the released CLI and every install afterwards reported "up to date" while the line kept pointing at `npx @taskless/cli-nightly@<pinned>` — a version that may no longer be published, in exactly the situation where the reader has nothing else to fall back on.

An install now rewrites a stub whose recovery command names a build other than its own, with one exception: the released, version-free `npx @taskless/cli init` is left alone by every build. It resolves for any reader, so a nightly has no reason to replace it, and the released and nightly builds do not rewrite each other's stubs on repeat installs. Stub bytes written by a released build are unchanged.
13 changes: 13 additions & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,19 @@ Stub content SHALL carry nothing that varies per release, so that adding this in

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.

An install SHALL also reclaim a stub whose recovery command names a build other than the one installing, EXCEPT when the command named is the released, version-free one. The released form resolves for every reader, so leaving it in place is what keeps a released build and a nightly from rewriting one another's stub on every install; anything else names a build that may not be reachable at the moment the reader needs it, and is replaced.

#### Scenario: A pinned nightly recovery command is reclaimed by a later install

- **WHEN** an install finds a stub whose recovery command names a version-pinned nightly other than the build installing
- **THEN** the install SHALL rewrite that stub with its own recovery command

#### Scenario: A released recovery command survives a nightly install

- **WHEN** a nightly build installs over a stub whose recovery command is the released, version-free one
- **THEN** the install SHALL leave that stub untouched
- **AND** a subsequent install by either build SHALL leave it untouched

#### Scenario: Stub names the command that restores a missing canonical file

- **WHEN** the CLI writes a skill stub or a command stub
Expand Down
103 changes: 92 additions & 11 deletions packages/cli/src/install/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { join } from "node:path";

import { stringify } from "yaml";

import { applyCliInvocation, withCliBuildNotice } from "../util/invocation";
import {
applyCliInvocation,
PROD_INVOCATION,
withCliBuildNotice,
} from "../util/invocation";
import { parseFrontmatter } from "./frontmatter";

/**
Expand Down Expand Up @@ -98,14 +102,29 @@ function shimMetadata(): Record<string, string> {
return { type: "shim" };
}

/**
* The recovery invocation a released build writes, and the one form of it that
* every build accepts from every other.
*
* It names no version and no machine-local path, so it resolves for anyone,
* forever. That is what makes it the resting state of
* {@link stubRecoveryInvocationStale}: a build whose own invocation differs
* still leaves it alone, which is what keeps prod and a nightly from rewriting
* each other's stub on every install.
*/
const PROD_RESTORE_COMMAND = `${PROD_INVOCATION} init`;

/**
* 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.
* hardcoded the released package would tell someone running a `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.
*
* Because this is baked into the stub body, it is frozen at whichever build
* wrote the file. {@link stubRecoveryInvocationStale} is what unfreezes it.
*
* `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`,
Expand All @@ -118,20 +137,28 @@ function shimMetadata(): Record<string, string> {
* `.taskless` stays byte-stable (see {@link shimMetadata}).
*/
function restoreCommand(): string {
return applyCliInvocation("npx @taskless/cli init");
return applyCliInvocation(PROD_RESTORE_COMMAND);
}

/**
* 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.
* Detection of a *pre-recovery* stub keys on this fragment rather than on the
* whole sentence: the invocation inside it differs between a prod build and a
* `nightly`/`self` one, and treating the full text as the staleness test would
* make each build treat the other's stub as stale and rewrite it on every
* install. The invocation is compared separately and asymmetrically, by
* {@link stubRecoveryInvocationStale}.
*/
const RECOVERY_TAIL = "to restore it, then read it.";

/** The literal that opens the recovery sentence's backtick-quoted command. */
const RECOVERY_RUN = "run `";

/** The literal between that command's closing backtick and the tail. */
const RECOVERY_AFTER_COMMAND = "` from the project root ";

/**
* 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
Expand All @@ -141,8 +168,8 @@ const RECOVERY_TAIL = "to restore it, then read it.";
*/
function recoveryInstruction(canonical: string): string {
return (
`If \`${canonical}\` does not exist, run \`${restoreCommand()}\` from the ` +
`project root ${RECOVERY_TAIL}\n`
`If \`${canonical}\` does not exist, ${RECOVERY_RUN}${restoreCommand()}` +
`${RECOVERY_AFTER_COMMAND}${RECOVERY_TAIL}\n`
);
}

Expand All @@ -156,6 +183,60 @@ export function stubPredatesRecovery(content: string): boolean {
return !parseFrontmatter(content).content.includes(RECOVERY_TAIL);
}

/**
* The invocation recorded inside an existing stub's recovery sentence, or
* `undefined` when the stub has no recovery sentence at all (see
* {@link stubPredatesRecovery}, which is what handles that case).
*
* The stub already carries the writing build's invocation in plain text, so
* this reads it back rather than adding a frontmatter field to record it a
* second time. Keeping it out of the frontmatter is what lets the fix land
* with the prod stub's bytes completely unchanged — no field to add, and so no
* migration rewrite for the installs that are already correct.
*/
export function stubRecoveryInvocation(content: string): string | undefined {
const body = parseFrontmatter(content).content;
const end = body.indexOf(`${RECOVERY_AFTER_COMMAND}${RECOVERY_TAIL}`);
if (end === -1) return undefined;
const start = body.lastIndexOf(RECOVERY_RUN, end);
if (start === -1) return undefined;
return body.slice(start + RECOVERY_RUN.length, end);
}

/**
* Whether an existing stub's recovery invocation must be reclaimed by this
* build. See taskless/cli#227.
*
* The recovery sentence is the one line a reader reaches for when the canonical
* file is already gone, and until now the invocation inside it was frozen at
* whichever build wrote the stub first. Install a nightly once and go back to
* the released CLI and every later install reported "up to date" while the stub
* kept pointing at `npx @taskless/cli-nightly@<pinned>` — a version that may no
* longer be published, in the one situation where it has to work.
*
* The test is deliberately ASYMMETRIC, which is what keeps it from
* reintroducing the cross-build rewrite loop `RECOVERY_TAIL` exists to prevent:
*
* - An invocation equal to this build's own is current. Nothing to do.
* - {@link PROD_RESTORE_COMMAND} is accepted by EVERY build, released or not.
* It carries no version and no path, so it resolves for any reader; a nightly
* has no reason to overwrite it.
* - Anything else names a build this one is not — another nightly's pin, a
* `self` path from someone else's checkout — and is rewritten.
*
* So the released form is a fixed point that every build converges on and none
* moves away from: a prod install reclaims a nightly-written stub exactly once,
* and a nightly install afterwards leaves the result alone. Two *different*
* nightlies do rewrite each other, and should — the alternative is leaving a
* pin from a build that is not present, which is the defect itself.
*/
export function stubRecoveryInvocationStale(content: string): boolean {
const recorded = stubRecoveryInvocation(content);
if (recorded === undefined) return false;
if (recorded === restoreCommand()) return false;
return recorded !== PROD_RESTORE_COMMAND;
}

/** Serialize ordered frontmatter fields into a `---`-delimited block. */
function frontmatterBlock(fields: Record<string, unknown>): string {
const yaml = stringify(fields).trimEnd();
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isShimStub,
stubFrontmatterDrifted,
stubPredatesRecovery,
stubRecoveryInvocationStale,
writeCanonicalCommand,
writeCanonicalSkill,
type CommandStubFrontmatter,
Expand Down Expand Up @@ -445,6 +446,11 @@ async function referenceNeedsRewrite(
if (existing === undefined) return true;
if (!isShimStub(existing)) return true; // a full copy — convert it
if (stubPredatesRecovery(existing)) return true; // one-time body migration
// The recovery invocation is baked into the body too, and is frozen at
// whichever build wrote the stub — so without this a project that installed a
// nightly once keeps a pinned nightly in its recovery line forever, however
// many released installs follow. See taskless/cli#227.
if (stubRecoveryInvocationStale(existing)) return true;
return stubFrontmatterDrifted(existing, meta);
}

Expand Down
32 changes: 31 additions & 1 deletion packages/cli/test/apply-install-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
getEmbeddedCommands,
getEmbeddedSkills,
} from "../src/install/install";
import { isShimStub } from "../src/install/canonical";
import { isShimStub, stubRecoveryInvocation } from "../src/install/canonical";
import { parseFrontmatter } from "../src/install/frontmatter";
import { readInstallState, writeInstallState } from "../src/install/state";

Expand Down Expand Up @@ -272,6 +272,36 @@ describe("applyInstallPlan", () => {
).toContain("does not exist");
});

it("reclaims a stub whose recovery line names a pinned nightly", async () => {
// taskless/cli#227. Reproduced by hand as: nightly `init`, then a released
// `init` in the same directory — the canonical files and
// `install.cliVersion` reverted to the release while the stub kept
// `npx @taskless/cli-nightly@<pinned> init` in the one line a reader
// reaches for when the canonical file is already missing.
const skill = tasklessSkill();
const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md");
const plan = buildInstallPlan([".claude"], [skill], []);

await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" });
const written = await readFile(claudeSkill, "utf8");
const current = stubRecoveryInvocation(written);
if (current === undefined) throw new Error("stub carries no recovery line");
const nightly = "npx @taskless/cli-nightly@0.11.0-nightly.20260101 init";
await writeFile(
claudeSkill,
written.replace(`run \`${current}\``, `run \`${nightly}\``),
"utf8"
);

const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" });

expect(result.writtenSkills).toContainEqual({
target: ".claude",
skill: "taskless",
});
expect(await readFile(claudeSkill, "utf8")).toBe(written);
});

it("converts a full per-tool copy into a shim stub", async () => {
const skill = tasklessSkill();
const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md");
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/test/canonical-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
isShimStub,
stubFrontmatterDrifted,
stubPredatesRecovery,
stubRecoveryInvocation,
stubRecoveryInvocationStale,
writeCanonicalCommand,
writeCanonicalSkill,
} from "../src/install/canonical";
Expand Down Expand Up @@ -270,3 +272,66 @@ describe("stubPredatesRecovery", () => {
expect(stubPredatesRecovery(legacyStub)).toBe(true);
});
});

/** A copy of `stub` whose recovery line names `invocation` instead. */
function withRecoveryInvocation(stub: string, invocation: string): string {
const current = stubRecoveryInvocation(stub);
if (current === undefined) throw new Error("stub carries no recovery line");
return stub.replace(`run \`${current}\``, `run \`${invocation}\``);
}

describe("stubRecoveryInvocation / stubRecoveryInvocationStale", () => {
const meta = { name: "taskless", description: "Use for any Taskless task." };
// This project runs under a released define, so a stub written here carries
// the released invocation. The nightly side of the same behaviour lives in
// test/nightly/stub-recovery-invocation.test.ts.
const productionRestore = `${buildInvocation()} init`;

it("reads the invocation back out of a stub it wrote", () => {
expect(stubRecoveryInvocation(buildSkillStub(meta))).toBe(
productionRestore
);
expect(stubRecoveryInvocation(buildCommandStub(meta, "tskl.md"))).toBe(
productionRestore
);
});

it("returns undefined for a stub that predates the recovery instruction", () => {
expect(
stubRecoveryInvocation("---\nname: t\n---\n\nbody\n")
).toBeUndefined();
});

it("leaves a stub written by this same build alone", () => {
expect(stubRecoveryInvocationStale(buildSkillStub(meta))).toBe(false);
expect(stubRecoveryInvocationStale(buildCommandStub(meta, "tskl.md"))).toBe(
false
);
});

it("reclaims a stub frozen with a pinned nightly invocation", () => {
// taskless/cli#227: try a nightly once, go back to the released CLI, and
// every later install reported "up to date" while the recovery line kept
// naming a nightly version that may no longer be published.
const frozen = withRecoveryInvocation(
buildSkillStub(meta),
"npx @taskless/cli-nightly@0.11.0-nightly.20260101 init"
);
expect(stubRecoveryInvocationStale(frozen)).toBe(true);
});

it("reclaims a stub frozen with a self build's filesystem path", () => {
const frozen = withRecoveryInvocation(
buildSkillStub(meta),
"node packages/cli/dist-self/index.js init"
);
expect(stubRecoveryInvocationStale(frozen)).toBe(true);
});

it("says nothing about a stub that has no recovery line at all", () => {
// That case belongs to stubPredatesRecovery, which rewrites it anyway.
expect(stubRecoveryInvocationStale("---\nname: t\n---\n\nbody\n")).toBe(
false
);
});
});
Loading
Loading