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
2 changes: 2 additions & 0 deletions .agents/skills/taskless/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions .changeset/stub-recovery-instruction.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 62 additions & 2 deletions packages/cli/src/install/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,64 @@ function shimMetadata(): Record<string, string> {
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);
Comment thread
theCodeDrift marked this conversation as resolved.
}

/** Serialize ordered frontmatter fields into a `---`-delimited block. */
function frontmatterBlock(fields: Record<string, unknown>): string {
const yaml = stringify(fields).trimEnd();
Expand All @@ -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)
);
}

Expand All @@ -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)
);
}

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildSkillStub,
isShimStub,
stubFrontmatterDrifted,
stubPredatesRecovery,
writeCanonicalCommand,
writeCanonicalSkill,
type CommandStubFrontmatter,
Expand Down Expand Up @@ -350,6 +351,13 @@ async function unlinkIfSymlink(path: string): Promise<void> {
* 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,
Expand All @@ -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);
}

Expand Down
50 changes: 50 additions & 0 deletions packages/cli/test/apply-install-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading