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 .changeset/plan-aware-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
12 changes: 10 additions & 2 deletions openspec/specs/cli-check/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ruleId>`. The only files
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
- **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>`

Expand All @@ -597,6 +599,12 @@ name the command that repairs the rule, `taskless rule restore <ruleId>`. 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)
Expand Down
28 changes: 16 additions & 12 deletions openspec/specs/cli-rule-reconciliation/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand All @@ -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 <ruleId>`
- **AND** SHALL NOT change the exit code because of it

Expand Down Expand Up @@ -367,15 +370,16 @@ 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>`, and the CLI SHALL NOT print a separate `missing`
`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`
- **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
Expand Down
75 changes: 74 additions & 1 deletion openspec/specs/cli-rule-recovery/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ruleId> <revisionId>` 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`.
Expand Down Expand Up @@ -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 <ruleId> <revisionId>`

#### Scenario: An unknown rule is reported as not found

- **WHEN** the service answers `404 rule_not_found`
Expand All @@ -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 <ruleId>` 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=<commit>` 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=<commit>` 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 <ruleId>` 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
44 changes: 40 additions & 4 deletions packages/cli/src/agent/recover-rule.md
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -17,7 +17,28 @@ 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`. 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`. 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
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
Expand Down Expand Up @@ -61,7 +82,13 @@ write nothing they cannot verify.

## 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 <ruleId>` 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 <ruleId> --json
```
Expand All @@ -87,7 +114,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 directory>`; `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
Expand Down
14 changes: 9 additions & 5 deletions packages/cli/src/commands/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
},
});
Expand Down
Loading
Loading