From 2c4c5629a37bd6b4dbd7fff6dda27d0d0dc1b999 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 30 Sep 2026 12:33:57 -0700 Subject: [PATCH 1/3] feat(rule): stop naming rule rollback on a plan that does not serve it --- .changeset/plan-aware-recovery.md | 2 + .../changes/cli-plan-aware-recovery/tasks.md | 10 +-- packages/cli/src/agent/recover-rule.md | 33 ++++++++- packages/cli/src/commands/rules.ts | 14 ++-- packages/cli/src/rules/recover.ts | 14 +++- packages/cli/test/rule-recovery.test.ts | 67 +++++++++++++++++++ 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/.changeset/plan-aware-recovery.md b/.changeset/plan-aware-recovery.md index b0e9a0d2..ab5706e1 100644 --- a/.changeset/plan-aware-recovery.md +++ b/.changeset/plan-aware-recovery.md @@ -3,3 +3,5 @@ --- `taskless check` stops suggesting `taskless rule restore` to an organization whose plan does not include restoring rules. For an edited or missing rule, and for a renamed one, it gives the `git log` and `git restore` steps for the rule's directory instead, so a user is no longer sent to run a command the service will refuse. The plan is read from the `whoami` call the CLI already makes, so no request is added. When the plan is unknown, the suggestion is `rule restore` as before, and `rule restore` still asks the service on every plan. + +`taskless rule revisions` lists a rule's revisions on every plan as before, but on a plan without rule recovery its closing line says rolling back is not included instead of naming `taskless rule rollback`. The `check` and `recover-rule` agent recipes tell an agent to read which recovery the CLI offered before running `rule restore` or `rule rollback`. diff --git a/openspec/changes/cli-plan-aware-recovery/tasks.md b/openspec/changes/cli-plan-aware-recovery/tasks.md index cbd89d6f..8ecad6b6 100644 --- a/openspec/changes/cli-plan-aware-recovery/tasks.md +++ b/openspec/changes/cli-plan-aware-recovery/tasks.md @@ -10,15 +10,15 @@ ## 3. Suggestions in rule revisions -- [ ] 3.1 Give `describeRevisions` an optional `restoreRules` and pass `identity.restoreRules` from `commands/rules.ts`; verify with `rule-recovery.test.ts` that `false` keeps the listing and replaces only the closing line, and unknown names `rule rollback` -- [ ] 3.2 Confirm `rule restore` / `rule rollback` still call the service under `restoreRules: false`; verify with a `rule-recovery.test.ts` case that relays the refusal +- [x] 3.1 Give `describeRevisions` an optional `restoreRules` and pass `identity.restoreRules` from `commands/rules.ts`; verify with `rule-recovery.test.ts` that `false` keeps the listing and replaces only the closing line, and unknown names `rule rollback` +- [x] 3.2 Confirm `rule restore` / `rule rollback` still call the service under `restoreRules: false`; verify with a `rule-recovery.test.ts` case that relays the refusal ## 4. Guidance -- [ ] 4.1 Update `agent/check.md` (v5) and `agent/recover-rule.md` (v3) as design.md describes, hand-edited, no prettier; verify `prompts.test.ts` and `recipe-cross-references.test.ts` pass -- [ ] 4.2 Add a patch changeset for `@taskless/cli` on unit 2, extended on unit 3; verify it is `patch` (pre-1.0) +- [x] 4.1 Update `agent/check.md` (v5) and `agent/recover-rule.md` (v3) as design.md describes, hand-edited, no prettier; verify `prompts.test.ts` and `recipe-cross-references.test.ts` pass +- [x] 4.2 Add a patch changeset for `@taskless/cli` on unit 2, extended on unit 3; verify it is `patch` (pre-1.0) ## 5. Verify -- [ ] 5.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass +- [x] 5.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass - [ ] 5.2 Pre-archive scenario check for all three capabilities, then archive the change on this PR (`pnpm openspec archive cli-plan-aware-recovery -y`) diff --git a/packages/cli/src/agent/recover-rule.md b/packages/cli/src/agent/recover-rule.md index ec05fb03..433b40f5 100644 --- a/packages/cli/src/agent/recover-rule.md +++ b/packages/cli/src/agent/recover-rule.md @@ -1,4 +1,4 @@ -# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v2) +# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v3) ## Goal Put an issued rule back the way Taskless issued it, after `check` @@ -17,7 +17,25 @@ write nothing they cannot verify. revisions and marks the current one. It reads only, and it works on every plan. -`check` never does either. It reports and names `rule restore`. +`check` never does either. It reports and names `rule restore`, or, +when the organization's plan does not include recovery, the git steps +that do the same job (see "When the plan does not include recovery"). + +**Check the plan before offering either command.** The CLI knows the +plan from the same login lookup it already makes, and says so in what +it prints: + +- `check` gives git steps ("Restoring rules is not included in your + organization's plan, so recover … from git") where it would name + `rule restore`. +- `rule revisions` ends with "Rolling back is not included in your + organization's plan" where it would name `rule rollback`. + +When either appears, the plan does not include recovery. Follow the +git steps instead of running `rule restore` or `rule rollback`, which +the service refuses on this plan. When `check` names `rule restore`, +the plan includes recovery or could not be read; run the commands as +below, and the service's answer settles it. ## Preconditions - User is logged in, and the project has a GitHub `origin`. Recovery @@ -87,7 +105,16 @@ write nothing they cannot verify. ## When the plan does not include recovery -On a plan without rule recovery, restore and rollback answer with +If `check` or `rule revisions` already said the plan does not include +recovery, use the git steps `check` gave. Choosing the commit is covered +in `%(TASKLESS_CLI)s agent check`, under "When the plan does not include +restoring rules". To go back to an earlier revision on such a plan, +find the commit in `git log -- `; `rule revisions` +still lists when each revision was created and its pull request, which +helps match a revision to a commit. + +If you ran restore or rollback anyway, or the plan could not be read +beforehand, the service answers with guidance instead of a rule: `RULE_RECOVERY_NOT_IN_PLAN`, and a `message` that names the plan, says the rule is in the repository's git history, and gives the `git log` / `git restore` commands for the rule's diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index 01b7325d..0adcc14b 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -831,16 +831,20 @@ const revisionsCommand = defineCommand({ }, }, async run({ args }) { - const list = await runForIssuedRule(args, (_cwd, identity) => - revisions(identity, args.id) - ); - if (list === undefined) return; + const result = await runForIssuedRule(args, async (_cwd, identity) => ({ + list: await revisions(identity, args.id), + restoreRules: identity.restoreRules, + })); + if (result === undefined) return; + const { list, restoreRules } = result; if (args.json) { console.log( JSON.stringify(revisionsOutputSchema.parse({ success: true, ...list })) ); } else { - for (const line of describeRevisions(list)) console.log(line); + for (const line of describeRevisions(list, restoreRules)) { + console.log(line); + } } }, }); diff --git a/packages/cli/src/rules/recover.ts b/packages/cli/src/rules/recover.ts index 48ee25b8..5da6b193 100644 --- a/packages/cli/src/rules/recover.ts +++ b/packages/cli/src/rules/recover.ts @@ -422,8 +422,16 @@ export async function revisions( * The listing as a person reads it. Order is the service's; the current * revision is found by its flag, because one older than the newest ten is * appended after them. + * + * `restoreRules` is the acting org's plan entitlement. Only `false` changes + * anything: the listing is the same on every plan, but the closing line stops + * naming `rule rollback`, which the service would refuse. Unknown names it, as + * before, and rollback still asks the service either way. */ -export function describeRevisions(list: RevisionList): string[] { +export function describeRevisions( + list: RevisionList, + restoreRules?: boolean +): string[] { const { ruleId } = list; if (list.revisions.length === 0) { return [`Rule ${ruleId} has no revisions.`]; @@ -452,7 +460,9 @@ export function describeRevisions(list: RevisionList): string[] { ); } lines.push( - `Make a revision current with \`${getCliPrefix()} rule rollback ${ruleId} \`.` + restoreRules === false + ? "Rolling back is not included in your organization's plan; earlier versions of this rule are in the repository's git history." + : `Make a revision current with \`${getCliPrefix()} rule rollback ${ruleId} \`.` ); return lines; } diff --git a/packages/cli/test/rule-recovery.test.ts b/packages/cli/test/rule-recovery.test.ts index 824c779d..491c7a94 100644 --- a/packages/cli/test/rule-recovery.test.ts +++ b/packages/cli/test/rule-recovery.test.ts @@ -62,6 +62,8 @@ interface Stub { status?: number; /** The body the revisions listing answers with, and its status. */ revisions?: { body: unknown; status?: number }; + /** The acting org's `entitlements.restoreRules`; unset serves no whoami. */ + restoreRules?: boolean; } const REFUSAL = { @@ -112,6 +114,27 @@ describe("rule restore / rule rollback", () => { if (url.pathname === "/cli/api/whoami") { return Response.json({}, { status: 500 }); } + if (url.pathname === "/cli/api/v2/whoami") { + if (options.restoreRules === undefined) { + return Response.json({}, { status: 500 }); + } + return Response.json({ + user: "Ada", + orgs: [ + { + id: "uuid-test", + name: "test", + source: "github", + url: "https://github.com/test", + entitlements: { + remoteGeneration: true, + runtimeSignatures: true, + restoreRules: options.restoreRules, + }, + }, + ], + }); + } if (url.pathname === "/cli/api/v2/reconcile") { const body = (await input.json()) as { rules: { ruleId: string }[]; @@ -461,6 +484,23 @@ describe("rule restore / rule rollback", () => { expect(await readFile(ruleFile(), "utf8")).toBe(EDITED); }); + it("still asks the service to restore when whoami says the plan excludes it", async () => { + await writeLocal(EDITED); + stub({ + verdict: { kind: "unsafe", expected: await canonicalHash(ISSUED) }, + served: REFUSAL, + restoreRules: false, + }); + const output = await run(["restore", RULE_ID]); + // A hint, never a gate: the call is made and the refusal relayed. + expect(restoreCalled()).toBe(true); + expect(output).toMatchObject({ + ok: false, + code: "RULE_RECOVERY_NOT_IN_PLAN", + }); + expect(String(output.message)).toContain("Recover it with git"); + }); + it("prints the refusal to a human, not an outage", async () => { await writeLocal(EDITED); stub({ @@ -592,6 +632,33 @@ describe("rule restore / rule rollback", () => { expect(process.exitCode).toBeUndefined(); }); + it("on a plan known to exclude rollback, lists the same revisions and does not name rollback", async () => { + stub({ + verdict: { kind: "run" }, + restoreRules: false, + revisions: { + body: listing([revision("r2", true), revision("r1", false)]), + }, + }); + const printed = await print(); + expect(currentLine(printed)[0]).toContain("r2"); + expect(printed).toContain("r1"); + expect(printed).toContain( + "Rolling back is not included in your organization's plan" + ); + expect(printed).not.toContain("rule rollback"); + expect(process.exitCode).toBeUndefined(); + }); + + it("on a plan known to include rollback, names rollback", async () => { + stub({ + verdict: { kind: "run" }, + restoreRules: true, + revisions: { body: listing([revision("r1", true)]) }, + }); + expect(await print()).toContain(`rule rollback ${RULE_ID} `); + }); + it("marks a current revision appended after the newest ten by its flag", async () => { const newest = Array.from({ length: 10 }, (_, index) => revision(`n${String(10 - index)}`, false) From 13979bd7bbebe1c196c9df011a0ca691bad311bb Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 30 Sep 2026 12:34:11 -0700 Subject: [PATCH 2/3] docs(openspec): archive cli-plan-aware-recovery --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli-check/spec.md | 0 .../specs/cli-rule-reconciliation/spec.md | 0 .../specs/cli-rule-recovery/spec.md | 0 .../tasks.md | 0 openspec/specs/cli-check/spec.md | 12 ++- .../specs/cli-rule-reconciliation/spec.md | 28 ++++--- openspec/specs/cli-rule-recovery/spec.md | 75 ++++++++++++++++++- 10 files changed, 100 insertions(+), 15 deletions(-) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/.openspec.yaml (100%) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/design.md (100%) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/proposal.md (100%) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/specs/cli-check/spec.md (100%) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/specs/cli-rule-reconciliation/spec.md (100%) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/specs/cli-rule-recovery/spec.md (100%) rename openspec/changes/{cli-plan-aware-recovery => archive/2026-09-30-cli-plan-aware-recovery}/tasks.md (100%) diff --git a/openspec/changes/cli-plan-aware-recovery/.openspec.yaml b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/.openspec.yaml similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/.openspec.yaml rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/.openspec.yaml diff --git a/openspec/changes/cli-plan-aware-recovery/design.md b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/design.md similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/design.md rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/design.md diff --git a/openspec/changes/cli-plan-aware-recovery/proposal.md b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/proposal.md similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/proposal.md rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/proposal.md diff --git a/openspec/changes/cli-plan-aware-recovery/specs/cli-check/spec.md b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/specs/cli-check/spec.md similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/specs/cli-check/spec.md rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/specs/cli-check/spec.md diff --git a/openspec/changes/cli-plan-aware-recovery/specs/cli-rule-reconciliation/spec.md b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/specs/cli-rule-reconciliation/spec.md similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/specs/cli-rule-reconciliation/spec.md rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/specs/cli-rule-reconciliation/spec.md diff --git a/openspec/changes/cli-plan-aware-recovery/specs/cli-rule-recovery/spec.md b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/specs/cli-rule-recovery/spec.md similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/specs/cli-rule-recovery/spec.md rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/specs/cli-rule-recovery/spec.md diff --git a/openspec/changes/cli-plan-aware-recovery/tasks.md b/openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/tasks.md similarity index 100% rename from openspec/changes/cli-plan-aware-recovery/tasks.md rename to openspec/changes/archive/2026-09-30-cli-plan-aware-recovery/tasks.md diff --git a/openspec/specs/cli-check/spec.md b/openspec/specs/cli-check/spec.md index 3da426dd..c57e574b 100644 --- a/openspec/specs/cli-check/spec.md +++ b/openspec/specs/cli-check/spec.md @@ -582,12 +582,14 @@ When reconciliation completes and returns a non-empty `entitlement.withheld`, `t `taskless check` SHALL NOT create, modify, or delete anything under `.taskless/rules/`. It SHALL NOT call restore, rollback, or rule fetch. For an `unsafe` or `missing` verdict it SHALL -name the command that repairs the rule, `taskless rule restore `. The only files +name how to repair the rule: `taskless rule restore `, or, when the organization's +plan is known to exclude rule recovery, the git steps for the rule's directory (see +`cli-rule-recovery`, "Recovery suggestions follow the plan"). The only files `check` writes under `.taskless/` SHALL be under `.taskless/.run/`. #### Scenario: An edited rule is reported, not repaired -- **WHEN** reconciliation returns `unsafe` for a rule +- **WHEN** reconciliation returns `unsafe` for a rule, and the organization's plan is not known to exclude rule recovery - **THEN** `.taskless/rules/` SHALL be byte-identical before and after the run - **AND** the output SHALL name `taskless rule restore ` @@ -597,6 +599,12 @@ name the command that repairs the rule, `taskless rule restore `. The on - **THEN** `check` SHALL NOT call any restore or fetch endpoint - **AND** SHALL NOT create the rule's directory +#### Scenario: An edited rule on a plan without recovery is reported with git steps + +- **WHEN** reconciliation returns `unsafe` for a rule and the organization's `restoreRules` entitlement is `false` +- **THEN** `.taskless/rules/` SHALL be byte-identical before and after the run +- **AND** the output SHALL give the git steps for the rule's directory and SHALL NOT name `taskless rule restore` + ### Requirement: Each check run works in its own run directory `taskless check` SHALL do its work (the snapshot, the assembled engine configs, and its logs) diff --git a/openspec/specs/cli-rule-reconciliation/spec.md b/openspec/specs/cli-rule-reconciliation/spec.md index a0834b8e..cad10a30 100644 --- a/openspec/specs/cli-rule-reconciliation/spec.md +++ b/openspec/specs/cli-rule-reconciliation/spec.md @@ -234,18 +234,21 @@ The CLI SHALL read the v2 reconcile response as a list of per-rule verdicts `missing`), a list of `unknown` rules (each `{ ruleId, copyOf? }`), and `entitlement.withheld`, and SHALL apply this policy: -| Verdict | runtime | sg / vale | -| -------------------- | -------------------------------------------------- | --------------------------------------------- | -| `run` | execute | run | -| `withheld` | do not execute; fail `check` | (never sent) | -| `unsafe` | do not execute; name `rule restore` | do not run; fail `check`; name `rule restore` | -| `missing` | warn; name `rule restore` | warn; name `rule restore` | -| `unknown` | do not execute (needs `--dangerously-run-scripts`) | run | -| `unknown` + `copyOf` | do not execute; name the source | do not run; fail `check`; name the source | +| Verdict | runtime | sg / vale | +| -------------------- | -------------------------------------------------- | ------------------------------------------- | +| `run` | execute | run | +| `withheld` | do not execute; fail `check` | (never sent) | +| `unsafe` | do not execute; name the recovery | do not run; fail `check`; name the recovery | +| `missing` | warn; name the recovery | warn; name the recovery | +| `unknown` | do not execute (needs `--dangerously-run-scripts`) | run | +| `unknown` + `copyOf` | do not execute; name the source | do not run; fail `check`; name the source | The engine SHALL be taken from the verdict's `engine` for `rules[]` entries and from the reporting directory for `unknown` entries. An `unsafe` notice SHALL name the rule and each -differing path, saying whether it changed, was removed, or was added. A signature SHALL +differing path, saying whether it changed, was removed, or was added. "Name the recovery" +means `taskless rule restore `, or, when the organization's plan is known to exclude +rule recovery, the git steps for the rule's directory (see `cli-rule-recovery`, "Recovery +suggestions follow the plan"). A signature SHALL authorize running a runtime rule only through a `run` verdict, never by local comparison. #### Scenario: An edited static rule fails and does not run @@ -268,7 +271,7 @@ authorize running a runtime rule only through a `run` verdict, never by local co #### Scenario: Missing warns and does not fail -- **WHEN** reconcile returns a `missing` verdict for any engine, and no `unknown` rule names it in `copyOf` +- **WHEN** reconcile returns a `missing` verdict for any engine, and no `unknown` rule names it in `copyOf`, and the organization's plan is not known to exclude rule recovery - **THEN** the CLI SHALL warn naming the rule and `taskless rule restore ` - **AND** SHALL NOT change the exit code because of it @@ -367,7 +370,8 @@ was not issued by the rule service. When `copyOf.ruleId` is also returned as `missing`, the CLI SHALL report the pair as one rename: the copy's message SHALL say the source was deleted and SHALL name -`taskless rule restore `, and the CLI SHALL NOT print a separate `missing` +`taskless rule restore `, or, when the organization's plan is known to exclude +rule recovery, the git steps for the source's directory, and the CLI SHALL NOT print a separate `missing` warning for the source. A runtime rename SHALL be one notice and SHALL NOT change the exit code. `copyOf` absent or `null` SHALL be treated as no copy. A `copyOf` that is present but has no non-empty string `ruleId` SHALL fail closed: the rule SHALL be treated as @@ -375,7 +379,7 @@ unaccounted. #### Scenario: A renamed and loosened Vale rule fails check as one rename -- **WHEN** reconcile returns vale rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1` and `copyOf.files` listing `.vale.ini` changed, and returns `foo-1` as `missing` +- **WHEN** reconcile returns vale rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1` and `copyOf.files` listing `.vale.ini` changed, and returns `foo-1` as `missing`, and the organization's plan is not known to exclude rule recovery - **THEN** `bar-2` SHALL NOT run - **AND** `check` SHALL exit non-zero with one message saying `bar-2` is a copy of `foo-1`, which was deleted, naming `.vale.ini` as changed and `taskless rule restore foo-1` - **AND** the CLI SHALL NOT print a separate warning that `foo-1` is missing diff --git a/openspec/specs/cli-rule-recovery/spec.md b/openspec/specs/cli-rule-recovery/spec.md index e26894c9..0025d5cd 100644 --- a/openspec/specs/cli-rule-recovery/spec.md +++ b/openspec/specs/cli-rule-recovery/spec.md @@ -123,7 +123,9 @@ a current revision older than the newest ten after them. Human output SHALL show revision, its `revisionId`, `createdAt`, `delivery`, and `prUrl` when present, SHALL mark the current one, and SHALL say when `truncated` is `true` that older revisions exist and are listed on the Taskless dashboard. It SHALL name `rule rollback ` as the way to -make a listed revision current. +make a listed revision current, unless the organization's plan is known to exclude rule +recovery, in which case it SHALL instead say that rolling back is not included in the plan. +The listing itself SHALL be the same either way. `404 rule_not_found` SHALL be reported as `RULE_NOT_FOUND`, a rejected token as `AUTH_REQUIRED`, and any other failure as `NETWORK_ERROR`. @@ -154,6 +156,17 @@ make a listed revision current. - **WHEN** the organization's plan does not include rule recovery and the service returns a listing - **THEN** the CLI SHALL print the listing and exit zero +#### Scenario: A plan known to exclude recovery is not told to roll back + +- **WHEN** the organization's `restoreRules` entitlement is `false` and the service returns a listing +- **THEN** the CLI SHALL print the listing +- **AND** SHALL say that rolling back is not included in the plan, and SHALL NOT name `rule rollback` + +#### Scenario: An unknown plan is told to roll back + +- **WHEN** the organization's `restoreRules` entitlement is unknown and the service returns a listing +- **THEN** the CLI SHALL name `rule rollback ` + #### Scenario: An unknown rule is reported as not found - **WHEN** the service answers `404 rule_not_found` @@ -175,3 +188,63 @@ error envelope `{ ok: false, code, message }` otherwise. - **WHEN** `rule revisions --json` is answered `404 rule_not_found` - **THEN** stdout SHALL be `{ ok: false, code: "RULE_NOT_FOUND", message }` + +### 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 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 +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 log` and `git restore --source=` 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 From 49d278d74ec6fcff7d626a9c602ac85214ca79cf Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 30 Sep 2026 20:27:22 -0700 Subject: [PATCH 3/3] docs(agent): read the plan where --json carries it before rolling back --- packages/cli/src/agent/recover-rule.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/agent/recover-rule.md b/packages/cli/src/agent/recover-rule.md index 433b40f5..bc381dac 100644 --- a/packages/cli/src/agent/recover-rule.md +++ b/packages/cli/src/agent/recover-rule.md @@ -27,9 +27,12 @@ it prints: - `check` gives git steps ("Restoring rules is not included in your organization's plan, so recover … from git") where it would name - `rule restore`. + `rule restore`. Under `--json` the same sentence is in `notices` and + `failures`, so read it from there. - `rule revisions` ends with "Rolling back is not included in your - organization's plan" where it would name `rule rollback`. + organization's plan" where it would name `rule rollback`. That line + is printed **only without `--json`**; the `--json` listing is the + same on every plan and says nothing about it. When either appears, the plan does not include recovery. Follow the git steps instead of running `rule restore` or `rule rollback`, which @@ -79,7 +82,13 @@ below, and the service's answer settles it. ## Rolling back -1. **List the revisions.** +1. **Check the plan, then list the revisions.** If `check` already + gave git steps for this rule, the plan does not include rolling + back; go to "When the plan does not include recovery". Otherwise + run `%(TASKLESS_CLI)s rule revisions ` once without + `--json` and read its last line: "Rolling back is not included in + your organization's plan" means the same. Then list them for + reading: ``` %(TASKLESS_CLI)s rule revisions --json ```