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
5 changes: 5 additions & 0 deletions .changeset/cli-rule-revisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

Add `taskless rule revisions <ruleId>`, which lists a rule's recent revisions and marks the current one, so you can pick a revision for `taskless rule rollback` without opening the dashboard. The listing works on every plan. Under `--json` it prints `{ success, ruleId, revisions, truncated }`. The `recover-rule` agent recipe now walks through choosing a revision, and a rollback to a revision that isn't the rule's names `rule revisions` as the fix.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-29
83 changes: 83 additions & 0 deletions openspec/changes/archive/2026-09-29-cli-rule-revisions/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
## Context

`rule restore` and `rule rollback` already talk to v2 through `packages/cli/src/api/v2.ts`. Each
call there returns a `V2Outcome` and never throws for a condition the service documents, and
`packages/cli/src/rules/recover.ts` turns a failed outcome into the `CLIError` a command
reports. The new endpoint (see proposal.md, Why) is a `GET` with `repositoryUrl` and optional
`orgId` in the query. It answers `200 { ruleId, revisions[], truncated }`, or `400
validation_error`, `401`, or `404 organization_not_found | rule_not_found`. It has no plan
refusal: the listing carries no bytes, so the service never reads the plan for it.

## Goals / Non-Goals

**Goals:**

- A revision id an agent can pass to `rule rollback` without leaving the terminal.
- The listing fits the existing recovery surface: the same identity resolution, the same error
codes, and a `--json` shape parsed from a schema, as `rules-recover.ts` is.

**Non-Goals:**

- **Marking which revision is on disk.** That would need a reconcile round trip, meaning a
snapshot and report of the whole tree, to annotate a read-only listing. `current` is the
service's answer to "what should be on disk", and `check` already reports when the disk
disagrees.
- **Interactive selection in `rollback`.** Agents drive these commands, and an optional
`<revisionId>` that opens a prompt would behave differently in CI than at a terminal.
`rollback` keeps both arguments required.
- **Paging past the cap.** The service caps the listing at ten and does not page. `truncated`
sends the user to the dashboard, which lists every revision.

## Decisions

**A separate `rule revisions` command, not a listing mode of `rollback`.** Listing is read-only
and works on every plan, while rollback writes files and is plan-gated. Keeping them as
separate commands means a Free-plan user can see their revision history. It also means
nothing about `rollback --json` changes shape. The alternative, `rollback <id>` with no
revision printing the list, would make one command's output mean two different things
depending on how many arguments it got.

**`listRevisions` in `v2.ts` goes through `settle` with a completeness-checked code list.**
Its codes are `errorCodes<ErrorCode<"/cli/api/v2/rule/{ruleId}/revisions","get">>()`, so a
later schema refresh that adds a code fails `tsc` here. It accepts the body with a small
guard (`ruleId` string, `revisions` array, `truncated` boolean). Anything else is
`unavailable`, never an empty listing: an empty list read from a malformed body would tell
the user the rule has no history.

**Error mapping reuses `failure()` in `recover.ts`, with one message changed for
`rule_not_found`.** On restore, the service also answers `rule_not_found` for a rule that
exists only on an open pull request, and the restore message says so. The revisions endpoint
has no such case: it lists a PR-only rule, with no revision marked `current` (taskless/taskless
#261's own spec, "A rule with no current revision marks none"). So the listing reports
`RULE_NOT_FOUND` as "not a rule Taskless issued for this repository", without the
pull-request clause. `failure()` takes an optional override for that one message rather than
being copied.

**Identity and output go through the same scaffolding as the other recovery commands.**
`runRecovery` is specific to recovery, because its success path is a `Recovered` write. So
the new command resolves identity and reports errors the same way `runRecovery` does, by
factoring out its `report` / `resolveIdentity` prelude. Its success path prints the listing.
Two copies of the prelude does not meet the threshold for a shared helper. If a third caller
appears, that is the time to extract one.

**`--json` passes the service's revision objects through, parsed by its schema.**
The `outputSchema` in `schemas/rules-revisions.ts`, internal like `rules-recover.ts`, declares every field the service
documents, with `prUrl` optional. The output is `schema.parse(...)` of the assembled
object, as the other recovery commands do. This keeps the output to exactly what is
declared, so a field the service adds later does not leak into our output unannounced.

**The recipe topic moves from v1 to v2.** `recover-rule.md` gains a step: to roll back, run
`rule revisions <ruleId> --json`, pick the revision the user described, and pass its
`revisionId` to `rule rollback`. The Errors table's `REVISION_NOT_FOUND` fix becomes "list them
with `rule revisions`". The recipe is edited by hand and never run through prettier.

## Risks / Trade-offs

- [The service raises the cap or changes the ordering] → The CLI prints whatever order the
service returns and finds the current revision by its flag, so neither change breaks it.
The spec says so explicitly, so a later "sort by date" refactor is caught in review.
- [`createdAt` format drifts] → It is printed as received, not parsed, so a format change
cannot crash the listing.
- [The regenerated `api-v2.d.ts` comes out unformatted] → `generate:api` writes it
unformatted, and the committed file is prettier-formatted. Format it by explicit path
after every refresh. The schema diff for this change is the one added path.
51 changes: 51 additions & 0 deletions openspec/changes/archive/2026-09-29-cli-rule-revisions/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
## Why

`taskless rule rollback <ruleId> <revisionId>` needs a revision id, and nothing in the CLI can
produce one. The `recover-rule` recipe sends the user to the dashboard's rule history to copy
it, so an agent asked to "roll this rule back to the last version" cannot finish without a
human opening a browser. The service now lists a rule's revisions
(`GET /cli/api/v2/rule/{ruleId}/revisions`, taskless/taskless#261, closing #253), deployed
and published in the v2 `__schema` as of 2026-09-30.

## What Changes

- New command `taskless rule revisions <ruleId> [--json]`. It is read-only, needs
authentication and a resolvable repository like `rule restore`, and works on every plan:
the listing carries no rule bytes, so the service does not read the plan for it.
- Human output lists the revisions newest first, marking the current one and showing
when each was generated, how it was delivered, and its pull request when there is one. It
says when older revisions were omitted, and where to see them.
- `--json` prints the listing with its `current` flags and `truncated`, so an agent can pick
a revision and pass it to `rule rollback`.
- `REVISION_NOT_FOUND`'s remedy and the `recover-rule` recipe point at `rule revisions`
instead of the dashboard. `rule rollback`'s arguments and behavior do not change.
- The vendored `api-v2.schema.json` / `api-v2.d.ts` pick up the one new path. The refresh is
purely additive.

## Capabilities

### New Capabilities

_None._

### Modified Capabilities

- `cli-rule-recovery`: adds a requirement for listing a rule's revisions, and for the
listing's `--json` shape and errors. Existing requirements are untouched; the change is
an ADDED requirement, not a MODIFIED one.

## Impact

- `packages/cli/src/api/v2.ts`: a `listRevisions` call beside `restoreRule` / `rollbackRule`.
- `packages/cli/src/rules/recover.ts`: the listing, reusing its failure mapping.
- `packages/cli/src/commands/rules.ts`: the `revisions` subcommand.
- `packages/cli/src/schemas/`: an output schema for `rule revisions --json` beside
`rules-recover.ts`. Like that one it is internal, not exported from `@taskless/cli/schemas`,
which publishes only the `verify` / `test` envelopes.
- `packages/cli/src/agent/recover-rule.md`: the topic version is bumped, and it gains a
step for choosing a revision.
- `packages/cli/src/generated/api-v2.*`: the regenerated contract.

**Delivery shape: single PR.** The whole change is one command over one read-only endpoint,
together with its recipe text and tests, which fits well inside one reviewable diff. It does
not touch anything a published version depends on, so there is nothing to stack.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
## ADDED Requirements

### Requirement: Rule revisions lists what rollback can choose from

The CLI SHALL provide `taskless rule revisions <ruleId>`, which requires authentication and a
resolvable repository and calls `GET /cli/api/v2/rule/{ruleId}/revisions` with
`repositoryUrl` and, when known, `orgId`. It SHALL write nothing to the working tree. It SHALL
NOT treat the plan as a precondition: the listing is served on every plan, so a plan without
rule recovery SHALL still get the list.

The CLI SHALL present the revisions in the order the service returns them and SHALL identify
the current revision by its `current` flag, never by its position, because the service appends
a current revision older than the newest ten after them. Human output SHALL show, for each
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.

`404 rule_not_found` SHALL be reported as `RULE_NOT_FOUND`, a rejected token as
`AUTH_REQUIRED`, and any other failure as `NETWORK_ERROR`.

#### Scenario: Revisions are listed with the current one marked

- **WHEN** the service returns revisions `r3`, `r2`, `r1` with `r2` current and `truncated: false`
- **THEN** the CLI SHALL list all three in that order and mark `r2` as current
- **AND** SHALL NOT say that older revisions were omitted

#### Scenario: A current revision older than the listed ones is still marked

- **WHEN** the service returns ten revisions none of which is current, followed by an eleventh with `current: true`
- **THEN** the CLI SHALL mark the eleventh as current

#### Scenario: A rule with no current revision marks none

- **WHEN** the service returns a single revision with `current: false`, delivered by a pull request that has not merged
- **THEN** the CLI SHALL list it with its `prUrl`, mark no revision as current, and say that the rule has no current revision until a pull request delivering it merges

#### Scenario: Truncation is reported

- **WHEN** the service returns `truncated: true`
- **THEN** the CLI SHALL say that older revisions exist and are listed on the Taskless dashboard

#### Scenario: A plan without recovery still lists revisions

- **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: An unknown rule is reported as not found

- **WHEN** the service answers `404 rule_not_found`
- **THEN** the CLI SHALL exit non-zero with `RULE_NOT_FOUND`

### Requirement: Rule revisions reports under --json

Under `--json`, `rule revisions` SHALL print
`{ success: true, ruleId, revisions: [{ revisionId, createdAt, delivery, requestId, prUrl?, current }], truncated }`
on success, carrying each revision's fields as the service sent them, and the standardized
error envelope `{ ok: false, code, message }` otherwise.

#### Scenario: The listing is machine-readable

- **WHEN** `rule revisions no-eval-3fa9c21b --json` succeeds
- **THEN** stdout SHALL be a single JSON object with `success: true`, `ruleId: "no-eval-3fa9c21b"`, the `revisions` array, and `truncated`

#### Scenario: A failure is machine-readable

- **WHEN** `rule revisions --json` is answered `404 rule_not_found`
- **THEN** stdout SHALL be `{ ok: false, code: "RULE_NOT_FOUND", message }`
22 changes: 22 additions & 0 deletions openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## 1. Contract

- [x] 1.1 Vendor the v2 schema carrying `GET /cli/api/v2/rule/{ruleId}/revisions` (`pnpm --filter @taskless/cli generate:api`, then prettier on `api-v2.d.ts` by explicit path); verify the `api-v2.schema.json` diff is exactly the one added path
- [x] 1.2 Add `listRevisions` to `api/v2.ts` through `settle`, with a completeness-checked code list and a body guard that answers `unavailable` for anything but `{ ruleId, revisions[], truncated }`; verify with `api-v2.test.ts` cases for 200, 401, 404 `rule_not_found`, and a malformed 200

## 2. Command

- [x] 2.1 Add `schemas/rules-revisions.ts` (`{ success, ruleId, revisions[], truncated }`) beside `rules-recover.ts`, internal like it; verify `pnpm typecheck` passes
- [x] 2.2 Add the listing to `rules/recover.ts`, reusing `failure()` with the pull-request clause dropped from `rule_not_found`; verify with `rule-recovery.test.ts` cases for each spec scenario (current marked by flag not position, truncation note, plan-less listing exits 0, `RULE_NOT_FOUND`)
- [x] 2.3 Add `rule revisions <ruleId> [--json]` to `commands/rules.ts`, sharing identity resolution and error reporting with `runRecovery`; verify human output names `rule rollback`, and `--json` success and error envelopes match the spec

## 3. Guidance

- [x] 3.1 Update `agent/recover-rule.md` to topic v2: a step for choosing a revision with `rule revisions --json`, and `REVISION_NOT_FOUND`'s fix pointing at it instead of the dashboard (hand-edited, no prettier); verify `prompts.test.ts` and `recipe-cross-references.test.ts` pass
- [x] 3.2 Point `REVISION_NOT_FOUND`'s CLI message at `rule revisions <ruleId>`; verify with the existing rollback test
- [x] 3.3 Add a patch changeset for `@taskless/cli` describing the new command

## 4. Verify

- [x] 4.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass
- [x] 4.2 With a `pnpm build:next` build, run `pnpm cli rule revisions <id>` and `--json` against production for a real issued rule, and `rule rollback` to one of the listed ids; record the result in the PR
- [x] 4.3 Archive the change on this PR (`pnpm openspec archive cli-rule-revisions -y`), after the pre-archive scenario check that every prior `cli-rule-recovery` scenario survives
67 changes: 67 additions & 0 deletions openspec/specs/cli-rule-recovery/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,70 @@ envelope `{ ok: false, code, message }` otherwise, with `code` distinguishing

- **WHEN** `rule restore --json` is refused for the plan
- **THEN** stdout SHALL be `{ ok: false, code: "RULE_RECOVERY_NOT_IN_PLAN", message }` with `message` carrying the server's guidance

### Requirement: Rule revisions lists what rollback can choose from

The CLI SHALL provide `taskless rule revisions <ruleId>`, which requires authentication and a
resolvable repository and calls `GET /cli/api/v2/rule/{ruleId}/revisions` with
`repositoryUrl` and, when known, `orgId`. It SHALL write nothing to the working tree. It SHALL
NOT treat the plan as a precondition: the listing is served on every plan, so a plan without
rule recovery SHALL still get the list.

The CLI SHALL present the revisions in the order the service returns them and SHALL identify
the current revision by its `current` flag, never by its position, because the service appends
a current revision older than the newest ten after them. Human output SHALL show, for each
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.

`404 rule_not_found` SHALL be reported as `RULE_NOT_FOUND`, a rejected token as
`AUTH_REQUIRED`, and any other failure as `NETWORK_ERROR`.

#### Scenario: Revisions are listed with the current one marked

- **WHEN** the service returns revisions `r3`, `r2`, `r1` with `r2` current and `truncated: false`
- **THEN** the CLI SHALL list all three in that order and mark `r2` as current
- **AND** SHALL NOT say that older revisions were omitted

#### Scenario: A current revision older than the listed ones is still marked

- **WHEN** the service returns ten revisions none of which is current, followed by an eleventh with `current: true`
- **THEN** the CLI SHALL mark the eleventh as current

#### Scenario: A rule with no current revision marks none

- **WHEN** the service returns a single revision with `current: false`, delivered by a pull request that has not merged
- **THEN** the CLI SHALL list it with its `prUrl`, mark no revision as current, and say that the rule has no current revision until a pull request delivering it merges

#### Scenario: Truncation is reported

- **WHEN** the service returns `truncated: true`
- **THEN** the CLI SHALL say that older revisions exist and are listed on the Taskless dashboard

#### Scenario: A plan without recovery still lists revisions

- **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: An unknown rule is reported as not found

- **WHEN** the service answers `404 rule_not_found`
- **THEN** the CLI SHALL exit non-zero with `RULE_NOT_FOUND`

### Requirement: Rule revisions reports under --json

Under `--json`, `rule revisions` SHALL print
`{ success: true, ruleId, revisions: [{ revisionId, createdAt, delivery, requestId, prUrl?, current }], truncated }`
on success, carrying each revision's fields as the service sent them, and the standardized
error envelope `{ ok: false, code, message }` otherwise.

#### Scenario: The listing is machine-readable

- **WHEN** `rule revisions no-eval-3fa9c21b --json` succeeds
- **THEN** stdout SHALL be a single JSON object with `success: true`, `ruleId: "no-eval-3fa9c21b"`, the `revisions` array, and `truncated`

#### Scenario: A failure is machine-readable

- **WHEN** `rule revisions --json` is answered `404 rule_not_found`
- **THEN** stdout SHALL be `{ ok: false, code: "RULE_NOT_FOUND", message }`
Loading
Loading