Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/cli-plan-aware-recovery/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-30
90 changes: 90 additions & 0 deletions openspec/changes/cli-plan-aware-recovery/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
## Context

Two paths already call `GET /cli/api/v2/whoami` to pick the acting organization, both through
`resolveOrgSubject` in `packages/cli/src/auth/org.ts`: `resolveIdentity` (every `rule`
subcommand that talks to the service) and `planCheck` (`check`'s reconcile). Each discards
everything but the organization's `id`.

Every recovery suggestion in `check` is rendered in `applyVerdicts`
(`packages/cli/src/rules/verdicts.ts`) through one callback, `restoreCommand(ruleId)`, that
`planCheck` supplies so the policy stays free of how the CLI was invoked. It is used at three
sites: `unsafe` (runtime notice, static failure), `missing` (notice), and a rename
(`applyCopy`, when the source is `missing`). `rule revisions`' closing line is rendered by
`describeRevisions` in `rules/recover.ts`.

## Goals / Non-Goals

**Goals:**

- One place decides "restore or git", fed by the whoami call already being made.
- Unknown is byte-for-byte today's output, so every existing test keeps passing unchanged.

**Non-Goals:**

- Gating `rule restore` / `rule rollback` locally. See proposal.md.
- Reading `runtimeSignatures` from whoami. Reconcile already reports it authoritatively, per
run, and `check` uses that.
- Surfacing entitlements in `info` or `auth status`. Nothing in this change needs it.

## Decisions

**Resolve the organization once, return its entitlement with its subject.** Replace
`resolveOrgSubject(cwd, token): Promise<string | number>` with
`resolveActingOrg(cwd, token): Promise<{ subject: string | number; restoreRules?: boolean }>`.
`restoreRules` is set only when an organization matched and its `entitlements.restoreRules`
is a boolean; the token-claim and nil-UUID fallbacks leave it `undefined`. `Identity` gains
`restoreRules?: boolean`. Considered: a second `fetchWhoami` from the suggestion sites.
Rejected because the issue requires no extra request, and a second call could disagree with
the first about which organization acted.

**The generated whoami type is read defensively.** `entitlements` is optional in the schema
and "absent means unknown". Read it with `typeof === "boolean"`, so an older or partial
response degrades to unknown rather than throwing.

**Replace `restoreCommand` with a `recovery` callback that returns a whole sentence.** Today
each site writes `Run \`${restoreCommand(id)}\` to <purpose>.`The git alternative is two
commands and a reason, which does not fit a slot shaped like one command. The callback
takes`{ ruleId, engine?, purpose }`and returns the sentence.`planCheck` builds it from the
tri-state:

- not `false`: `Run \`<prefix> rule restore <ruleId>\` to <purpose>.`, exactly today's text.
- `false`: `Restoring rules is not included in your organization's plan, so recover it from
git: \`git log -- <dir>\` lists the commits that changed it, and
\`git restore --source=<commit> -- <dir>\` puts it back as of one of them.`

`<dir>` is `.taskless/rules/<engine>/<ruleId>/`, or the quoted glob pathspec
`'.taskless/rules/*/<ruleId>/'` when a `missing` verdict carries no known engine. The
rendering lives in a small pure function beside `applyVerdicts` so it is tested as a table
like the rest of the policy. Considered: passing the tri-state into `applyVerdicts`. Rejected
to keep `verdicts.ts` free of CLI prefix and plan concerns, which is why the callback exists.

For a `missing` rule the newest commit `git log` lists is the one that deleted it. The
sentence says "as of one of them", and the `check` recipe spells out choosing the commit
before the deletion. A notice line is not the place for a git tutorial.

**`describeRevisions(list, restoreRules?)`.** When `false`, the closing line becomes
`Rolling back is not included in your organization's plan; earlier versions of this rule are
in the repository's git history.` Otherwise unchanged. The command passes
`identity.restoreRules`.

**Recipes.** `check.md` (v4 → v5): an edited or missing rule is reported with either
`rule restore` or git steps; follow whichever `check` printed, and for a deleted rule restore
from the commit before the deletion. `recover-rule.md` (v2 → v3): before offering restore or
rollback, read what `check` or `rule revisions` offered. If it gave git steps, the plan
excludes recovery; follow them rather than running a command that will be refused.

## Risks / Trade-offs

- [Entitlement changes between whoami and the suggestion, e.g. an upgrade mid-session] →
Each run reads whoami fresh, and the recovery commands still call the service, so the worst
case is one run with a stale suggestion.
- [A `false` from whoami that the service would not refuse] → The user is sent to git, which
still works. Nothing is blocked, so a wrong hint costs a detour, never a capability.
- [Glob pathspec on an unknown engine] → Quoted so the shell does not expand it; git applies
glob pathspecs by default. Only reachable when reconcile omits the engine, which it rarely
does.

## Migration Plan

None. No config, schema, or `--json` change. Rolling back the release restores the old
suggestions.
77 changes: 77 additions & 0 deletions openspec/changes/cli-plan-aware-recovery/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
## Why

The CLI suggests `taskless rule restore` and `taskless rule rollback` to every user, and learns
that the organization's plan does not include them only when the service refuses the call. A
user on such a plan is told to run a command, runs it, and is then told to use git instead.
v2 `whoami` now returns each organization's `entitlements`, including `restoreRules`
(taskless/taskless#254, live, and already in the vendored `api-v2.schema.json`), so the CLI
can stop suggesting what the plan will not serve. The two changes this builds on, `rule
revisions` (#422) and the `copyOf` rename notice (#423), have both merged.

## What Changes

- The acting organization's `entitlements.restoreRules` is read from the `whoami` call the
CLI already makes to resolve the organization. No new request. It is tri-state: `true`,
`false`, or unknown (whoami failed, `entitlements` absent, or no organization matched the
repository and the CLI fell back to the token's claim).
- When, and only when, `restoreRules` is exactly `false`:
- `check`'s notice for an `unsafe` or `missing` rule gives the git recovery steps for the
rule's directory instead of naming `rule restore <ruleId>`.
- The rename notice (a copy of an issued rule whose source is `missing`) gives the git
steps for the source's directory instead of naming `rule restore <source>`.
- `rule revisions` still lists revisions, since the listing is served on every plan. Its
closing line says rolling back is not included in the plan, instead of naming the
`rule rollback` command.
- Unknown behaves exactly as today: every suggestion above names `rule restore` /
`rule rollback`.
- **Suggestions only, never a gate.** `rule restore` and `rule rollback` keep calling the
service whatever `whoami` said, and keep relaying its refusal verbatim. The schema calls
`entitlements` "a hint for the client, never a gate", and the refusal is the better answer
anyway: it carries the exact commands and the upgrade link.
- The `recover-rule` and `check` agent recipes tell an agent to read which recovery `check`
offered before reaching for `rule restore` or `rule rollback`. Both topics bump their
version.
- No `--json` change. `integrity` in `check --json` already says what is wrong with each
rule, and a recovery command an agent runs anyway is answered by the refusal. Nothing found
in this proposal needs a machine-readable plan field.

## Capabilities

### New Capabilities

_None._

### Modified Capabilities

- `cli-rule-recovery`: adds a requirement that recovery suggestions follow the plan's
`restoreRules` entitlement, and modifies `rule revisions`' closing line.
- `cli-check`: the "never writes to the rules tree" requirement names the recovery step for
the plan rather than always `rule restore`.
- `cli-rule-reconciliation`: the verdict table and the rename requirement name the recovery
step for the plan rather than always `rule restore`. The issue expected only the first two
specs, but these two requirements state the `rule restore` wording normatively, so leaving
them would contradict the new requirement.

## Impact

- `packages/cli/src/auth/org.ts`, `auth/identity.ts`: resolve the acting organization's
`restoreRules` alongside its subject, carried as an optional field on `Identity`.
- `packages/cli/src/rules/verdicts.ts`, `rules/plan-check.ts`: the notice text is rendered
through one recovery callback that knows the plan, replacing `restoreCommand`.
- `packages/cli/src/rules/recover.ts`, `commands/rules.ts`: `describeRevisions`' closing line.
- `packages/cli/src/agent/recover-rule.md`, `agent/check.md`: topic version bumps.
- Tests: `org.test.ts`, `verdicts.test.ts`, `rule-recovery.test.ts`, and the recipe parity
tests.
- No schema, dependency, or `--json` change.

**Delivery shape: stacked, merging forward.** Three PRs, each safe in production alone:

1. Resolve `restoreRules` onto `Identity` from the existing whoami call, with this proposal.
Nothing reads it yet, so output is unchanged.
2. `check`'s suggestions (`unsafe`, `missing`, rename) and the `check` recipe.
3. `rule revisions`' closing line, the `recover-rule` recipe, and the archive.

Unknown preserves today's output exactly, so no intermediate state suggests anything wrong:
a slice that has not landed yet keeps suggesting `rule restore`. The whole diff would fit
one PR; it is split so external reviewers can read each behavior on its own. Unit 1 changes
nothing a user can observe, so the changeset starts on unit 2, and unit 3 extends it.
28 changes: 28 additions & 0 deletions openspec/changes/cli-plan-aware-recovery/specs/cli-check/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## MODIFIED Requirements

### Requirement: Check never writes to the rules tree

`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 how to repair the rule: `taskless rule restore <ruleId>`, 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, 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 <ruleId>`

#### Scenario: A missing rule is not fetched

- **WHEN** reconciliation returns `missing` for a rule
- **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`
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
## MODIFIED Requirements

### Requirement: Reconcile verdicts are applied per engine

The CLI SHALL read the v2 reconcile response as a list of per-rule verdicts
(`rules[]`, each `{ ruleId, engine, verdict }` with `verdict` one of `run`, `unsafe`,
`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 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. "Name the recovery"
means `taskless rule restore <ruleId>`, 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

- **WHEN** reconcile returns `{ ruleId: "no-simply-1a2b3c4d", engine: "vale", verdict: "unsafe", files: [{ path: ".vale.ini", expected, got }] }`
- **THEN** the rule SHALL NOT run
- **AND** `check` SHALL exit non-zero naming the rule and `.vale.ini` as changed

#### Scenario: A locally written static rule runs

- **WHEN** reconcile lists a static rule's id in `unknown` without `copyOf`
- **THEN** that rule SHALL run
- **AND** the CLI SHALL emit no notice for it

#### Scenario: A locally written runtime rule does not execute

- **WHEN** reconcile lists a runtime rule's id in `unknown` and `--dangerously-run-scripts` is not set
- **THEN** the rule SHALL NOT execute
- **AND** its skip reason SHALL say it was not issued by the rule service

#### Scenario: Missing warns and does not fail

- **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 <ruleId>`
- **AND** SHALL NOT change the exit code because of it

#### Scenario: An edited runtime rule is withheld, not failed

- **WHEN** reconcile returns an `unsafe` verdict for a runtime rule
- **THEN** the rule SHALL NOT execute
- **AND** the exit code SHALL NOT change because of that verdict alone

### Requirement: A copy of an issued rule does not run as a local rule

When reconcile returns an `unknown` rule carrying `copyOf` (taskless/taskless#255), the CLI
SHALL NOT run or execute it, and SHALL remove it from the snapshot the engines read. For an
`sg` or `vale` rule, `check` SHALL fail with one message naming the rule, the source rule
`copyOf.ruleId`, and each path in `copyOf.files` as changed, removed, or added. For a runtime
rule, the exit code SHALL NOT change, and its skip reason SHALL name the source and say it
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 <copyOf.ruleId>`, 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
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`, 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

#### Scenario: A copy beside its present source fails check

- **WHEN** reconcile returns sg rule `bar-2` in `unknown` with `copyOf.ruleId` `foo-1`, and `foo-1` is not `missing`
- **THEN** `bar-2` SHALL NOT run
- **AND** `check` SHALL exit non-zero naming `bar-2` as a copy of `foo-1`

#### Scenario: A runtime copy is not executed and does not fail

- **WHEN** reconcile returns a runtime rule in `unknown` with `copyOf`
- **THEN** the rule SHALL NOT execute
- **AND** its skip reason SHALL name the source rule
- **AND** the exit code SHALL NOT change because of it

#### Scenario: An unreadable copyOf fails closed

- **WHEN** reconcile returns a rule in `unknown` whose `copyOf` is present but has no string `ruleId`
- **THEN** the rule SHALL NOT run or execute
- **AND** `check` SHALL exit non-zero naming it
Loading
Loading