Skip to content

fix(install): reclaim a stub frozen with another build's recovery command - #237

Merged
theCodeDrift merged 1 commit into
mainfrom
fix/227-stub-recovery-invocation
Sep 2, 2026
Merged

theCodeDrift merged 1 commit into
mainfrom
fix/227-stub-recovery-invocation

Conversation

@theCodeDrift

Copy link
Copy Markdown
Member

The defect

Every reference stub outside .taskless (.claude/skills/taskless/SKILL.md and friends) ends with the line that turns a missing canonical file from a dead end into a recoverable state:

If .taskless/skills/taskless/SKILL.md does not exist, run npx @taskless/cli init from the project root to restore it, then read it.

The invocation inside that sentence comes from applyCliInvocation, so it is whatever the build that wrote the stub was. But nothing ever looked at it again. referenceNeedsRewrite rewrites a stub when it is missing, a symlink, not a shim, missing the recovery tail, or has drifted name/description; the body probe, stubPredatesRecovery, matches only the build-independent fragment "to restore it, then read it.".

That fragment is deliberate — matching the whole sentence would make a released build and a nightly treat each other's stubs as stale and rewrite on every install. The unconsidered cost is that the invocation is frozen at whichever build wrote the stub, and no later install can correct it.

The result is a recovery instruction naming a possibly-unpublished nightly, reachable only when the canonical file is already missing, which is exactly when it has to work.

It also violated openspec/specs/cli-init/spec.md: two installs of different CLI versions SHALL write identical stubs.

Reproduction

TASKLESS_NIGHTLY_VERSION=0.11.0-nightly.20260901 pnpm --filter @taskless/cli build:nightly
cd /tmp/repro && node …/dist/index.js init     # nightly install
pnpm --filter @taskless/cli build              # back to a released build
node …/dist/index.js init                      # released install, same directory

Before this change, the second install reported the reload notice and rewrote the canonical store — install.cliVersion went from 0.11.0-nightly.20260901 back to 0.11.0 — while the stub kept:

… run `npx @taskless/cli-nightly@0.11.0-nightly.20260901 init` from the project root …

permanently, however many released installs followed.

Why no test caught it

__TASKLESS_CLI__ is a compile-time define, so whatever vite.config.ts resolves is what every test in the run sees. test/canonical-store.test.ts asserts isProductionInvocation() is true outright, so the entire suite ran under a released build where the recovery invocation carries no version and this divergence cannot be expressed, let alone asserted on. The nightly and self stub paths had no coverage at all.

What changed

stubRecoveryInvocationStale reads the command back out of an existing stub's recovery sentence and compares it to this build's, asymmetrically:

Recorded invocation Verdict
This build's own current, leave it
The released, version-free npx @taskless/cli init leave it — accepted by every build
Anything else (another nightly's pin, a self path) stale, rewrite

That makes the released form a fixed point every build converges on and none moves away from. A released install reclaims a nightly-written stub exactly once; a nightly install afterwards leaves the result alone. Two different nightlies do rewrite each other, and should — the alternative is leaving a pin for a build that is not present, which is the defect itself.

Design notes:

  • Read from the body, not a new frontmatter field. The stub already carries the writing build's invocation in plain text. Recording it a second time in frontmatter would change a released build's stub bytes and force a migration rewrite across every already-correct installation. Reading it back leaves released stub bytes completely unchanged, which is also what keeps the "stub content outside .taskless is byte-stable across releases" rule intact.
  • No resolvability probe. An earlier sketch was to record the writing build's specifier and treat a mismatch as drift only when the recorded specifier does not resolve. That needs a registry lookup at install time and turns an offline install into a rewrite. The asymmetric comparison above answers the same question — "will this command still work for whoever reads it?" — from information already on disk: the released form always resolves, a version pin may not.
  • The recovery sentence is now assembled from shared RECOVERY_RUN / RECOVERY_AFTER_COMMAND / RECOVERY_TAIL constants, so the builder and the probe cannot drift apart.

Verification that the ping-pong did not return

End-to-end, against real build:nightly and build artifacts in one directory, hashing the stub at each step:

Step Build Recovery line after Hash
1 nightly npx @taskless/cli-nightly@0.11.0-nightly.20260901 init —
2 released npx @taskless/cli init (reclaimed) 86a883d…
3 released unchanged 86a883d…
4 nightly unchanged — no stub written 86a883d…
5 nightly unchanged 86a883d…
6 released unchanged 86a883d…

Step 4 is the one that matters: the nightly install reported zero stub writes over a released-build stub. After one reclaiming rewrite the file is stable under either build, in either order, indefinitely.

The unit and integration tests were also confirmed to fail with the referenceNeedsRewrite hook disabled and pass with it, so they bite.

Test coverage

vite.config.ts now declares two vitest projects, so vitest run exercises both defines in one command:

  • cli — the existing suite, released define, unchanged behaviour.
  • nightly — test/nightly/, built from resolveCliInvocation({ TASKLESS_BUILD_TARGET: "nightly", … }) so the define is what build:nightly would really emit rather than a lookalike string.

New tests: test/nightly/stub-recovery-invocation.test.ts (nightly writes its own pin; leaves a released stub alone through a real applyInstallPlan round trip; reclaims another nightly's pin and a self path; is idempotent), plus released-side unit tests in test/canonical-store.test.ts and an end-to-end reclaim test in test/apply-install-plan.test.ts.

Checks

pnpm build, pnpm typecheck, pnpm lint, pnpm --filter @taskless/cli exec vitest run (71 files, 1156 tests), and pnpm cli check all clean — check reports only the 4 pre-existing no-hedging warnings in route.txt/onboard.txt.

openspec/specs/cli-init/spec.md gains the requirement text and two scenarios describing reclamation and the released form's immunity, since the existing requirement said detection must not depend on the writing build.

Fixes #227

@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Claude finished @theCodeDrift's task in 4m 9s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json held no threads, reviews, or top-level comments other than the trigger comment itself, so this is the first review of this PR.

  • Read prior review data
  • Fetch PR diff and metadata
  • Read changed files in full context
  • Run correctness/security/perf/test/clarity review angles
  • Verify candidate findings
  • Post final review

Files reviewed in full: packages/cli/src/install/canonical.ts, packages/cli/src/install/install.ts, packages/cli/src/util/invocation.ts, packages/cli/src/install/frontmatter.ts, packages/cli/scripts/build-target.ts, packages/cli/vite.config.ts, both test files touched/added, and the OpenSpec/changeset additions.

Correctness. I traced stubRecoveryInvocation/stubRecoveryInvocationStale by hand against every build-pair combination (prod↔prod, prod↔nightly, nightly↔nightly-different-version, self↔prod, self↔nightly):

  • The extraction (RECOVERY_RUN + lastIndexOf / RECOVERY_AFTER_COMMAND+RECOVERY_TAIL + indexOf) correctly round-trips the exact string recoveryInstruction() emits — confirmed the concatenation produces byte-identical output to the pre-PR template literal, so no drift for released stubs.
  • The asymmetric staleness check is sound: recorded === restoreCommand() (own build) → not stale; recorded === PROD_RESTORE_COMMAND (released form) → not stale for any build; anything else → stale. This is exactly the fixed-point behavior the PR describes, and I couldn't construct a case where it reintroduces the prod/nightly ping-pong.
  • referenceNeedsRewrite in install.ts wires the new check in correctly, after the existing stubPredatesRecovery short-circuit and before stubFrontmatterDrifted — single call site, no other caller needed updating.
  • vite.config.ts's new nightly vitest project derives its defines through the real resolveCliVersion/resolveCliInvocation/resolveCliNotice functions rather than hardcoding a lookalike string, and correctly excludes test/nightly/** from the cli project so nothing double-runs.

No correctness, security, or test-adequacy bugs found. The new tests (test/nightly/stub-recovery-invocation.test.ts, plus the additions to canonical-store.test.ts and apply-install-plan.test.ts) exercise the scenarios that matter: own-build stub left alone, released form immune to every build, a differing pin/path reclaimed, and idempotency on a second install.

One minor finding posted inline (not a bug): withRecoveryInvocation and the taskless skill metadata fixture are duplicated verbatim between test/canonical-store.test.ts and test/nightly/stub-recovery-invocation.test.ts. Worth a shared test helper so the two copies don't quietly diverge, but doesn't block anything.

Branch: fix/227-stub-recovery-invocation

Comment thread packages/cli/test/nightly/stub-recovery-invocation.test.ts
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @theCodeDrift's task in 4m 9s — Review complete. Review mode: incremental…"
#237 (comment)

No correctness findings to act on, and the one minor finding (the duplicated withRecoveryInvocation helper and META fixture across the two vitest projects) is left for the author to decide — it is deliberate duplication across a project boundary the split exists to create, and both copies call the same exported stubRecoveryInvocation, so a shape change breaks them together rather than letting them drift silently.

I did re-verify the load-bearing property empirically rather than by reading the code, using real build:nightly and build artifacts installed alternately into the same project and hashing .claude/skills/taskless/SKILL.md after each run:

  • nightly → prod → prod → nightly → prod → nightly: exactly one rewrite (the nightly pin reclaimed to npx @taskless/cli init), then byte-identical forever.
  • prod → nightly → prod → nightly → prod: zero rewrites; the released form is never moved away from.

Byte-stability also holds: a prod build of this branch and a prod build of main produce a bit-identical install footprint (diff -r clean, stub 4b37bdc5e2f58a26675983deaa593ea7 in both), so no released install is rewritten by this change.

Local verification on the branch is clean: pnpm build, pnpm typecheck, pnpm lint, vitest run (1156 tests, 71 files, including the 9 in the new nightly project), and pnpm cli check with only the 4 pre-existing no-hedging warnings. CI is green.

— AI Coding Agent

…mand

Every reference stub outside `.taskless` ends with the line that makes a
missing canonical file recoverable, and the invocation inside it was frozen
at whichever build wrote the stub. `stubPredatesRecovery` matches only the
build-independent tail, deliberately, so prod and a nightly do not treat each
other's stubs as stale — but nothing else looked at the command, so no later
install could correct it.

Reproduced: nightly `init`, then a released `init` in the same directory.
The canonical files and `install.cliVersion` revert to the release; the stub
keeps `npx @taskless/cli-nightly@<pinned> init`, a version that may no longer
be published, in the one line a reader reaches for when the canonical file is
already gone.

`stubRecoveryInvocationStale` reads the command back out of the stub body and
compares it to this build's, asymmetrically: the released, version-free form
is accepted by every build, anything else is reclaimed. That makes the
released form a fixed point every build converges on and none moves away
from, so a prod install reclaims a nightly-written stub exactly once and a
nightly install afterwards leaves the result alone.

The comparison reads the body rather than a new frontmatter field, so a
released build's stub bytes are unchanged and correct installs need no
migration rewrite.

The whole nightly stub path had no coverage: `__TASKLESS_CLI__` is a
compile-time define, and canonical-store.test.ts asserts
`isProductionInvocation()`, so a nightly's invocation could not appear in any
test. vitest now runs two projects, and `test/nightly/` builds stubs under a
real nightly define.

Fixes #227
@theCodeDrift
theCodeDrift force-pushed the fix/227-stub-recovery-invocation branch from da48fb4 to 84d2918 Compare September 2, 2026 00:13
@theCodeDrift
theCodeDrift merged commit a1ba72f into main Sep 2, 2026
2 checks passed
@theCodeDrift
theCodeDrift deleted the fix/227-stub-recovery-invocation branch September 2, 2026 00:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stub recovery line freezes at whichever build wrote it, so stable never reclaims a nightly stub

1 participant