From 6c85610b428cf6e9cf534bf56698869229b10ac7 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 20:28:07 -0700 Subject: [PATCH 1/6] feat(api): list a rule's revisions over v2 Vendors GET /cli/api/v2/rule/{ruleId}/revisions (taskless/taskless#261) and adds listRevisions beside restore and rollback. Proposes cli-rule-revisions. --- .../changes/cli-rule-revisions/.openspec.yaml | 2 + openspec/changes/cli-rule-revisions/design.md | 83 +++++++++ .../changes/cli-rule-revisions/proposal.md | 50 +++++ .../specs/cli-rule-recovery/spec.md | 68 +++++++ openspec/changes/cli-rule-revisions/tasks.md | 22 +++ packages/cli/src/api/v2.ts | 65 +++++++ packages/cli/src/generated/api-v2.d.ts | 118 ++++++++++++ packages/cli/src/generated/api-v2.schema.json | 171 ++++++++++++++++++ packages/cli/test/api-v2.test.ts | 49 +++++ 9 files changed, 628 insertions(+) create mode 100644 openspec/changes/cli-rule-revisions/.openspec.yaml create mode 100644 openspec/changes/cli-rule-revisions/design.md create mode 100644 openspec/changes/cli-rule-revisions/proposal.md create mode 100644 openspec/changes/cli-rule-revisions/specs/cli-rule-recovery/spec.md create mode 100644 openspec/changes/cli-rule-revisions/tasks.md diff --git a/openspec/changes/cli-rule-revisions/.openspec.yaml b/openspec/changes/cli-rule-revisions/.openspec.yaml new file mode 100644 index 00000000..29382a2c --- /dev/null +++ b/openspec/changes/cli-rule-revisions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-29 diff --git a/openspec/changes/cli-rule-revisions/design.md b/openspec/changes/cli-rule-revisions/design.md new file mode 100644 index 00000000..ffd21684 --- /dev/null +++ b/openspec/changes/cli-rule-revisions/design.md @@ -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 published through `@taskless/cli/schemas`. + +**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 + `` 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 ` 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>()`, 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 the published schema.** +`rulesRevisionsOutputSchema` in `schemas/rules-revisions.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 --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. diff --git a/openspec/changes/cli-rule-revisions/proposal.md b/openspec/changes/cli-rule-revisions/proposal.md new file mode 100644 index 00000000..575f2760 --- /dev/null +++ b/openspec/changes/cli-rule-revisions/proposal.md @@ -0,0 +1,50 @@ +## Why + +`taskless rule rollback ` 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 [--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`, published + through `@taskless/cli/schemas` like the others. +- `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. diff --git a/openspec/changes/cli-rule-revisions/specs/cli-rule-recovery/spec.md b/openspec/changes/cli-rule-revisions/specs/cli-rule-recovery/spec.md new file mode 100644 index 00000000..3e6e7f49 --- /dev/null +++ b/openspec/changes/cli-rule-revisions/specs/cli-rule-recovery/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Rule revisions lists what rollback can choose from + +The CLI SHALL provide `taskless rule revisions `, 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 ` 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 }` diff --git a/openspec/changes/cli-rule-revisions/tasks.md b/openspec/changes/cli-rule-revisions/tasks.md new file mode 100644 index 00000000..79f41d09 --- /dev/null +++ b/openspec/changes/cli-rule-revisions/tasks.md @@ -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 + +- [ ] 2.1 Add `schemas/rules-revisions.ts` (`{ success, ruleId, revisions[], truncated }`) and export it from `@taskless/cli/schemas`; verify `pnpm typecheck` and the schemas entry build pass +- [ ] 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`) +- [ ] 2.3 Add `rule revisions [--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 + +- [ ] 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 +- [ ] 3.2 Point `REVISION_NOT_FOUND`'s CLI message at `rule revisions `; verify with the existing rollback test +- [ ] 3.3 Add a patch changeset for `@taskless/cli` describing the new command + +## 4. Verify + +- [ ] 4.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass +- [ ] 4.2 With a `pnpm build:next` build, run `pnpm cli rule revisions ` and `--json` against production for a real issued rule, and `rule rollback` to one of the listed ids; record the result in the PR +- [ ] 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 diff --git a/packages/cli/src/api/v2.ts b/packages/cli/src/api/v2.ts index 401bc8bb..1d807779 100644 --- a/packages/cli/src/api/v2.ts +++ b/packages/cli/src/api/v2.ts @@ -304,6 +304,71 @@ export function rollbackRule( ); } +/** A rule's recent revisions: the `200` body of the revisions listing. */ +export type RevisionList = OkBody<"/cli/api/v2/rule/{ruleId}/revisions", "get">; + +/** One entry of a {@link RevisionList}. */ +export type RevisionEntry = RevisionList["revisions"][number]; + +export type RevisionsCode = ErrorCode< + "/cli/api/v2/rule/{ruleId}/revisions", + "get" +>; + +const REVISIONS_CODES = errorCodes< + ErrorCode<"/cli/api/v2/rule/{ruleId}/revisions", "get"> +>()(["validation_error", "organization_not_found", "rule_not_found"]); + +/** + * Accept a revisions listing only when it has the documented shape. Anything + * else is `unavailable`, never an empty list: reading a malformed body as no + * revisions would tell the user the rule has no history. + */ +function acceptRevisions( + data: unknown +): V2Outcome { + if ( + !isRecord(data) || + typeof data.ruleId !== "string" || + !Array.isArray(data.revisions) || + typeof data.truncated !== "boolean" + ) { + return { + status: "unavailable", + reason: "the response was not a revision listing", + }; + } + return { status: "ok", data: data as unknown as RevisionList }; +} + +/** + * List a rule's recent revisions, to choose one to roll back to. Carries no + * rule bytes, so it is served on every plan and never refused. + */ +export function listRevisions( + token: string, + ruleId: string, + query: { repositoryUrl: string; orgId?: string | number } +): Promise> { + const client = createV2Client(token); + return settle( + () => + client.GET("/cli/api/v2/rule/{ruleId}/revisions", { + params: { + path: { ruleId }, + query: { + repositoryUrl: query.repositoryUrl, + ...(query.orgId === undefined + ? {} + : { orgId: String(query.orgId) }), + }, + }, + }), + REVISIONS_CODES, + acceptRevisions + ); +} + // --- Generation: request, poll, iterate --- export type RequestBody = NonNullable< diff --git a/packages/cli/src/generated/api-v2.d.ts b/packages/cli/src/generated/api-v2.d.ts index 51f17e78..d673e37e 100644 --- a/packages/cli/src/generated/api-v2.d.ts +++ b/packages/cli/src/generated/api-v2.d.ts @@ -862,6 +862,124 @@ export interface paths { patch?: never; trace?: never; }; + "/cli/api/v2/rule/{ruleId}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A rule’s newest revisions and which one is current, on every plan, to choose one to roll back to */ + get: { + parameters: { + query: { + /** @description Full repository URL the rule belongs to */ + repositoryUrl: string; + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string; + }; + header?: never; + path: { + /** @description The rule id reconcile returns: the rule’s directory name, stable for the life of the rule. NOT a request id, unlike the legacy v1 `rule/{ruleId}`. */ + ruleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description The rule listed */ + ruleId: string; + /** @description The newest 10 revisions, newest first, followed by the current revision when it is older than those. A rollback writes no revision: it shows only as `current` moving. The cap may change. */ + revisions: { + /** @description Pass to rollback, or to fetch as `revision` */ + revisionId: string; + /** @description When the revision was generated */ + createdAt: string; + /** + * @description How the revision was delivered + * @enum {string} + */ + delivery: "cli" | "pull-request"; + /** @description The request that produced it */ + requestId: string; + /** @description The pull request that delivered it; only for pull-request delivery */ + prUrl?: string; + /** @description Whether it is the rule's effective current revision; at most one is */ + current: boolean; + }[]; + /** @description Whether the rule has older revisions this response omits; the dashboard's rule page lists them all */ + truncated: boolean; + }; + }; + }; + /** @description `validation_error`: The body or query failed validation, or the body is not JSON; `details` lists why. Not retryable as sent. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "validation_error"; + /** @description Human-readable reasons, when the code carries them */ + details?: string[]; + }; + }; + }; + /** @description `unauthorized`: Missing or invalid bearer token. Log in again. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "unauthorized"; + }; + }; + }; + /** + * @description `organization_not_found`: The organization is not accessible to this user, or its GitHub App installation does not cover the repository. Deliberately indistinguishable, so a probe cannot tell 'not yours' from 'does not exist'. + * + * `rule_not_found`: Not a rule of this repository. On restore, also a rule with no current revision (it exists only on an open pull request). Answered on every plan, before any plan refusal. + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "organization_not_found" | "rule_not_found"; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/cli/api/v2/rule/{ruleId}": { parameters: { query?: never; diff --git a/packages/cli/src/generated/api-v2.schema.json b/packages/cli/src/generated/api-v2.schema.json index 54b66ea1..1cd58b82 100644 --- a/packages/cli/src/generated/api-v2.schema.json +++ b/packages/cli/src/generated/api-v2.schema.json @@ -1460,6 +1460,177 @@ } } }, + "/cli/api/v2/rule/{ruleId}/revisions": { + "get": { + "summary": "A rule’s newest revisions and which one is current, on every plan, to choose one to roll back to", + "parameters": [ + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The rule id reconcile returns: the rule’s directory name, stable for the life of the rule. NOT a request id, unlike the legacy v1 `rule/{ruleId}`." + }, + { + "name": "repositoryUrl", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Full repository URL the rule belongs to" + }, + "description": "Full repository URL the rule belongs to" + }, + { + "name": "orgId", + "in": "query", + "required": false, + "schema": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "type": "string" + }, + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule listed" + }, + "revisions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "revisionId": { + "type": "string", + "description": "Pass to rollback, or to fetch as `revision`" + }, + "createdAt": { + "type": "string", + "description": "When the revision was generated" + }, + "delivery": { + "type": "string", + "enum": ["cli", "pull-request"], + "description": "How the revision was delivered" + }, + "requestId": { + "type": "string", + "description": "The request that produced it" + }, + "prUrl": { + "description": "The pull request that delivered it; only for pull-request delivery", + "type": "string" + }, + "current": { + "type": "boolean", + "description": "Whether it is the rule's effective current revision; at most one is" + } + }, + "required": [ + "revisionId", + "createdAt", + "delivery", + "requestId", + "current" + ], + "additionalProperties": false + }, + "description": "The newest 10 revisions, newest first, followed by the current revision when it is older than those. A rollback writes no revision: it shows only as `current` moving. The cap may change." + }, + "truncated": { + "type": "boolean", + "description": "Whether the rule has older revisions this response omits; the dashboard's rule page lists them all" + } + }, + "required": ["ruleId", "revisions", "truncated"], + "additionalProperties": false, + "description": "A rule's recent revisions, without their files, on every plan. Fetching an older revision or rolling back to one requires restoreRules." + } + } + } + }, + "400": { + "description": "`validation_error`: The body or query failed validation, or the body is not JSON; `details` lists why. Not retryable as sent.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["validation_error"], + "description": "Machine-readable error code" + }, + "details": { + "description": "Human-readable reasons, when the code carries them", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "401": { + "description": "`unauthorized`: Missing or invalid bearer token. Log in again.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["unauthorized"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "404": { + "description": "`organization_not_found`: The organization is not accessible to this user, or its GitHub App installation does not cover the repository. Deliberately indistinguishable, so a probe cannot tell 'not yours' from 'does not exist'.\n\n`rule_not_found`: Not a rule of this repository. On restore, also a rule with no current revision (it exists only on an open pull request). Answered on every plan, before any plan refusal.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["organization_not_found", "rule_not_found"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, "/cli/api/v2/rule/{ruleId}": { "get": { "summary": "A rule by its id: its head on every plan, or an older revision with restoreRules", diff --git a/packages/cli/test/api-v2.test.ts b/packages/cli/test/api-v2.test.ts index b43994b7..5aecf59b 100644 --- a/packages/cli/test/api-v2.test.ts +++ b/packages/cli/test/api-v2.test.ts @@ -4,6 +4,7 @@ import { fetchRule, getRequestStatus, iterateRule, + listRevisions, reconcileRules, restoreRule, rollbackRule, @@ -151,6 +152,54 @@ describe("v2 client", () => { }); }); + describe("revisions", () => { + const LISTING = { + ruleId: "no-eval-3fa9c21b", + revisions: [ + { + revisionId: "rev-2", + createdAt: "2026-09-29T12:00:00.000Z", + delivery: "cli", + requestId: "req-2", + current: true, + }, + ], + truncated: false, + }; + + it("lists by rule id, with the repository in the query", async () => { + respond(200, LISTING); + const outcome = await listRevisions("tok", "no-eval-3fa9c21b", { + repositoryUrl: REPO, + orgId: 42, + }); + + const url = new URL(sent().url); + expect(url.pathname).toBe("/cli/api/v2/rule/no-eval-3fa9c21b/revisions"); + expect(url.searchParams.get("repositoryUrl")).toBe(REPO); + expect(url.searchParams.get("orgId")).toBe("42"); + expect(outcome).toEqual({ status: "ok", data: LISTING }); + }); + + it("maps rule_not_found, so a caller can report RULE_NOT_FOUND", async () => { + respond(404, { error: "rule_not_found" }); + const outcome = await listRevisions("tok", "r", { repositoryUrl: REPO }); + expect(outcome).toMatchObject({ + status: "error", + code: "rule_not_found", + }); + }); + + it("never reads a body that is not a listing as an empty history", async () => { + respond(200, { ruleId: "r", revisions: [] }); + const outcome = await listRevisions("tok", "r", { repositoryUrl: REPO }); + expect(outcome).toEqual({ + status: "unavailable", + reason: "the response was not a revision listing", + }); + }); + }); + describe("errors", () => { it("maps a documented code to an error outcome, keeping details", async () => { respond(400, { From 1511c76901e429e867ecdfdc399795c9e003be6f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 20:30:47 -0700 Subject: [PATCH 2/6] feat(rule): add rule revisions, to choose a revision for rollback Lists a rule's recent revisions on every plan, marking the current one by its flag. REVISION_NOT_FOUND now names the command instead of the dashboard. --- openspec/changes/cli-rule-revisions/design.md | 6 +- .../changes/cli-rule-revisions/proposal.md | 5 +- openspec/changes/cli-rule-revisions/tasks.md | 8 +- packages/cli/src/commands/rules.ts | 69 ++++++++-- packages/cli/src/rules/recover.ts | 81 +++++++++++- packages/cli/src/schemas/rules-revisions.ts | 43 ++++++ packages/cli/test/rule-recovery.test.ts | 122 ++++++++++++++++++ 7 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/schemas/rules-revisions.ts diff --git a/openspec/changes/cli-rule-revisions/design.md b/openspec/changes/cli-rule-revisions/design.md index ffd21684..770aa923 100644 --- a/openspec/changes/cli-rule-revisions/design.md +++ b/openspec/changes/cli-rule-revisions/design.md @@ -14,7 +14,7 @@ refusal: the listing carries no bytes, so the service never reads the plan for i - 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 published through `@taskless/cli/schemas`. + codes, and a `--json` shape parsed from a schema, as `rules-recover.ts` is. **Non-Goals:** @@ -60,8 +60,8 @@ factoring out its `report` / `resolveIdentity` prelude. Its success path prints 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 the published schema.** -`rulesRevisionsOutputSchema` in `schemas/rules-revisions.ts` declares every field the service +**`--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. diff --git a/openspec/changes/cli-rule-revisions/proposal.md b/openspec/changes/cli-rule-revisions/proposal.md index 575f2760..f1740249 100644 --- a/openspec/changes/cli-rule-revisions/proposal.md +++ b/openspec/changes/cli-rule-revisions/proposal.md @@ -39,8 +39,9 @@ _None._ - `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`, published - through `@taskless/cli/schemas` like the others. +- `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. diff --git a/openspec/changes/cli-rule-revisions/tasks.md b/openspec/changes/cli-rule-revisions/tasks.md index 79f41d09..0afbee27 100644 --- a/openspec/changes/cli-rule-revisions/tasks.md +++ b/openspec/changes/cli-rule-revisions/tasks.md @@ -5,14 +5,14 @@ ## 2. Command -- [ ] 2.1 Add `schemas/rules-revisions.ts` (`{ success, ruleId, revisions[], truncated }`) and export it from `@taskless/cli/schemas`; verify `pnpm typecheck` and the schemas entry build pass -- [ ] 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`) -- [ ] 2.3 Add `rule revisions [--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 +- [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 [--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 - [ ] 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 -- [ ] 3.2 Point `REVISION_NOT_FOUND`'s CLI message at `rule revisions `; verify with the existing rollback test +- [x] 3.2 Point `REVISION_NOT_FOUND`'s CLI message at `rule revisions `; verify with the existing rollback test - [ ] 3.3 Add a patch changeset for `@taskless/cli` describing the new command ## 4. Verify diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index f194853b..01b7325d 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -31,9 +31,12 @@ import { } from "../schemas/rules-improve"; import { outputSchema as metaOutputSchema } from "../schemas/rules-meta"; import { outputSchema as recoverOutputSchema } from "../schemas/rules-recover"; +import { outputSchema as revisionsOutputSchema } from "../schemas/rules-revisions"; import { beginRestore, + describeRevisions, restore, + revisions, rollback, type Recovered, } from "../rules/recover"; @@ -672,15 +675,15 @@ const deleteCommand = defineCommand({ }); /** - * The shared body of `rule restore` and `rule rollback`: resolve identity, run - * the recovery, and report it in both output modes. Every failure is a - * `CLIError` carrying the code an agent branches on. + * Resolve identity and run `act` for a command that addresses an issued rule + * by id, reporting any failure in both output modes. Every failure is a + * `CLIError` carrying the code an agent branches on. Returns `undefined` once + * a failure has been reported. */ -async function runRecovery( +async function runForIssuedRule( args: { dir?: string; json: boolean }, - ruleId: string, - recover: (cwd: string, identity: Identity) => Promise -): Promise { + act: (cwd: string, identity: Identity) => Promise +): Promise { const cwd = resolve(args.dir ?? process.cwd()); const report = (message: string, code: CLIErrorCode): void => { if (args.json) writeJsonError(code, message); @@ -696,19 +699,31 @@ async function runRecovery( error instanceof Error ? error.message : String(error), identityFailureCode(error) ); - return; + return undefined; } - let outcome: Recovered | string; try { - outcome = await recover(cwd, identity); + return await act(cwd, identity); } catch (error) { report( error instanceof Error ? error.message : String(error), error instanceof CLIError && error.code ? error.code : "INTERNAL_ERROR" ); - return; + return undefined; } +} + +/** + * The shared body of `rule restore` and `rule rollback`: run the recovery and + * report it in both output modes. + */ +async function runRecovery( + args: { dir?: string; json: boolean }, + ruleId: string, + recover: (cwd: string, identity: Identity) => Promise +): Promise { + const outcome = await runForIssuedRule(args, recover); + if (outcome === undefined) return; // A string is "nothing to do": the rule is already intact. if (typeof outcome === "string") { @@ -799,6 +814,37 @@ const rollbackCommand = defineCommand({ }, }); +const revisionsCommand = defineCommand({ + meta: { + name: "revisions", + description: + "List a rule's recent revisions, to choose one for `rule rollback`", + }, + args: { + dir: { type: "string", alias: "d", description: "Working directory" }, + json: { type: "boolean", description: "Output as JSON", default: false }, + id: { + type: "positional", + description: + "Rule id: its directory name under .taskless/rules//", + required: true, + }, + }, + async run({ args }) { + const list = await runForIssuedRule(args, (_cwd, identity) => + revisions(identity, args.id) + ); + if (list === undefined) return; + if (args.json) { + console.log( + JSON.stringify(revisionsOutputSchema.parse({ success: true, ...list })) + ); + } else { + for (const line of describeRevisions(list)) console.log(line); + } + }, +}); + export const ruleCommand = defineCommand({ meta: { name: "rule", @@ -811,5 +857,6 @@ export const ruleCommand = defineCommand({ delete: deleteCommand, restore: restoreCommand, rollback: rollbackCommand, + revisions: revisionsCommand, }, }); diff --git a/packages/cli/src/rules/recover.ts b/packages/cli/src/rules/recover.ts index 8728b8a1..48ee25b8 100644 --- a/packages/cli/src/rules/recover.ts +++ b/packages/cli/src/rules/recover.ts @@ -1,7 +1,9 @@ import { + listRevisions, reconcileRules, restoreRule, rollbackRule, + type RevisionList, type ServedRule, type V2Outcome, } from "../api/v2"; @@ -57,10 +59,17 @@ function records(value: unknown): Record[] { return Array.isArray(value) ? value.filter((entry) => isRecord(entry)) : []; } -/** Map a v2 failure to the error a recovery command reports. */ +/** + * Map a v2 failure to the error a recovery command reports. + * + * `notFound` replaces the `rule_not_found` message for a route where that code + * means less than it does on restore, which also answers it for a rule that + * exists only on an open pull request. + */ function failure( outcome: Exclude, { status: "ok" }>, - ruleId: string + ruleId: string, + notFound?: string ): CLIError { switch (outcome.status) { case "refused": { @@ -91,13 +100,14 @@ function failure( switch (outcome.code) { case "rule_not_found": { return new CLIError( - `Rule ${ruleId} is not a rule Taskless issued for this repository, or it only exists on an open pull request, so there is nothing to recover.`, + notFound ?? + `Rule ${ruleId} is not a rule Taskless issued for this repository, or it only exists on an open pull request, so there is nothing to recover.`, "RULE_NOT_FOUND" ); } case "revision_not_found": { return new CLIError( - `That revision is not a revision of rule ${ruleId}. Check the revision id.`, + `That revision is not a revision of rule ${ruleId}. Run \`${getCliPrefix()} rule revisions ${ruleId}\` to list its revisions.`, "REVISION_NOT_FOUND" ); } @@ -383,3 +393,66 @@ async function write( ); return { ruleId: fileSet.id, revisionId, files: [ruleFile], notices }; } + +/** + * List `ruleId`'s recent revisions, to choose one for `rule rollback`. + * + * Read-only and served on every plan, so unlike restore and rollback there is + * no refusal to relay and nothing is reconciled first. + */ +export async function revisions( + identity: Identity, + ruleId: string +): Promise { + const outcome = await listRevisions(identity.token, ruleId, { + repositoryUrl: identity.repositoryUrl, + orgId: identity.orgSubject, + }); + if (outcome.status !== "ok") { + throw failure( + outcome, + ruleId, + `Rule ${ruleId} is not a rule Taskless issued for this repository, so it has no revisions.` + ); + } + return outcome.data; +} + +/** + * 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. + */ +export function describeRevisions(list: RevisionList): string[] { + const { ruleId } = list; + if (list.revisions.length === 0) { + return [`Rule ${ruleId} has no revisions.`]; + } + const lines = [`Revisions of rule ${ruleId}, newest first:`, ""]; + for (const revision of list.revisions) { + const fields = [ + revision.current ? "*" : " ", + revision.revisionId, + revision.createdAt, + revision.delivery, + ...(revision.prUrl === undefined ? [] : [revision.prUrl]), + ...(revision.current ? ["(current)"] : []), + ]; + lines.push(fields.join(" ")); + } + lines.push(""); + if (list.truncated) { + lines.push( + "Older revisions exist and are not listed here; the rule's page on the Taskless dashboard lists every one." + ); + } + if (!list.revisions.some((revision) => revision.current)) { + lines.push( + `Rule ${ruleId} has no current revision: it exists only on a pull request that has not merged, and gets one when that pull request merges.` + ); + } + lines.push( + `Make a revision current with \`${getCliPrefix()} rule rollback ${ruleId} \`.` + ); + return lines; +} diff --git a/packages/cli/src/schemas/rules-revisions.ts b/packages/cli/src/schemas/rules-revisions.ts new file mode 100644 index 00000000..43092e94 --- /dev/null +++ b/packages/cli/src/schemas/rules-revisions.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +/** Output schema for `taskless rule revisions --json` on success */ +export const outputSchema = z.object({ + success: z.literal(true), + ruleId: z + .string() + .describe( + "The rule's id: its directory name under `.taskless/rules//`" + ), + revisions: z + .array( + z.object({ + revisionId: z + .string() + .describe("Pass to `rule rollback` to make this revision current"), + createdAt: z.string().describe("When the revision was generated"), + delivery: z + .enum(["cli", "pull-request"]) + .describe("How the revision was delivered"), + requestId: z.string().describe("The request that produced it"), + prUrl: z + .string() + .optional() + .describe( + "The pull request that delivered it; only for pull-request delivery" + ), + current: z + .boolean() + .describe( + "Whether it is the rule's current revision. At most one is, and none is while the rule exists only on an unmerged pull request" + ), + }) + ) + .describe( + "Newest first, followed by the current revision when it is older than those. Find the current one by `current`, not by position" + ), + truncated: z + .boolean() + .describe( + "Whether older revisions exist that this listing omits; the Taskless dashboard lists every one" + ), +}); diff --git a/packages/cli/test/rule-recovery.test.ts b/packages/cli/test/rule-recovery.test.ts index 4b65e60a..824c779d 100644 --- a/packages/cli/test/rule-recovery.test.ts +++ b/packages/cli/test/rule-recovery.test.ts @@ -60,6 +60,8 @@ interface Stub { served?: Record; /** A status for restore / rollback other than 200. */ status?: number; + /** The body the revisions listing answers with, and its status. */ + revisions?: { body: unknown; status?: number }; } const REFUSAL = { @@ -70,6 +72,28 @@ const REFUSAL = { upgradeUrl: "https://app.taskless.io/org/1/upgrade?from=restore", }; +function revision( + revisionId: string, + current: boolean, + extra: Record = {} +) { + return { + revisionId, + createdAt: "2026-09-29T12:00:00.000Z", + delivery: "cli", + requestId: `req-${revisionId}`, + current, + ...extra, + }; +} + +function listing(revisions: unknown[], truncated = false) { + return { ruleId: RULE_ID, revisions, truncated }; +} + +const currentLine = (printed: string) => + printed.split("\n").filter((line) => line.includes("(current)")); + describe("rule restore / rule rollback", () => { let cwd: string; let logSpy: MockInstance<(...data: unknown[]) => void>; @@ -148,6 +172,11 @@ describe("rule restore / rule rollback", () => { : { runtimeSignatures: true }, }); } + if (url.pathname.endsWith("/revisions") && options.revisions) { + return Response.json(options.revisions.body, { + status: options.revisions.status ?? 200, + }); + } if ( url.pathname.endsWith("/restore") || url.pathname.endsWith("/rollback") @@ -176,6 +205,14 @@ describe("rule restore / rule rollback", () => { >; } + /** Run without `--json`, returning what was printed to stdout. */ + async function print(): Promise { + await runCommand(ruleCommand, { + rawArgs: ["revisions", RULE_ID, "-d", cwd], + }); + return logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + } + const restoreCalled = () => calls.some((call) => call.endsWith("/restore")); beforeEach(async () => { @@ -475,6 +512,7 @@ describe("rule restore / rule rollback", () => { }); const output = await run(["rollback", RULE_ID, "someone-elses"]); expect(output).toMatchObject({ ok: false, code: "REVISION_NOT_FOUND" }); + expect(String(output.message)).toContain(`rule revisions ${RULE_ID}`); }); it("rollback relays a plan refusal", async () => { @@ -531,4 +569,88 @@ describe("rule restore / rule rollback", () => { const output = await run(["restore", RULE_ID]); expect(String(output.message).split(REFUSAL.upgradeUrl)).toHaveLength(2); }); + + describe("rule revisions", () => { + it("lists revisions in the service's order and marks the current one", async () => { + stub({ + verdict: { kind: "run" }, + revisions: { + body: listing([ + revision("r3", false), + revision("r2", true), + revision("r1", false), + ]), + }, + }); + const printed = await print(); + expect(printed.indexOf("r3")).toBeLessThan(printed.indexOf("r2")); + expect(printed.indexOf("r2")).toBeLessThan(printed.indexOf("r1")); + expect(currentLine(printed)).toHaveLength(1); + expect(currentLine(printed)[0]).toContain("r2"); + expect(printed).not.toContain("Older revisions"); + expect(printed).toContain(`rule rollback ${RULE_ID} `); + expect(process.exitCode).toBeUndefined(); + }); + + 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) + ); + stub({ + verdict: { kind: "run" }, + revisions: { + body: listing([...newest, revision("old", true)], true), + }, + }); + const printed = await print(); + expect(currentLine(printed)).toHaveLength(1); + expect(currentLine(printed)[0]).toContain("old"); + }); + + it("marks none for a rule that exists only on an unmerged pull request", async () => { + const prUrl = "https://github.com/test/test/pull/7"; + stub({ + verdict: { kind: "run" }, + revisions: { + body: listing([ + revision("r1", false, { delivery: "pull-request", prUrl }), + ]), + }, + }); + const printed = await print(); + expect(currentLine(printed)).toHaveLength(0); + expect(printed).toContain(prUrl); + expect(printed).toContain("has no current revision"); + }); + + it("says when older revisions were omitted, and where to find them", async () => { + stub({ + verdict: { kind: "run" }, + revisions: { body: listing([revision("r1", true)], true) }, + }); + expect(await print()).toContain("Taskless dashboard"); + }); + + it("prints the listing under --json, without reconciling or reading the plan", async () => { + const body = listing([revision("r2", true), revision("r1", false)]); + stub({ verdict: { kind: "run" }, revisions: { body } }); + const output = await run(["revisions", RULE_ID]); + expect(output).toEqual({ success: true, ...body }); + expect(process.exitCode).toBeUndefined(); + expect(calls).toContain(`GET /cli/api/v2/rule/${RULE_ID}/revisions`); + expect(calls.some((call) => call.endsWith("/reconcile"))).toBe(false); + }); + + it("reports an unknown rule as RULE_NOT_FOUND", async () => { + stub({ + verdict: { kind: "run" }, + revisions: { body: { error: "rule_not_found" }, status: 404 }, + }); + const output = await run(["revisions", RULE_ID]); + expect(output).toMatchObject({ ok: false, code: "RULE_NOT_FOUND" }); + // Restore's pull-request clause does not apply: a PR-only rule is listed. + expect(String(output.message)).not.toContain("pull request"); + expect(process.exitCode).toBe(1); + }); + }); }); From 0fb52216a8e7c428c35689a8ed216124910ffdf0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 20:32:45 -0700 Subject: [PATCH 3/6] docs(agent): choose a rollback revision with rule revisions recover-rule moves to topic v2: it lists revisions instead of sending the user to the dashboard for an id. Adds the changeset. --- .changeset/cli-rule-revisions.md | 5 +++ openspec/changes/cli-rule-revisions/tasks.md | 6 ++-- packages/cli/src/agent/recover-rule.md | 36 +++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 .changeset/cli-rule-revisions.md diff --git a/.changeset/cli-rule-revisions.md b/.changeset/cli-rule-revisions.md new file mode 100644 index 00000000..625c73ab --- /dev/null +++ b/.changeset/cli-rule-revisions.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Add `taskless rule revisions `, 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. diff --git a/openspec/changes/cli-rule-revisions/tasks.md b/openspec/changes/cli-rule-revisions/tasks.md index 0afbee27..3ce9a00d 100644 --- a/openspec/changes/cli-rule-revisions/tasks.md +++ b/openspec/changes/cli-rule-revisions/tasks.md @@ -11,12 +11,12 @@ ## 3. Guidance -- [ ] 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.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 `; verify with the existing rollback test -- [ ] 3.3 Add a patch changeset for `@taskless/cli` describing the new command +- [x] 3.3 Add a patch changeset for `@taskless/cli` describing the new command ## 4. Verify -- [ ] 4.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass +- [x] 4.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass - [ ] 4.2 With a `pnpm build:next` build, run `pnpm cli rule revisions ` and `--json` against production for a real issued rule, and `rule rollback` to one of the listed ids; record the result in the PR - [ ] 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 diff --git a/packages/cli/src/agent/recover-rule.md b/packages/cli/src/agent/recover-rule.md index eb1fe5a1..3625ed1d 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 v1) +# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v2) ## Goal Put an issued rule back the way Taskless issued it, after `check` @@ -11,8 +11,11 @@ write nothing they cannot verify. edited (`unsafe`) or is missing. - `%(TASKLESS_CLI)s rule rollback ` makes an earlier revision the rule's current one and writes it. Use it only - when the user asks to go back to a specific revision; the revision id - comes from the Taskless dashboard's rule history. + when the user asks to go back to a specific revision; take the + revision id from `rule revisions` (see Rolling back). +- `%(TASKLESS_CLI)s rule revisions ` lists the rule's recent + 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`. @@ -54,6 +57,31 @@ write nothing they cannot verify. (`%(TASKLESS_CLI)s agent improve-rule`) or write a new local rule under a new id. +## Rolling back + +1. **List the revisions.** + ``` + %(TASKLESS_CLI)s rule revisions --json + ``` + `revisions` is newest first, each with `revisionId`, `createdAt`, + `delivery`, and `prUrl` for a pull-request delivery. Find the + current one by `current: true`, not by position: a current revision + older than the newest ten is listed after them. When no entry is + current, the rule exists only on a pull request that has not merged, + and there is nothing to roll back from. `truncated: true` means + older revisions exist that the listing omits; the rule's page on + the Taskless dashboard lists every one. + +2. **Pick the revision the user described**, such as "the one before + the last change" or "the one from that pull request". If more than + one fits, show the user the candidates and ask. Do not guess. + +3. **Roll back to it.** + ``` + %(TASKLESS_CLI)s rule rollback --json + ``` + Read the result as in step 3 above, then run `%(TASKLESS_CLI)s check`. + ## When the plan does not include recovery On a plan without rule recovery, restore and rollback answer with @@ -74,7 +102,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: | `AUTH_REQUIRED` | not logged in, or the token was rejected | fetch `%(TASKLESS_CLI)s agent auth` | | `RULE_RECOVERY_NOT_IN_PLAN` | the plan does not include recovery | follow the git steps in `message`; do not retry | | `RULE_NOT_FOUND` | Taskless did not issue this rule for this repository | check the id; a local rule cannot be restored | -| `REVISION_NOT_FOUND` | rollback named a revision that is not this rule's | check the revision id in the dashboard | +| `REVISION_NOT_FOUND` | rollback named a revision that is not this rule's | list them with `rule revisions ` | | `RULE_RESTORE_MISMATCH` | the service served bytes other than the expected ones | nothing was written; report it, do not retry blindly | | `RULE_ID_AMBIGUOUS` | two engines hold this id | rename the local one, then restore | | `NETWORK_ERROR` | the service could not be reached or failed | report and suggest a retry | From 62dccdbec6b3a288e6d370bbb9356504910e2c1d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 20:33:41 -0700 Subject: [PATCH 4/6] docs(openspec): archive cli-rule-revisions --- .../.openspec.yaml | 0 .../2026-09-29-cli-rule-revisions}/design.md | 0 .../proposal.md | 0 .../specs/cli-rule-recovery/spec.md | 0 .../2026-09-29-cli-rule-revisions}/tasks.md | 2 +- openspec/specs/cli-rule-recovery/spec.md | 67 +++++++++++++++++++ 6 files changed, 68 insertions(+), 1 deletion(-) rename openspec/changes/{cli-rule-revisions => archive/2026-09-29-cli-rule-revisions}/.openspec.yaml (100%) rename openspec/changes/{cli-rule-revisions => archive/2026-09-29-cli-rule-revisions}/design.md (100%) rename openspec/changes/{cli-rule-revisions => archive/2026-09-29-cli-rule-revisions}/proposal.md (100%) rename openspec/changes/{cli-rule-revisions => archive/2026-09-29-cli-rule-revisions}/specs/cli-rule-recovery/spec.md (100%) rename openspec/changes/{cli-rule-revisions => archive/2026-09-29-cli-rule-revisions}/tasks.md (97%) diff --git a/openspec/changes/cli-rule-revisions/.openspec.yaml b/openspec/changes/archive/2026-09-29-cli-rule-revisions/.openspec.yaml similarity index 100% rename from openspec/changes/cli-rule-revisions/.openspec.yaml rename to openspec/changes/archive/2026-09-29-cli-rule-revisions/.openspec.yaml diff --git a/openspec/changes/cli-rule-revisions/design.md b/openspec/changes/archive/2026-09-29-cli-rule-revisions/design.md similarity index 100% rename from openspec/changes/cli-rule-revisions/design.md rename to openspec/changes/archive/2026-09-29-cli-rule-revisions/design.md diff --git a/openspec/changes/cli-rule-revisions/proposal.md b/openspec/changes/archive/2026-09-29-cli-rule-revisions/proposal.md similarity index 100% rename from openspec/changes/cli-rule-revisions/proposal.md rename to openspec/changes/archive/2026-09-29-cli-rule-revisions/proposal.md diff --git a/openspec/changes/cli-rule-revisions/specs/cli-rule-recovery/spec.md b/openspec/changes/archive/2026-09-29-cli-rule-revisions/specs/cli-rule-recovery/spec.md similarity index 100% rename from openspec/changes/cli-rule-revisions/specs/cli-rule-recovery/spec.md rename to openspec/changes/archive/2026-09-29-cli-rule-revisions/specs/cli-rule-recovery/spec.md diff --git a/openspec/changes/cli-rule-revisions/tasks.md b/openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md similarity index 97% rename from openspec/changes/cli-rule-revisions/tasks.md rename to openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md index 3ce9a00d..c59d73db 100644 --- a/openspec/changes/cli-rule-revisions/tasks.md +++ b/openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md @@ -19,4 +19,4 @@ - [x] 4.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass - [ ] 4.2 With a `pnpm build:next` build, run `pnpm cli rule revisions ` and `--json` against production for a real issued rule, and `rule rollback` to one of the listed ids; record the result in the PR -- [ ] 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 +- [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 diff --git a/openspec/specs/cli-rule-recovery/spec.md b/openspec/specs/cli-rule-recovery/spec.md index 72002dfc..e26894c9 100644 --- a/openspec/specs/cli-rule-recovery/spec.md +++ b/openspec/specs/cli-rule-recovery/spec.md @@ -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 `, 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 ` 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 }` From a108d68ff9a1dd37dce6a1ce93243b44f57726aa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 21:34:15 -0700 Subject: [PATCH 5/6] docs(openspec): record the cli-rule-revisions production round trip --- openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md b/openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md index c59d73db..da08672e 100644 --- a/openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md +++ b/openspec/changes/archive/2026-09-29-cli-rule-revisions/tasks.md @@ -18,5 +18,5 @@ ## 4. Verify - [x] 4.1 Run `pnpm typecheck`, `pnpm lint`, and `pnpm test`; all pass -- [ ] 4.2 With a `pnpm build:next` build, run `pnpm cli rule revisions ` 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.2 With a `pnpm build:next` build, run `pnpm cli rule revisions ` 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 From c9710cd29eba695f9b02f325fef280d61c4f7902 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 22:15:24 -0700 Subject: [PATCH 6/6] fix(rule): address review on rule revisions Name the recipe step rollback defers to instead of a number that reads as itself, give REVISION_NOT_FOUND's fix the CLI prefix so it runs, and drop the unused RevisionEntry export. --- packages/cli/src/agent/recover-rule.md | 5 +++-- packages/cli/src/api/v2.ts | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/agent/recover-rule.md b/packages/cli/src/agent/recover-rule.md index 3625ed1d..337e290e 100644 --- a/packages/cli/src/agent/recover-rule.md +++ b/packages/cli/src/agent/recover-rule.md @@ -80,7 +80,8 @@ write nothing they cannot verify. ``` %(TASKLESS_CLI)s rule rollback --json ``` - Read the result as in step 3 above, then run `%(TASKLESS_CLI)s check`. + Read the result the way restore's is read ("Read the result" under + Steps), then run `%(TASKLESS_CLI)s check`. ## When the plan does not include recovery @@ -102,7 +103,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: | `AUTH_REQUIRED` | not logged in, or the token was rejected | fetch `%(TASKLESS_CLI)s agent auth` | | `RULE_RECOVERY_NOT_IN_PLAN` | the plan does not include recovery | follow the git steps in `message`; do not retry | | `RULE_NOT_FOUND` | Taskless did not issue this rule for this repository | check the id; a local rule cannot be restored | -| `REVISION_NOT_FOUND` | rollback named a revision that is not this rule's | list them with `rule revisions ` | +| `REVISION_NOT_FOUND` | rollback named a revision that is not this rule's | list them with `%(TASKLESS_CLI)s rule revisions ` | | `RULE_RESTORE_MISMATCH` | the service served bytes other than the expected ones | nothing was written; report it, do not retry blindly | | `RULE_ID_AMBIGUOUS` | two engines hold this id | rename the local one, then restore | | `NETWORK_ERROR` | the service could not be reached or failed | report and suggest a retry | diff --git a/packages/cli/src/api/v2.ts b/packages/cli/src/api/v2.ts index 1d807779..7426ec74 100644 --- a/packages/cli/src/api/v2.ts +++ b/packages/cli/src/api/v2.ts @@ -307,9 +307,6 @@ export function rollbackRule( /** A rule's recent revisions: the `200` body of the revisions listing. */ export type RevisionList = OkBody<"/cli/api/v2/rule/{ruleId}/revisions", "get">; -/** One entry of a {@link RevisionList}. */ -export type RevisionEntry = RevisionList["revisions"][number]; - export type RevisionsCode = ErrorCode< "/cli/api/v2/rule/{ruleId}/revisions", "get"