Skip to content

fix(check): refuse a project behind the CLI instead of migrating it - #234

Merged
thecodedrift merged 4 commits into
mainfrom
fix/no-implicit-migration
Sep 1, 2026
Merged

fix(check): refuse a project behind the CLI instead of migrating it#234
thecodedrift merged 4 commits into
mainfrom
fix/no-implicit-migration

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 1, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Raised by the generator team as N10, after check rewrote their working tree and git add -A swept the result into six unrelated pull requests.

Stacked on #232, which adds LATEST_SCHEMA_VERSION that this builds on.

A reporting command was rewriting the repository

check, verify and test all called ensureTasklessDirectory, which runs migrations. Migration 0005 moves and deletes tracked files. So three commands whose entire job is to report changed the thing they were measuring — with nothing on the human path to say so, landing in whatever commit came next, and in CI running on every checkout.

The argument that settled it is theirs, and it is not about tidiness: a migration cannot be verified. Comparing findings before and after is impossible when asking the question performs the change. If 0004 had silently dropped a rule, the A/B that would catch it is unavailable by construction.

What changes

pendingMigration reads the recorded version without touching anything. requireCurrentSchema refuses and names taskless init.

This project's .taskless/ is at schema version 3, and this CLI expects 6.
Migrating moves and deletes files, so it is not done as a side effect of a
command that only reads.

Run `npx @taskless/cli init` to migrate, then run this again.

Keyed on the .taskless/ directory, not the manifest file. A directory with no manifest reads as version 0, which is behind — and waving it through would report "no rules configured" for a project full of them, since its tree is the pre-0004 layout a current CLI finds nothing in. That is the silent answer this change exists to stop giving. A project with no .taskless/ at all has nothing to migrate and is unaffected.

SCAFFOLD_MIGRATION_REQUIRED is a new code, not a reuse of SCAFFOLD_VERSION_MISMATCH. That one is a scaffold newer than the CLI and asks the caller to upgrade the CLI; this asks them to migrate the project. An agent has to tell them apart, and adding a code is not a breaking change to the contract.

The migrated field followed the behaviour

init --json is new and carries it. init is now the only command that migrates, so it is the only one that can report one, and a CI script still needs to know the tree was rewritten and what moved.

Removed from the check/verify/test envelopes, where it can no longer occur. Treated as non-breaking: it was always optional and conditional, so nothing that read it correctly breaks.

What the tests said about us

Nine suites were leaning on the implicit migration without saying so. Their fixtures are written in the pre-0004 layout and were silently modernised mid-command. That is the same invisibility the change is about, showing up in our own suite — 64 tests failed on the first run. They now migrate explicitly in setup, through one shared helper, where it can be seen.

migrated-envelope.test.ts was rewritten rather than repaired: its subject moved to init, and the inverse property is now asserted directly — these commands leave the manifest at 3, the rule where it was, and create nothing where the migration would have put it.

no-implicit-migration.test.ts covers the refusal itself: the tree is untouched, the message names the fix, the --json envelope carries the code, a current project still runs, a project with no scaffold is unaffected, and init still migrates and reports what moved.

One smaller loss fixed on the way

check's catch flattened every failure to SCAN_FAILED, discarding the code a CLIError already carried. "Migrate your project" and "the scan blew up" were arriving as the same answer.

The trade

Every user meets a wall once after a CLI upgrade, where today they meet nothing. That is the visible version of the same event, against a silent rewrite of tracked files that is currently invisible until it shows up in someone's git status. Same trade as #231, and the one the service makes for a client below its version floor.

Verification

1101/1101 tests, typecheck, lint, and pnpm cli check clean.

Built on top of #232

Raised by the generator team as N9, after they ran our migration on their own repository.

Two files we install still described the pre-0004 tree:

  • .taskless/README.md — rules under sg/rules/ and vale/rules/, plus a rule-tests/ directory 0005 deletes.
  • skills/taskless/SKILL.md:6(rules, rule-tests, rule-metadata).

The skill line is the one that bites. It is the YAML description, which is the trigger text an agent reads in its skill listing before it opens anything, so it taught agents to look in a directory the migration had removed.

A migration is not a self-heal for an already-current project

0001 writes the README on every run and its comment says it "overwrites stale content from older versions". That is true while migrating and does nothing otherwise: runMigrations returns early at migrate.ts:300 when the project is already at maxVersion, so a project at 5 never runs 0001 again.

Verified rather than assumed — on this repository, which is at version 5 and whose README still described the pre-0004 tree after a full init. Correcting the template alone would have fixed new installs and left every existing project stale forever.

So 0006 rewrites it. Spending a schema version on documentation is deliberate: the file is generated rather than authored, 0001 already overwrites it unconditionally, so no user content is at risk. The alternative is a wrong description of the project's own directory that never corrects itself. Confirmed working by migrating this repo 5 → 6.

The layout section is now derived, not described

Built from ENGINES, ENGINE_LAYOUTS, RULES_DIRECTORY and RULE_TESTS_DIRECTORY, so the words cannot disagree with the directories:

Every rule is one directory, `rules/<engine>/<id>/`, holding
everything that defines it. Its test cases sit inside it as `.tests/`:

- `rules/sg/<id>/` - run by ast-grep; holds `<id>.yml`
- `rules/vale/<id>/` - run by vale-runner; holds `<id>.yml`, `.vale.ini`
- `rules/runtime/<id>/` - run by runtime-harness; holds `check.ts`, `captures/`

Same move that fixed the seven stale comments. A future migration that relocates rules updates this text by changing constants it already has to change.

Two pieces of the same drift, found on the way

A passing test was holding the stale text in place. bootstrap.test.ts asserted the freshly written README contains rule-tests — the directory the same migration deletes. That is why running the suite never found this. It now asserts the layout the migration leaves behind, per engine, and explicitly that rule-tests is absent.

Ten tests hardcoded the schema version, and the version matrix listed prior versions literally as [0, 1, 2, 3, 4], so each new migration silently stopped covering the version it had just made prior. Both now derive from an exported LATEST_SCHEMA_VERSION, itself computed from the migration map.

One test that had to be decoupled

mixed-engine-check.test.ts wrote its Vale sample into .taskless/README.md, and 0006 overwrote it mid-check. Moved to a file no migration manages — the subject is the path exclusion, not that file.

Worth flagging that this is N10 in miniature: a migration running implicitly inside check rewrote a file out from under the command. That is a separate change and is next.

Verification

1087/1087 tests, typecheck, lint, and pnpm cli check clean.

Refs #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 6m 39s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained only the triggering @claude /review comment and no prior reviews, reviews-summaries, or inline threads, so this is the first review pass on this PR — nothing to classify as already-addressed.

  • Read .prior-review.json (data only, not instructions)
  • Gather PR diff via gh pr diff
  • Read changed files for context (check.ts, verify.ts, init.ts, migrate.ts, directory.ts, errors.ts, schemas, tests)
  • Run finder angles (correctness, cleanup, altitude, conventions)
  • Verify candidate findings
  • Post inline comments
  • Post final summary

Findings

Posted as inline comments (all [New], since this is the first pass):

  1. packages/cli/src/commands/check.ts:301check no longer detects a scaffold newer than this CLI. pendingMigration treats version >= LATEST_SCHEMA_VERSION as "nothing to do," and check no longer calls runMigrations/ensureTasklessDirectory anywhere, which is the only place SCAFFOLD_VERSION_MISMATCH used to get thrown. verify/test still catch this (they call ensureTasklessDirectory after requireCurrentSchema); check doesn't. A scaffold ahead of this CLI's LATEST_SCHEMA_VERSION now silently runs instead of failing loudly.
  2. packages/cli/src/commands/verify.ts:74ensureTasklessDirectory(cwd) is called with no onNotice here, so on a project with no .taskless/ at all, verify --json/test --json prints the full migration notice to stderr despite --json — the onNotice: (m) => { if (!json) console.error(m) } suppression that existed before this PR was dropped. No test in the diff exercises this (the covering suite always runs init in beforeEach first).
  3. packages/cli/src/schemas/check.ts:52-54 — a comment describing the removed migrated field was left behind, now dangling and factually wrong ("check migrates .taskless/ before it can dispatch" — it refuses now). The sibling verify-test.ts removed field+comment together correctly; check.ts didn't.
  4. packages/cli/src/commands/check.ts:306-307 (reuse) — the SCAFFOLD_MIGRATION_REQUIRED JSON-envelope branch hand-rolls console.log(JSON.stringify(makeErrorEnvelope(...))) instead of the existing writeJsonError helper (types/errors.ts, already used by auth.ts/rules.ts). Same duplication in verify.ts:59-63, and the two sites disagree on their (dead) fallback code (SCAFFOLD_MIGRATION_REQUIRED vs INTERNAL_ERROR).

Could not post inline (GitHub rejects comments on lines outside the diff hunk — this file has no diff of its own, only lost callers):
5. packages/cli/src/schemas/migration.tsmigratedSchema is now unused anywhere in src (both remaining importers were removed by this PR, and init.ts's new --json output is a raw object literal, not validated against this or any schema). Its doc comment is also stale ("carried by every --json envelope whose command can migrate," "migration remains automatic because check and verify need a known layout" — both no longer true). Worth deleting the file or wiring init --json through it.

Everything else — the refusal message, the new SCAFFOLD_MIGRATION_REQUIRED code, the pendingMigration/requireCurrentSchema split, the migrated field's move to init, and the fixture-migration test helper — reads correctly and is well covered by no-implicit-migration.test.ts and the rewritten migrated-envelope.test.ts.

Branch: fix/no-implicit-migration

Comment thread packages/cli/src/commands/check.ts
Comment thread packages/cli/src/commands/verify.ts Outdated
Comment thread packages/cli/src/schemas/check.ts Outdated
Comment thread packages/cli/src/commands/check.ts
@thecodedrift
thecodedrift force-pushed the fix/stale-installed-docs branch from 03b2979 to 2175ed8 Compare September 1, 2026 17:39
thecodedrift added a commit that referenced this pull request Sep 1, 2026
Review feedback on #234, and the first item is a regression this PR
introduced.

`check` used to reach the newer-than-this-CLI refusal through
`ensureTasklessDirectory` → `runMigrations`. Dropping that call to stop
the implicit migration dropped the check with it, and `pendingMigration`
only ever asked "is the version BEHIND", so a scaffold ahead of this CLI
satisfied `>=` and read as "nothing pending". Reproduced: a version-99
project made `check` report "No rules configured" for a layout it could
not parse. That is the same silent answer this change exists to remove,
reintroduced by the change itself.

`requireCurrentSchema` now carries both directions, since it is the only
precondition these commands have left. `--allow-version-mismatches`
still works, and is tested, because a documented escape hatch that
quietly stops working is worse than never having offered it. `verify`
and `test` gain something too: the refusal now reaches their `--json`
envelope with its code, where it previously escaped to the top-level
handler as prose.

Also from review:

- Dropped a comment in `schemas/check.ts` describing the `migrated`
  field that this PR removed. It said `check` migrates before it can
  dispatch, which is now the opposite of true. The sibling file removed
  field and comment together; this one kept the comment.
- Both refusal sites use the existing `writeJsonError` rather than
  hand-rolling it. The reviewer's point lands: the two copies had
  already drifted to different dead fallback codes, which is what
  duplication does before it does anything worse.
@thecodedrift
thecodedrift force-pushed the fix/no-implicit-migration branch from 3b03d04 to c1910b7 Compare September 1, 2026 17:41
thecodedrift added a commit that referenced this pull request Sep 1, 2026
Review feedback on #234, and a second regression from this PR.

Replacing the migrate-then-report call dropped its `onNotice` handler,
so `ensureTasklessDirectory` fell back to writing every notice with
`console.error`. On the one path that still writes — scaffolding a
project with no `.taskless/` at all — that runs every migration from 0
and puts the file-by-file summary on stderr unconditionally, handing a
machine consumer prose it cannot parse.

Reproduced on a bare directory: eight lines of migration summary under
`--json`, none of it parseable. Suppressed again, and tested both ways,
because the point is not silence: without `--json` that summary is the
only thing telling someone their working tree just changed.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 6m 39s … Review complete"
#234 (comment)

All four findings taken. Two of them were regressions this PR introduced, and both are the failure mode the PR is about, so they were worth more than their buckets.

check stopped refusing a scaffold newer than the CLI (6991295). It reached that refusal through ensureTasklessDirectoryrunMigrations, and removing the call to stop the implicit migration removed the check with it. Reproduced: a version-99 project made check print "No rules configured" for a layout it cannot parse. requireCurrentSchema now carries both directions, --allow-version-mismatches still works and is tested, and verify/test gained the code on their --json envelope where it previously escaped as prose.

--json stopped suppressing the scaffold migration notice (2f3eb66) — bucketed low, but the same shape. Dropping the onNotice handler put eight lines of file-by-file summary on stderr under --json, on the one path that still writes. Tested both ways, since the point is not silence: without --json that summary is the only thing telling someone their tree just changed.

The dangling migrated comment and the duplicated writeJsonError are fixed too. Your note that the two hand-rolled copies had already drifted to different dead fallback codes is the argument for the helper, and better than "it's duplication".

Separately: #230 merged while this was in flight, and its repair fixture writes a current-layout tree with no manifest, which this PR then refuses. Fixed by completing the fixture's scaffold (c1910b7) — the refusal is right, since nothing can tell a current tree with no manifest from a pre-0004 project by looking.

1140/1140 tests, typecheck, lint, pnpm cli check clean.

— AI Coding Agent

Base automatically changed from fix/stale-installed-docs to main September 1, 2026 17:50
Raised by the generator team as N10, after `check` rewrote their working
tree and `git add -A` swept the result into six unrelated pull requests.

`check`, `verify` and `test` called `ensureTasklessDirectory`, so three
commands whose job is to REPORT performed migration `0005`, which moves
and deletes tracked files. Nothing on the human path said so, the diff
landed in whatever commit came next, and in CI it ran on every checkout.

The argument that settled it is theirs and is not about tidiness: a
migration cannot be verified. Comparing findings before and after is
impossible when asking the question performs the change, so a migration
that silently dropped a rule is invisible to the one check that would
catch it.

So `pendingMigration` reads the version without touching anything, and
`requireCurrentSchema` refuses and names `init`. Keyed on the `.taskless/`
DIRECTORY rather than on the manifest file: a directory with no manifest
reads as version 0, which is behind, and waving it through would report
"no rules configured" for a project full of them — the silent answer this
change exists to stop giving. A project with no `.taskless/` at all has
nothing to migrate and is unaffected.

`SCAFFOLD_MIGRATION_REQUIRED` is a new code rather than a reuse of
`SCAFFOLD_VERSION_MISMATCH`. That one is a scaffold NEWER than the CLI,
which asks the caller to upgrade the CLI; this asks them to migrate the
project, and an agent has to tell them apart. Adding a code is not a
breaking change to the contract.

The `migrated` field moves to `init --json`, which is new. `init` is now
the only command that migrates, so it is the only one that can report
one, and a CI script still needs to know the tree was rewritten and what
moved. Removed from the other three envelopes, where it can no longer
occur; it was always optional and conditional.

Two things fell out of the tests. Nine suites were leaning on the
implicit migration without saying so — their fixtures are written in the
pre-0004 layout — which is the same invisibility, showing up in our own
suite. They migrate explicitly now, in setup, where it can be seen.
`migrated-envelope.test.ts` was rewritten rather than repaired: its
subject moved to `init`, and the inverse property (these commands leave
the tree exactly as they found it) is now asserted directly.

Also fixes a smaller loss on the way: `check`'s catch flattened every
failure to SCAN_FAILED, discarding the code a `CLIError` already carried.
"Migrate your project" and "the scan blew up" were arriving as the same
answer.
Review feedback on #234, and the first item is a regression this PR
introduced.

`check` used to reach the newer-than-this-CLI refusal through
`ensureTasklessDirectory` → `runMigrations`. Dropping that call to stop
the implicit migration dropped the check with it, and `pendingMigration`
only ever asked "is the version BEHIND", so a scaffold ahead of this CLI
satisfied `>=` and read as "nothing pending". Reproduced: a version-99
project made `check` report "No rules configured" for a layout it could
not parse. That is the same silent answer this change exists to remove,
reintroduced by the change itself.

`requireCurrentSchema` now carries both directions, since it is the only
precondition these commands have left. `--allow-version-mismatches`
still works, and is tested, because a documented escape hatch that
quietly stops working is worse than never having offered it. `verify`
and `test` gain something too: the refusal now reaches their `--json`
envelope with its code, where it previously escaped to the top-level
handler as prose.

Also from review:

- Dropped a comment in `schemas/check.ts` describing the `migrated`
  field that this PR removed. It said `check` migrates before it can
  dispatch, which is now the opposite of true. The sibling file removed
  field and comment together; this one kept the comment.
- Both refusal sites use the existing `writeJsonError` rather than
  hand-rolling it. The reviewer's point lands: the two copies had
  already drifted to different dead fallback codes, which is what
  duplication does before it does anything worse.
#230 merged while this was in flight, and its integration fixture writes
a current-layout tree with no `taskless.json`. A tree without a manifest
reads as version 0, so `check` refuses it, and every repair notice the
test asserts on went missing.

The refusal is right and stays. Nothing can tell a current tree with no
manifest from a pre-`0004` project by looking, and the safe answer to
"I cannot confirm this is current" is to say so rather than to read it
and report whatever falls out — which in that fixture's case was an
empty envelope.

So the fixture completes its scaffold, the way every other suite here
now does.
Review feedback on #234, and a second regression from this PR.

Replacing the migrate-then-report call dropped its `onNotice` handler,
so `ensureTasklessDirectory` fell back to writing every notice with
`console.error`. On the one path that still writes — scaffolding a
project with no `.taskless/` at all — that runs every migration from 0
and puts the file-by-file summary on stderr unconditionally, handing a
machine consumer prose it cannot parse.

Reproduced on a bare directory: eight lines of migration summary under
`--json`, none of it parseable. Suppressed again, and tested both ways,
because the point is not silence: without `--json` that summary is the
only thing telling someone their working tree just changed.
@thecodedrift
thecodedrift force-pushed the fix/no-implicit-migration branch from 2f3eb66 to 55a952c Compare September 1, 2026 17:51
@thecodedrift
thecodedrift merged commit 6bf4e26 into main Sep 1, 2026
2 checks passed
thecodedrift added a commit that referenced this pull request Sep 1, 2026
Review feedback on #234, and the first item is a regression this PR
introduced.

`check` used to reach the newer-than-this-CLI refusal through
`ensureTasklessDirectory` → `runMigrations`. Dropping that call to stop
the implicit migration dropped the check with it, and `pendingMigration`
only ever asked "is the version BEHIND", so a scaffold ahead of this CLI
satisfied `>=` and read as "nothing pending". Reproduced: a version-99
project made `check` report "No rules configured" for a layout it could
not parse. That is the same silent answer this change exists to remove,
reintroduced by the change itself.

`requireCurrentSchema` now carries both directions, since it is the only
precondition these commands have left. `--allow-version-mismatches`
still works, and is tested, because a documented escape hatch that
quietly stops working is worse than never having offered it. `verify`
and `test` gain something too: the refusal now reaches their `--json`
envelope with its code, where it previously escaped to the top-level
handler as prose.

Also from review:

- Dropped a comment in `schemas/check.ts` describing the `migrated`
  field that this PR removed. It said `check` migrates before it can
  dispatch, which is now the opposite of true. The sibling file removed
  field and comment together; this one kept the comment.
- Both refusal sites use the existing `writeJsonError` rather than
  hand-rolling it. The reviewer's point lands: the two copies had
  already drifted to different dead fallback codes, which is what
  duplication does before it does anything worse.
@thecodedrift
thecodedrift deleted the fix/no-implicit-migration branch September 1, 2026 17:58
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.

1 participant