From e45a70b5b4a67f9695ed0b699c8e760222b8ff7b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 20:35:16 -0700 Subject: [PATCH 1/3] fix(cli): give error remedies that work as written Four messages pointed at a remedy that could not work: migration 0005 asked for migration 0004 to be re-run, the git recovery steps restored from the commit that deleted the rule, update --rules said to run the CLI once, and a duplicate rule id said only to rename one. --- .changeset/error-remedies.md | 5 ++ .../proposal.md | 47 ++++++++++++ .../specs/cli-rule-recovery/spec.md | 71 +++++++++++++++++++ .../tasks.md | 20 ++++++ openspec/specs/cli-rule-recovery/spec.md | 16 ++++- packages/cli/src/agent/check.md | 12 ++-- .../migrations/0005-rule-directories.ts | 11 ++- packages/cli/src/rules/plan-check.ts | 34 ++++++++- packages/cli/src/rules/reconcile-marker.ts | 3 +- packages/cli/src/rules/recovery-advice.ts | 21 ++++-- .../cli/test/migrate-engine-layout.test.ts | 21 ++++++ packages/cli/test/reconcile-marker.test.ts | 3 + packages/cli/test/runtime-check.test.ts | 9 +++ packages/cli/test/verdicts.test.ts | 16 +++-- 14 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 .changeset/error-remedies.md create mode 100644 openspec/changes/archive/2026-10-05-error-remedies-that-work/proposal.md create mode 100644 openspec/changes/archive/2026-10-05-error-remedies-that-work/specs/cli-rule-recovery/spec.md create mode 100644 openspec/changes/archive/2026-10-05-error-remedies-that-work/tasks.md diff --git a/.changeset/error-remedies.md b/.changeset/error-remedies.md new file mode 100644 index 00000000..ee300652 --- /dev/null +++ b/.changeset/error-remedies.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Four error messages now give a remedy that works as written. On a plan without rule recovery, `check`'s git steps restore a rule from the commit before the one that changed it (`~1`), and from `HEAD` when the change is not committed yet; restoring from the change itself put back nothing for a deleted rule. A rule id held by two engines now says to rename the locally written rule rather than the issued one, and lists where the id appears inside it. Migration 5 no longer asks for migration 4 to be re-run, which nothing can do, and says to move the loose rule files by hand and run `init` again. `update --rules` on a project with no `.taskless/` names `init` instead of "run the CLI once". Error codes are unchanged. The `check` agent recipe moves to topic v6. diff --git a/openspec/changes/archive/2026-10-05-error-remedies-that-work/proposal.md b/openspec/changes/archive/2026-10-05-error-remedies-that-work/proposal.md new file mode 100644 index 00000000..a0c1be28 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-error-remedies-that-work/proposal.md @@ -0,0 +1,47 @@ +## Why + +Four error messages give a remedy that does not work as written (#452): + +- Migration 0005 tells the user to run migration 0004. No command runs one + migration, and 0004 is already recorded as done, so it never runs again. +- On a plan without rule recovery, `check` says to restore a rule from the + commit `git log` lists. For a deleted rule that commit is the deletion, so + `git restore --source=` puts back nothing. +- `update --rules` on a project with no `.taskless/` says "Run the CLI once". + `check`, `verify` and `test` refuse a missing scaffold; only `init` makes one. +- `check` answers a rule id held by two engines with "Rename one." Renaming the + issued rule turns it into a copy, which does not run either, and a rename has + to reach the id inside the rule as well as its directory. + +## What Changes + +- Migration 0005 says to move the loose rule files into `.taskless/sg/rules/` + by hand, then run `init` again, and names no migration number. +- The git recovery steps restore from `~1`, the commit before the + change, and give `git restore --source=HEAD` for a change not yet committed. + `~1` rather than `^`, which zsh's `extendedglob` reads as a glob. +- `update --rules` names `init`. +- The duplicate-id failure says to rename the locally written rule, not the + issued one, and lists where the id appears for each engine involved. +- The `check` recipe goes to topic v6, with the new git steps in its example. + +## Capabilities + +### Modified Capabilities + +- `cli-rule-recovery`: the git steps restore from the commit before a change, + and from `HEAD` for an uncommitted one. + +## Delivery + +Single PR. Four message fixes, their tests, and one spec delta fit one +reviewable diff, and none depends on another. + +## Impact + +- `packages/cli/src/filesystem/migrations/0005-rule-directories.ts` +- `packages/cli/src/rules/recovery-advice.ts` +- `packages/cli/src/rules/reconcile-marker.ts` +- `packages/cli/src/rules/plan-check.ts` +- `packages/cli/src/agent/check.md` +- `--json` envelopes keep their codes; only message text changes. diff --git a/openspec/changes/archive/2026-10-05-error-remedies-that-work/specs/cli-rule-recovery/spec.md b/openspec/changes/archive/2026-10-05-error-remedies-that-work/specs/cli-rule-recovery/spec.md new file mode 100644 index 00000000..f7202e8d --- /dev/null +++ b/openspec/changes/archive/2026-10-05-error-remedies-that-work/specs/cli-rule-recovery/spec.md @@ -0,0 +1,71 @@ +## MODIFIED Requirements + +### Requirement: Recovery suggestions follow the plan + +The CLI SHALL read the acting organization's `entitlements.restoreRules` from the +`GET /cli/api/v2/whoami` response it already fetches to resolve the organization, and SHALL +NOT make another request for it. The value SHALL be treated as tri-state: + +- `true`: the plan includes rule recovery. +- `false`: the plan is known to exclude rule recovery. +- unknown: whoami failed, the matched organization carried no `entitlements` or no boolean + `restoreRules`, or no organization matched the repository's remotes and the CLI fell back + to the token's claim. + +Only `false` SHALL change what the CLI suggests. Wherever the CLI would name +`taskless rule restore ` as the way to repair a rule (an `unsafe` or `missing` rule +reported by `check`, or the source of a rename), a `false` plan SHALL instead be told that +restoring rules is not included in the plan, and given git steps for the rule's directory +under `.taskless/rules/`: a `git restore --source=HEAD` command for a change not yet committed, +and for a committed one a `git log` command that lists the commits that changed it, with a +`git restore --source=~1` command that puts it back as it was before one of them. The +steps SHALL restore from the commit before a change, never from the change itself: the newest +commit `git log` lists for a deleted rule is the deletion, which does not contain the rule. The +parent SHALL be spelled `~1`, not `^`, which zsh reads as a glob under `extendedglob`. When the rule's +engine is not known, the directory SHALL be given as a pathspec matching the rule id under any +engine. `true` and unknown SHALL produce the suggestions the CLI produced before this +requirement. + +This is a suggestion, never a gate. `rule restore` and `rule rollback` SHALL call the service +whatever `restoreRules` says, and SHALL relay a plan refusal as "A plan refusal is an answer, +not a failure of the service" requires. `--json` output SHALL NOT change. + +#### Scenario: An edited rule on a plan without recovery gets git steps + +- **WHEN** `check` reports sg rule `no-eval-3fa9c21b` as `unsafe` and `restoreRules` is `false` +- **THEN** the message SHALL say restoring rules is not included in the plan +- **AND** SHALL give `git restore --source=HEAD`, `git log` and `git restore --source=~1` commands for `.taskless/rules/sg/no-eval-3fa9c21b/` +- **AND** SHALL NOT name `taskless rule restore` + +#### Scenario: A missing rule of unknown engine gets a pathspec for any engine + +- **WHEN** `check` reports rule `foo-1` as `missing` with no known engine and `restoreRules` is `false` +- **THEN** the git steps SHALL name a pathspec matching `foo-1` under any engine directory in `.taskless/rules/` + +#### Scenario: A rename on a plan without recovery gets git steps for the source + +- **WHEN** `check` reports vale rule `bar-2` as a copy of `foo-1`, `foo-1` is `missing`, and `restoreRules` is `false` +- **THEN** the message SHALL give the git steps for `foo-1`'s directory, then say to delete `.taskless/rules/vale/bar-2/` +- **AND** SHALL NOT name `taskless rule restore` + +#### Scenario: Unknown keeps today's suggestion + +- **WHEN** whoami fails, or the matched organization has no `entitlements`, or no organization matches the repository +- **THEN** every suggestion SHALL name `taskless rule restore ` as before + +#### Scenario: The entitlement never blocks a recovery command + +- **WHEN** `restoreRules` is `false` and the user runs `taskless rule restore no-eval-3fa9c21b` +- **THEN** the CLI SHALL call the service's restore endpoint +- **AND** SHALL relay its refusal as it does today + +#### Scenario: No extra request is made + +- **WHEN** `check` or a `rule` subcommand resolves the acting organization +- **THEN** the CLI SHALL call `GET /cli/api/v2/whoami` at most once for that resolution + +#### Scenario: A committed deletion is restored from the commit before it + +- **WHEN** `check` reports sg rule `foo-1` as `missing`, its deletion is committed, and `restoreRules` is `false` +- **THEN** the `git restore` command for a committed change SHALL take `--source=~1` +- **AND** following it with the newest commit `git log` lists SHALL put the rule's files back diff --git a/openspec/changes/archive/2026-10-05-error-remedies-that-work/tasks.md b/openspec/changes/archive/2026-10-05-error-remedies-that-work/tasks.md new file mode 100644 index 00000000..992220c0 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-error-remedies-that-work/tasks.md @@ -0,0 +1,20 @@ +## 1. Spec + +- [x] 1.1 Restate "Recovery suggestions follow the plan" in full as a MODIFIED + block, keeping all six scenarios. +- [x] 1.2 Dry-run `openspec archive` and confirm every scenario survives. + +## 2. Messages + +- [x] 2.1 Migration 0005: move by hand, then run `init`. +- [x] 2.2 Git steps: `HEAD` for an uncommitted change, `~1` otherwise. +- [x] 2.3 `update --rules`: name `init`. +- [x] 2.4 Duplicate id: rename the local rule, and where its id appears. +- [x] 2.5 `check` recipe example and commit guidance; topic v6. + +## 3. Tests + +- [x] 3.1 Migration 0005 refusal names the hand move and `init`, not 0004. +- [x] 3.2 Verdict tests assert the new git steps. +- [x] 3.3 Reconcile-marker refusal names `init`. +- [x] 3.4 Duplicate-id failure names the local rule and each engine's places. diff --git a/openspec/specs/cli-rule-recovery/spec.md b/openspec/specs/cli-rule-recovery/spec.md index 0025d5cd..93b5db1f 100644 --- a/openspec/specs/cli-rule-recovery/spec.md +++ b/openspec/specs/cli-rule-recovery/spec.md @@ -205,8 +205,12 @@ Only `false` SHALL change what the CLI suggests. Wherever the CLI would name `taskless rule restore ` as the way to repair a rule (an `unsafe` or `missing` rule reported by `check`, or the source of a rename), a `false` plan SHALL instead be told that restoring rules is not included in the plan, and given git steps for the rule's directory -under `.taskless/rules/`: a `git log` command that lists the commits that changed it, and a -`git restore --source=` command that puts it back as of one of them. When the rule's +under `.taskless/rules/`: a `git restore --source=HEAD` command for a change not yet committed, +and for a committed one a `git log` command that lists the commits that changed it, with a +`git restore --source=~1` command that puts it back as it was before one of them. The +steps SHALL restore from the commit before a change, never from the change itself: the newest +commit `git log` lists for a deleted rule is the deletion, which does not contain the rule. The +parent SHALL be spelled `~1`, not `^`, which zsh reads as a glob under `extendedglob`. When the rule's engine is not known, the directory SHALL be given as a pathspec matching the rule id under any engine. `true` and unknown SHALL produce the suggestions the CLI produced before this requirement. @@ -219,7 +223,7 @@ not a failure of the service" requires. `--json` output SHALL NOT change. - **WHEN** `check` reports sg rule `no-eval-3fa9c21b` as `unsafe` and `restoreRules` is `false` - **THEN** the message SHALL say restoring rules is not included in the plan -- **AND** SHALL give `git log` and `git restore --source=` commands for `.taskless/rules/sg/no-eval-3fa9c21b/` +- **AND** SHALL give `git restore --source=HEAD`, `git log` and `git restore --source=~1` commands for `.taskless/rules/sg/no-eval-3fa9c21b/` - **AND** SHALL NOT name `taskless rule restore` #### Scenario: A missing rule of unknown engine gets a pathspec for any engine @@ -248,3 +252,9 @@ not a failure of the service" requires. `--json` output SHALL NOT change. - **WHEN** `check` or a `rule` subcommand resolves the acting organization - **THEN** the CLI SHALL call `GET /cli/api/v2/whoami` at most once for that resolution + +#### Scenario: A committed deletion is restored from the commit before it + +- **WHEN** `check` reports sg rule `foo-1` as `missing`, its deletion is committed, and `restoreRules` is `false` +- **THEN** the `git restore` command for a committed change SHALL take `--source=~1` +- **AND** following it with the newest commit `git log` lists SHALL put the rule's files back diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index ef5f7246..b78a5867 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -133,7 +133,7 @@ When the organization's plan is known not to include restoring rules, every notice above gives git steps where it would name `rule restore`: ``` -sg rule no-eval-3fa9c21b was edited since Taskless issued it (changed no-eval-3fa9c21b.yml), so it did not run and `check` fails. Restoring rules is not included in your organization's plan, so recover no-eval-3fa9c21b from git: `git log -- .taskless/rules/sg/no-eval-3fa9c21b/` lists the commits that changed it, and `git restore --source= -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back as of one of them. +sg rule no-eval-3fa9c21b was edited since Taskless issued it (changed no-eval-3fa9c21b.yml), so it did not run and `check` fails. Restoring rules is not included in your organization's plan, so recover no-eval-3fa9c21b from git. If the change is not committed yet, `git restore --source=HEAD -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back. If it is, `git log -- .taskless/rules/sg/no-eval-3fa9c21b/` lists the commits that changed it, newest first, and `git restore --source=~1 -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back as it was before . ``` Follow the git steps, then run `check` again. Do not run @@ -141,10 +141,12 @@ Follow the git steps, then run `check` again. Do not run this plan and answers with the same git steps. `integrity` is the same on every plan. -Choosing the commit: for an edited rule, restore from the commit just -before the edit, or from `HEAD` when the edit is not committed yet. For -a deleted rule, the newest commit is the one -that deleted it, so restore from its parent (`~1`). A rule +Choosing the commit: `` is the commit that made the change, +and `~1` restores from its parent, the last commit that still held the +rule as issued. Restoring from `` itself puts back nothing for a +deleted rule, because the rule is not in that commit. When `git log` +lists several edits since the rule was issued, use the oldest of them. +For a change that is not committed yet, use the `HEAD` step. A rule whose engine is not known is given as a quoted pathspec, `'.taskless/rules/*//*'`; pass it to git as written. For a rename, recover the source, then delete the copy, as above. diff --git a/packages/cli/src/filesystem/migrations/0005-rule-directories.ts b/packages/cli/src/filesystem/migrations/0005-rule-directories.ts index 6595db2e..7f8144aa 100644 --- a/packages/cli/src/filesystem/migrations/0005-rule-directories.ts +++ b/packages/cli/src/filesystem/migrations/0005-rule-directories.ts @@ -10,6 +10,7 @@ import { dirname, join } from "node:path"; import type { Migration } from "../types"; import { CLIError } from "../../util/cli-error"; +import { buildInvocation } from "../../util/invocation"; import { escapeRegExp } from "../../util/regex"; import { ENGINES, @@ -64,6 +65,11 @@ async function move(source: string, destination: string): Promise { * `sg/rules/`; a `*.yml` still sitting there means `0004` did not complete, and * creating `rules/sg/` around it would interleave two layouts in one tree with * no way to tell them apart afterwards. + * + * The remedy is a hand move, not "run 0004". No command runs one migration, + * and by the time this runs `0004` is recorded as done, so nothing would ever + * run it again. The message also names no migration number: the runner + * prefixes it with `Migration 5 failed:`, and a second number reads as noise. */ async function assertRootIsFree(directory: string): Promise { const root = join(directory, RULES_DIRECTORY); @@ -75,8 +81,9 @@ async function assertRootIsFree(directory: string): Promise { throw new CLIError( `Cannot create the rule directories: .taskless/${RULES_DIRECTORY}/ still contains ` + - `${stray.join(", ")} from the pre-migration layout. Migration 0004 moves those to ` + - `.taskless/sg/rules/; run it to completion first.`, + `${stray.join(", ")} from the pre-migration layout. Move ` + + `${stray.length === 1 ? "it" : "them"} into .taskless/sg/rules/ by hand, ` + + `then run \`${buildInvocation()} init\` again.`, "SCAFFOLD_CONFLICT" ); } diff --git a/packages/cli/src/rules/plan-check.ts b/packages/cli/src/rules/plan-check.ts index e558a74a..4b980bb6 100644 --- a/packages/cli/src/rules/plan-check.ts +++ b/packages/cli/src/rules/plan-check.ts @@ -8,6 +8,7 @@ import { } from "../util/git-remote"; import { getCliPrefix } from "../util/package-manager"; import { orgNotFoundRemedy } from "./generate"; +import type { EngineName } from "./layout"; import { recoveryAdvice } from "./recovery-advice"; import { reportRules } from "./report"; import type { RunDirectory } from "./run-directory"; @@ -192,7 +193,7 @@ export async function planCheck( duplicate.engines .map((engine) => `.taskless/rules/${engine}/${duplicate.ruleId}/`) .join(", ") + - "), so neither can be verified and neither ran. Rename one." + `), so neither can be verified and neither ran. ${renameAdvice(duplicate.ruleId, duplicate.engines)}` ); } for (const rule of report.unreadable) { @@ -341,6 +342,37 @@ export async function planCheck( } /** The one notice a withheld run prints, so the upgrade URL appears once. */ +/** + * What a user does about a duplicate id: which rule to rename, and where the id + * lives inside it. + * + * The local rule, never the issued one. Renaming an issued rule turns it into + * a copy of the rule service's id, and a copy does not run: `check` fails one + * under `sg` or `vale`, and skips one under `runtime`. Which side is issued is + * not known here, because the pair is refused before reconcile, so the user is + * told how to tell rather than told a path. + * + * The places are the ones migration `9` rewrites. That migration does this + * automatically, but only once: it does not run again on a current scaffold, + * so a collision made afterwards is renamed by hand. + */ +function renameAdvice(ruleId: string, engines: readonly EngineName[]): string { + const places: Record = { + sg: + `under sg, the directory, ${ruleId}.yml and its \`id:\`, and each ` + + `.tests/${ruleId}-*-test.yml and its \`id:\``, + vale: + `under vale, the directory, ${ruleId}.yml, and in .vale.ini the ` + + `\`tskl) rule = ${ruleId}\` breadcrumb and the \`${ruleId}.${ruleId}\` key`, + runtime: "under runtime, the directory alone", + }; + return ( + "Rename the rule you wrote locally, not the one Taskless issued: a renamed " + + "issued rule becomes a copy, and a copy does not run. The id appears " + + `${engines.map((engine) => places[engine]).join("; ")}.` + ); +} + function withheldNotice(entitlement: PlanEntitlement): string { const count = entitlement.withheld.length; return ( diff --git a/packages/cli/src/rules/reconcile-marker.ts b/packages/cli/src/rules/reconcile-marker.ts index ee8214a7..211672b0 100644 --- a/packages/cli/src/rules/reconcile-marker.ts +++ b/packages/cli/src/rules/reconcile-marker.ts @@ -5,6 +5,7 @@ import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; import { readManifest, writeManifest } from "../filesystem/manifest"; import { TASKLESS_DIRECTORY } from "./vale/formats"; import { CLIError } from "../util/cli-error"; +import { buildInvocation } from "../util/invocation"; import { compareVersions } from "../util/version-compare"; import { getCliVersion } from "../wizard/intro"; @@ -79,7 +80,7 @@ export async function recordReconciliation( // states this precondition; this is the code holding to it. if (!(await pathExists(tasklessDirectory))) { throw new CLIError( - `No \`${TASKLESS_DIRECTORY}/\` in this project, so there are no rules to reconcile. Run the CLI once to set it up before recording a reconciliation.`, + `No \`${TASKLESS_DIRECTORY}/\` in this project, so there are no rules to reconcile. Run \`${buildInvocation()} init\` to set it up before recording a reconciliation.`, "INVALID_INPUT" ); } diff --git a/packages/cli/src/rules/recovery-advice.ts b/packages/cli/src/rules/recovery-advice.ts index e049e41f..1248cc32 100644 --- a/packages/cli/src/rules/recovery-advice.ts +++ b/packages/cli/src/rules/recovery-advice.ts @@ -45,13 +45,26 @@ function ruleDirectory(ruleId: string, engine?: EngineName): string { : `.taskless/rules/${engine}/${ruleId}/`; } -/** The sentence for a plan known not to include rule recovery. */ +/** + * The sentence for a plan known not to include rule recovery. + * + * It restores from the commit BEFORE a change, never from the change itself. + * `git log` lists the commit that deleted or edited the rule first, and + * restoring from that commit restores the damage: a deleted rule is not in + * it at all, so `git restore` puts nothing back. A change not yet committed + * appears in no commit, so it gets `HEAD`, which still holds the rule as + * issued. One sentence covers an edited, a deleted and a renamed rule alike. + * + * `~1` rather than `^`: zsh with `extendedglob` reads a bare `^` as a glob + * negation, and the `check` recipe already spells the parent `~1`. + */ function gitSteps({ ruleId, engine, afterwards, otherwise }: RecoveryTarget) { const directory = ruleDirectory(ruleId, engine); return ( - `Restoring rules is not included in your organization's plan, so recover ${ruleId} from git: ` + - `\`git log -- ${directory}\` lists the commits that changed it, and ` + - `\`git restore --source= -- ${directory}\` puts it back as of one of them.` + + `Restoring rules is not included in your organization's plan, so recover ${ruleId} from git. ` + + `If the change is not committed yet, \`git restore --source=HEAD -- ${directory}\` puts it back. ` + + `If it is, \`git log -- ${directory}\` lists the commits that changed it, newest first, and ` + + `\`git restore --source=~1 -- ${directory}\` puts it back as it was before .` + (afterwards === undefined ? "" : ` Then ${afterwards}.`) + (otherwise === undefined ? "" : ` Or ${otherwise}.`) ); diff --git a/packages/cli/test/migrate-engine-layout.test.ts b/packages/cli/test/migrate-engine-layout.test.ts index b62318cb..f915be75 100644 --- a/packages/cli/test/migrate-engine-layout.test.ts +++ b/packages/cli/test/migrate-engine-layout.test.ts @@ -429,6 +429,27 @@ describe("migrations 0004 + 0005 — one directory per rule", () => { "not a directory\n" ); }); + + it("tells the user to move loose rules by hand when 0005 finds them", async () => { + // Recorded at 4, so only 0005 onward runs. A loose rule under `rules/` + // is one 0004 never moved, and 0004 will not run again. + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: 4 }), + "utf8" + ); + await writeTree(tasklessDirectory, { + "rules/no-eval.yml": CAPTURE_YML, + "sg/rules/.gitkeep": "", + }); + + const failure = ensureTasklessDirectory(temporaryDirectory); + await expect(failure).rejects.toThrow( + /Migration 5 failed: .*still contains no-eval\.yml .*Move it into \.taskless\/sg\/rules\/ by hand, then run `.* init` again\./ + ); + // The remedy must not send the user to a migration nothing can re-run. + await expect(failure).rejects.not.toThrow(/0004/); + }); }); describe("scaffold version gating", () => { diff --git a/packages/cli/test/reconcile-marker.test.ts b/packages/cli/test/reconcile-marker.test.ts index 452a0093..b2f055bb 100644 --- a/packages/cli/test/reconcile-marker.test.ts +++ b/packages/cli/test/reconcile-marker.test.ts @@ -127,6 +127,9 @@ describe("recording a rules reconciliation", () => { expect(envelope.code).toBe("INVALID_INPUT"); expect(envelope.code).not.toBe("INTERNAL_ERROR"); expect(envelope.message).toContain("no rules to reconcile"); + // Names the command that creates `.taskless/`. "Run the CLI once" did + // not: `check`, `verify` and `test` refuse a missing scaffold. + expect(envelope.message).toMatch(/ init` to set it up/); } finally { await rm(empty, { recursive: true, force: true }); } diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index 63f36fc2..7f92580b 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -758,6 +758,15 @@ describe("check: static vs runtime dispatch", () => { expect(output.failures?.join("\n")).toMatch( /\.taskless\/rules\/sg\/demo\/.*\.taskless\/rules\/runtime\/demo\// ); + // Not "Rename one.": renaming the issued side makes it a copy, which does + // not run either, and a rename has to reach every place the id appears. + const failure = output.failures?.join("\n") ?? ""; + expect(failure).toContain( + "Rename the rule you wrote locally, not the one Taskless issued" + ); + expect(failure).toContain( + "under sg, the directory, demo.yml and its `id:`, and each .tests/demo-*-test.yml and its `id:`; under runtime, the directory alone." + ); }); it("reconcile unavailable: runtime skipped, static runs, exit 0, and it says so", async () => { diff --git a/packages/cli/test/verdicts.test.ts b/packages/cli/test/verdicts.test.ts index c9cafdb0..61ecb901 100644 --- a/packages/cli/test/verdicts.test.ts +++ b/packages/cli/test/verdicts.test.ts @@ -460,9 +460,10 @@ describe("recoveryAdvice", () => { it("gives the git steps for the rule's directory when restoreRules is false", () => { expect(recoveryAdvice(false, restore)(target)).toBe( - "Restoring rules is not included in your organization's plan, so recover no-eval-3fa9c21b from git: " + - "`git log -- .taskless/rules/sg/no-eval-3fa9c21b/` lists the commits that changed it, and " + - "`git restore --source= -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back as of one of them." + "Restoring rules is not included in your organization's plan, so recover no-eval-3fa9c21b from git. " + + "If the change is not committed yet, `git restore --source=HEAD -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back. " + + "If it is, `git log -- .taskless/rules/sg/no-eval-3fa9c21b/` lists the commits that changed it, newest first, and " + + "`git restore --source=~1 -- .taskless/rules/sg/no-eval-3fa9c21b/` puts it back as it was before ." ); }); @@ -606,9 +607,10 @@ describe("applyVerdicts on a plan without rule recovery", () => { ); expect(plan.failures).toEqual([ `vale rule ${VALE.ruleId} is a copy of Taskless rule ${SOURCE}, which was deleted (changed .vale.ini), so it did not run and \`check\` fails. ` + - `${GIT}, so recover ${SOURCE} from git: ` + - `\`git log -- .taskless/rules/vale/${SOURCE}/\` lists the commits that changed it, and ` + - `\`git restore --source= -- .taskless/rules/vale/${SOURCE}/\` puts it back as of one of them. ` + + `${GIT}, so recover ${SOURCE} from git. ` + + `If the change is not committed yet, \`git restore --source=HEAD -- .taskless/rules/vale/${SOURCE}/\` puts it back. ` + + `If it is, \`git log -- .taskless/rules/vale/${SOURCE}/\` lists the commits that changed it, newest first, and ` + + `\`git restore --source=~1 -- .taskless/rules/vale/${SOURCE}/\` puts it back as it was before . ` + `Then delete .taskless/rules/vale/${VALE.ruleId}/.`, ]); expect(plan.notices).toEqual([]); @@ -639,7 +641,7 @@ describe("applyVerdicts on a plan without rule recovery", () => { `\`git log -- '.taskless/rules/*/${SOURCE}/*'\`` ); expect(plan.failures[0]).toContain( - `\`git restore --source= -- '.taskless/rules/*/${SOURCE}/*'\`` + `\`git restore --source=~1 -- '.taskless/rules/*/${SOURCE}/*'\`` ); expect( plan.failures[0]?.endsWith( From 2cfaa66d2092137fca68f5265e91a5efa69832fa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 21:25:06 -0700 Subject: [PATCH 2/3] fix(check): keep withheldNotice's doc comment above it --- packages/cli/src/rules/plan-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/rules/plan-check.ts b/packages/cli/src/rules/plan-check.ts index 4b980bb6..5e89177b 100644 --- a/packages/cli/src/rules/plan-check.ts +++ b/packages/cli/src/rules/plan-check.ts @@ -341,7 +341,6 @@ export async function planCheck( }; } -/** The one notice a withheld run prints, so the upgrade URL appears once. */ /** * What a user does about a duplicate id: which rule to rename, and where the id * lives inside it. @@ -373,6 +372,7 @@ function renameAdvice(ruleId: string, engines: readonly EngineName[]): string { ); } +/** The one notice a withheld run prints, so the upgrade URL appears once. */ function withheldNotice(entitlement: PlanEntitlement): string { const count = entitlement.withheld.length; return ( From 0c54cb64340a86704ac77124ae436085d5b31c8c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 21:52:14 -0700 Subject: [PATCH 3/3] fix(cli): name the user's own launcher in the init remedies --- .../cli/src/filesystem/migrations/0005-rule-directories.ts | 4 ++-- packages/cli/src/rules/reconcile-marker.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/filesystem/migrations/0005-rule-directories.ts b/packages/cli/src/filesystem/migrations/0005-rule-directories.ts index 7f8144aa..41771337 100644 --- a/packages/cli/src/filesystem/migrations/0005-rule-directories.ts +++ b/packages/cli/src/filesystem/migrations/0005-rule-directories.ts @@ -10,7 +10,7 @@ import { dirname, join } from "node:path"; import type { Migration } from "../types"; import { CLIError } from "../../util/cli-error"; -import { buildInvocation } from "../../util/invocation"; +import { getCliPrefix } from "../../util/package-manager"; import { escapeRegExp } from "../../util/regex"; import { ENGINES, @@ -83,7 +83,7 @@ async function assertRootIsFree(directory: string): Promise { `Cannot create the rule directories: .taskless/${RULES_DIRECTORY}/ still contains ` + `${stray.join(", ")} from the pre-migration layout. Move ` + `${stray.length === 1 ? "it" : "them"} into .taskless/sg/rules/ by hand, ` + - `then run \`${buildInvocation()} init\` again.`, + `then run \`${getCliPrefix()} init\` again.`, "SCAFFOLD_CONFLICT" ); } diff --git a/packages/cli/src/rules/reconcile-marker.ts b/packages/cli/src/rules/reconcile-marker.ts index 211672b0..45a3b43f 100644 --- a/packages/cli/src/rules/reconcile-marker.ts +++ b/packages/cli/src/rules/reconcile-marker.ts @@ -5,7 +5,7 @@ import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; import { readManifest, writeManifest } from "../filesystem/manifest"; import { TASKLESS_DIRECTORY } from "./vale/formats"; import { CLIError } from "../util/cli-error"; -import { buildInvocation } from "../util/invocation"; +import { getCliPrefix } from "../util/package-manager"; import { compareVersions } from "../util/version-compare"; import { getCliVersion } from "../wizard/intro"; @@ -80,7 +80,7 @@ export async function recordReconciliation( // states this precondition; this is the code holding to it. if (!(await pathExists(tasklessDirectory))) { throw new CLIError( - `No \`${TASKLESS_DIRECTORY}/\` in this project, so there are no rules to reconcile. Run \`${buildInvocation()} init\` to set it up before recording a reconciliation.`, + `No \`${TASKLESS_DIRECTORY}/\` in this project, so there are no rules to reconcile. Run \`${getCliPrefix()} init\` to set it up before recording a reconciliation.`, "INVALID_INPUT" ); }