From 20b07f5ee74fd2bb13593d8c6ada4751bfaf99cf Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 28 Sep 2026 20:31:00 -0700 Subject: [PATCH 1/4] docs(openspec): propose cli-v2-rule-api, moving the CLI to the v2 rule API for 0.12.0 --- .changeset/cli-v2-rule-api.md | 15 + .../changes/cli-v2-rule-api/.openspec.yaml | 2 + openspec/changes/cli-v2-rule-api/design.md | 297 ++++++++++++++++ openspec/changes/cli-v2-rule-api/proposal.md | 131 +++++++ .../cli-v2-rule-api/specs/cli-check/spec.md | 278 +++++++++++++++ .../specs/cli-generated-rule-delivery/spec.md | 109 ++++++ .../specs/cli-rule-reconciliation/spec.md | 326 ++++++++++++++++++ .../specs/cli-rule-recovery/spec.md | 104 ++++++ .../cli-v2-rule-api/specs/cli-rules/spec.md | 151 ++++++++ .../specs/cli-runtime-rule-execution/spec.md | 19 + openspec/changes/cli-v2-rule-api/tasks.md | 185 ++++++++++ 11 files changed, 1617 insertions(+) create mode 100644 .changeset/cli-v2-rule-api.md create mode 100644 openspec/changes/cli-v2-rule-api/.openspec.yaml create mode 100644 openspec/changes/cli-v2-rule-api/design.md create mode 100644 openspec/changes/cli-v2-rule-api/proposal.md create mode 100644 openspec/changes/cli-v2-rule-api/specs/cli-check/spec.md create mode 100644 openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md create mode 100644 openspec/changes/cli-v2-rule-api/specs/cli-rule-reconciliation/spec.md create mode 100644 openspec/changes/cli-v2-rule-api/specs/cli-rule-recovery/spec.md create mode 100644 openspec/changes/cli-v2-rule-api/specs/cli-rules/spec.md create mode 100644 openspec/changes/cli-v2-rule-api/specs/cli-runtime-rule-execution/spec.md create mode 100644 openspec/changes/cli-v2-rule-api/tasks.md diff --git a/.changeset/cli-v2-rule-api.md b/.changeset/cli-v2-rule-api.md new file mode 100644 index 00000000..9bc65076 --- /dev/null +++ b/.changeset/cli-v2-rule-api.md @@ -0,0 +1,15 @@ +--- +"@taskless/cli": minor +--- + +The CLI now speaks the Taskless v2 rule API, which addresses rules by their own id and protects every rule, not only runtime checks. **Upgrade before the service raises its minimum version:** once 0.12.0 is released, 0.11.x can no longer generate rules or verify runtime rules against the service. + +What you may need to react to: + +- **`taskless check` fails when an issued ast-grep or Vale rule has been edited.** Logged in, every file of every rule is checked against what Taskless issued, and an edited rule of any engine does not run. For ast-grep and Vale rules the run also fails, naming each changed, removed, or added file. To fix it, run `taskless rule restore ` rather than editing the rule back by hand. Logged out, with `--anonymous`, or with `--dangerously-run-scripts`, nothing is checked, as before. Rules you wrote yourself are unaffected. +- **`taskless check` never rewrites your rules.** It used to try to repair a changed runtime rule in the middle of a run. It now reports the change and names the command to run. +- **`taskless rule create --json` prints `requestId` instead of `ruleId`.** The old field always held the request id, never a rule id. The ids of the rules that were written are in `rules`, and those are what `taskless rule improve` takes. + +New: `taskless rule restore ` repairs a rule to its current revision, and `taskless rule rollback ` makes an earlier revision current. Both verify every file before writing it. On a plan that does not include rule recovery, they print how to recover the rule from your git history instead. + +Rules generated by 0.11.x or earlier were issued through the v1 API and are treated as locally written: ast-grep and Vale rules keep running, and runtime rules need to be regenerated. diff --git a/openspec/changes/cli-v2-rule-api/.openspec.yaml b/openspec/changes/cli-v2-rule-api/.openspec.yaml new file mode 100644 index 00000000..ee7c5448 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-28 diff --git a/openspec/changes/cli-v2-rule-api/design.md b/openspec/changes/cli-v2-rule-api/design.md new file mode 100644 index 00000000..67c6b4f9 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/design.md @@ -0,0 +1,297 @@ +## Context + +The server contract is taskless/taskless#229 (`rules-by-id`) and the live +`GET /cli/api/v2/__schema`. The schema is authoritative over any prose hand-off, +including the one this change was planned from. + +Where the CLI stands at `86799ef`: + +- **Reconcile covers runtime `check.ts` only.** `planRuntime` + (`rules/runtime/plan.ts`) signs each `check.ts` (`signRuntimeChecks`), posts + `{ files: [{ file, signature }] }` to v1 reconcile, joins `run` back by + signature, then copies blessed rules into `.taskless/.run/runtime-rules/` + (`materializeRuntimeRules`). sg and vale are assembled from the live tree by + `rules/assemble.ts` and never reconciled. +- **`check` repairs in place.** `repairWithheldRules` calls restore for every + `unsafe` and `missing` entry and writes the result into `.taskless/rules/` + during the run. Every one of those restores 404s (TSKL-307). +- **Generation is ticket-addressed.** `rule create` reports the generation + request id as `ruleId` in `--json`, and `rule improve` takes that value back as + its `ruleId`. v2 addresses a rule by its directory name, `-<8 hex>`, + stable across iterations. +- **Assembly is root-relative.** The assembled Vale config sets + `StylesPath = rules/vale` relative to its own location, and the sg config sets + `ruleDirs: rules/sg` the same way. Vale is spawned with an explicit `--config`. + Pointing both at a different root is a path change, not a redesign. + +## Goals / Non-Goals + +**Goals:** + +- One snapshot per `check`, from which every engine runs and every signature is + computed. +- One verdict table, applied per engine, with no path that fails silently open. +- Recovery (restore, rollback) as explicit commands that never write unverified + bytes. +- A v2-only client, typed from the vendored v2 schema. + +**Non-Goals:** + +- Listing a rule's revisions. v2 has no endpoint for it; rollback takes a + `revisionId` from the dashboard until one exists. +- Closing the directory-swap gap (an issued rule deleted and replaced by an + `unknown` copy) or warning on superseded revisions. Both need server-side + policy and are filed as follow-ups. +- Reading plan features up front. `whoami` does not return them; a refusal is how + the CLI learns. +- Changing `verify`, `test`, or anonymous authoring. They are author tools that + run what is on disk by the user's own request. + +## Decisions + +### 1. v2 replaces v1 in the vendored schema; nothing keeps v1 + +`fetch-api-schema.ts` reads `/cli/api/v2/__schema`, and `api.schema.json` / +`api.d.ts` become the v2 document. After this change nothing the CLI calls is +in v1: whoami and the hash vectors have v2 twins (measured byte-identical on +2026-09-29), and `/cli/auth/*` is outside both schemas and already hand-typed. + +_Alternative:_ vendor both schemas side by side through the migration. Rejected +because the stack merges down, so no intermediate state ships, and a second +schema is a second place for a v1 call to hide. + +All v2 calls go through one `openapi-fetch` client that sets +`x-taskless-cli-version` on every request, replacing the hand-rolled `fetch` in +`reconcile.ts` and `restore.ts`. Their "never throw on expected conditions" +contract is kept by mapping the typed error union to outcome values in one +place, not by staying on raw `fetch`. + +### 2. One snapshot per `check`, taken before anything is signed + +`check` copies `.taskless/rules/` to `.taskless/.run/rules/` first (replacing +any previous snapshot). Everything after reads only the snapshot: signing, +reporting, config assembly, and all three engines. The assembled configs for a +`check` are written beside it as `.taskless/.run/.vale.ini` and +`.taskless/.run/.sgconfig.yml`, so their root-relative `StylesPath` and +`ruleDirs` resolve into the snapshot unchanged. Runtime rules execute from +`.taskless/.run/rules/runtime/`, replacing `.run/runtime-rules/`. + +The snapshot is taken on every path, including unauthenticated and +`--anonymous`, so there is one execution path rather than a verified one and an +unverified one that drift apart. + +The copy **dereferences symlinks**. What is signed has to be what runs, and a +symlink resolved at run time is bytes nobody signed. A link that does not +resolve drops the file from the snapshot, which then shows up as a missing file +in the verdict rather than as a surprise at run time. + +A rule excluded from the run by its verdict (`unsafe`, `withheld`, runtime +`unknown`) is removed from the snapshot before assembly, so exclusion is a fact +about the tree the engines read and not a filter each engine must remember. + +_Alternative:_ copy only the rules that `run`, as today. Rejected: it keeps the +sign-then-copy window this change exists to close, and it cannot cover sg and +vale, which run whatever the verdict for `unknown`. + +`verify` and `test` keep assembling from the live tree at the existing paths. + +### 3. What is reported for a rule + +One `{ ruleId, files }` per directory under `.taskless/rules//`, where +`ruleId` is the directory name and `files` lists every regular file under it, +recursively, except anything under `.tests/`. Paths are relative to the rule +directory, POSIX. Each signature is `canonicalHash` over the snapshot's bytes, +unchanged from v1. + +A small fixed set of operating-system metadata files (`.DS_Store`, +`Thumbs.db`, `desktop.ini`) is neither copied into the snapshot nor reported. +The server treats any extra file as `unsafe`, and a Finder window must not fail +CI. Excluding them is safe only because they are also absent from what runs; the +list is closed, and nothing an engine reads can be on it. + +`--rule` narrows what runs, never what is reported. Reporting a subset would +make every unselected issued rule `missing`. + +### 4. Rule ids are unique across engines, or `check` stops + +v2 ids are unique across engines and reconcile carries no engine. Two local +directories with one id under different engines therefore cannot be judged, and +the CLI refuses the run before reconcile, naming both directories. + +It fails rather than skipping the pair. Skipping would let anyone neutralize an +issued `sg/foo-3fa9c21b` by creating `vale/foo-3fa9c21b`, because the issued rule +would then leave the report and run as if it were local. + +### 5. The verdict table, and what "excluded" means per engine + +| Verdict | runtime | sg / vale | Exit | +| ----------------- | ------------- | ------------- | ---------------------------------- | +| `run` | execute | run | — | +| `withheld` | not executed | (never sent) | fail | +| `unsafe` | not executed | **not run** | fail for sg/vale; runtime as today | +| `missing` | nothing local | nothing local | warn | +| `unknown` | not executed | run | — | +| not accounted for | not executed | **not run** | fail | + +An `unsafe` static rule is **not run** as well as failing. Its findings would be +the edited rule's findings, and the run is already failing; showing them invites +reading the failure as ordinary lint. + +**An `unsafe` runtime rule does not fail the run.** It is withheld from +execution and reported, as today. Restore is what an `unsafe` runtime rule is +offered, and failing CI on it too would change runtime policy that this change +was not asked to change. The asymmetry is the server's table, deliberately: for +a static rule the signature only detects tampering, so failing is the one way +tampering has an effect; for a runtime rule the signature also authorizes +execution, so not running it already denies the edit its effect. + +`unknown` static rules run with no notice, because every locally authored rule +is `unknown` and a notice per rule per run is noise that trains people to ignore +notices. `unknown` runtime rules keep today's skip reason. + +### 5a. `--dangerously-run-scripts` means no checksums, for any engine + +The flag skips reconcile entirely, logged in or not: nothing is signed, nothing is enforced, +every static rule runs and every runtime rule executes. It does only what it says, which is to +run things dangerously. + +_Alternative:_ keep reconciling when logged in and let the flag override only the runtime +gate, so adding it to a CI command would not also disable sg and vale tamper detection. +Rejected (product decision, 2026-09-29): a flag with that name should not have a second, +partial meaning. The snapshot is still taken, so the run is still one code path. + +### 6. Accounting is computed, not trusted + +After parsing, every reported `ruleId` must appear in exactly one of `rules[]`, +`unknown[]`, or `entitlement.withheld[]`. A reported id in none is "not +accounted for" and fails the run naming the rule. An id in more than one is +treated the same way: the CLI does not guess which answer the server meant. +`missing` entries are not reported rules and are outside this check. + +This is the check that would have caught #403's reuse hazard: a parser that +drops withheld entries produces unaccounted rules, and unaccounted rules fail. + +### 7. `check` never writes to `.taskless/rules/` + +The in-`check` repair is deleted. `unsafe` and `missing` produce a notice naming +`taskless rule restore `. The snapshot under `.taskless/.run/` is the only +thing `check` writes. + +A lint that rewrites the tree it is linting cannot be reasoned about in CI or in +a hook, and would turn a Free plan's refusal into a message on every run. + +### 8. Restore verifies against reconcile, not only against itself + +`taskless rule restore ` builds its expectation from a fresh reconcile +rather than from the restore response alone: + +1. Snapshot and sign the local rule (if its directory exists), and reconcile + the whole tree exactly as `check` would (Decision 3). Only this rule's + verdict is used. +2. `run` or `withheld`: nothing to restore; say so and exit 0. + `unknown`: not a rule of this repository; nothing to restore. +3. `unsafe`: the expected signature map is the local signatures, overridden by + each file's `expected`, with every `got`-only path removed. + `missing`: the expected revision is the verdict's `revisionId`. +4. Call restore. A refusal (`restoreRules: false`) prints `message` and exits + non-zero with `RULE_RECOVERY_NOT_IN_PLAN`. +5. The served set must satisfy both: every file's `canonicalHash` equals its + entry in the served `signatures`, and the signature map equals the expected + map (for `unsafe`) or the served `revisionId` equals the expected one (for + `missing`). Anything else is refused, and nothing is written. +6. Replace the rule directory with the served set (Decision 10). + +Step 5's second half is what stops "newest issue wins" from quietly upgrading a +rule: restore repairs, it never advances. + +`rule rollback ` has no reconcile expectation to check +against. It requires that the served `revisionId` equal the requested one and +that every file match its served signature. + +### 9. A refusal is an answer, printed with care + +Restore, rollback, and a non-head fetch may answer `200` with +`{ restoreRules: false, reason, message, upgradeUrl }`. The CLI discriminates on +`restoreRules === false` (and, for fetch, on the absence of `rules`), prints +`message`, then `upgradeUrl` when it parses as an absolute `https:` URL. +`message` is server-authored text printed to a terminal, so C0/C1 control +characters other than newline are stripped first. An unrecognized `reason` is +still printed as a refusal, never reported as a service failure. + +### 10. A delivered rule replaces its directory, after its signatures check + +Every file set the CLI writes (create, improve, restore, rollback) is first +checked: each file's `canonicalHash` must equal its entry in `signatures`, every +signature must name a delivered file, and every delivered file outside `.tests/` +must have a signature. Then the rule directory is replaced, reusing +`writeDeliveredFileSet`'s purge-then-write path (`PurgeIncompleteError` +reporting included), so a stale local file cannot survive and make the rule +`unsafe` on the next run. + +**Fixtures (pending the rules team's confirmation that served sets include `.tests/`):** +the purge covers `.tests/` only when the served set carries at least one `.tests/` file. If a +served set carries none, local fixtures are left in place rather than deleted. When fixtures are +confirmed to always ship, this guard is harmless; if they turn out not to ship, it is what keeps +every restore from deleting them. + +For create and improve the CLI also checks that the fetched `revisionId` equals +the one the request produced. The head is fetched without `revision=`, so a +Free organization is never refused for a just-generated rule. + +### 11. The published `--json` shapes + +- `rule create --json`: `{ success, requestId, rules: string[], files, notices? }`. + `ruleId` is removed. It always held the request id; a field named `ruleId` + that holds something other than a rule id is exactly the defect TSKL-307 + traced, and keeping it would re-teach agents to pass it to `rule improve`. +- `rule improve --json`: unchanged shape. Its input `ruleId` is now the rule's + directory name. +- `check --json` gains an optional `integrity` array of + `{ ruleId, engine?, verdict, files?, revisionId? }` for every non-`run` + outcome except static `unknown`, where `verdict` is one of `unsafe`, + `missing`, `unknown`, `unaccounted`, or `duplicate`. `entitlement.withheld` + keeps naming local rules, now resolved by rule id. +- `rule restore --json` / `rule rollback --json`: + `{ success, ruleId, revisionId, files, notices? }` on success, the standard + error envelope otherwise. + +## Risks / Trade-offs + +- **[Risk] Vale section globs might resolve relative to the config file.** The + assembled config moves from `.taskless/` to `.taskless/.run/`. → A test runs + one Vale rule scoped to a subdirectory glob from both locations and asserts + identical findings before the snapshot is wired into `check`. +- **[Risk] 0.11.x stops working when the floor is set.** Remote generation fails + and reconcile returns `400` once 0.12.0 ships. → Accepted server-side (#229); + 0.11.x degrades to "service unavailable" and skips runtime rules without + failing. The changeset says how to upgrade. +- **[Risk] A nightly from an intermediate slice.** → The stack merges down; no + slice reaches `main` alone (see the proposal's delivery shape). +- **[Trade-off] Tamper detection requires authentication.** Logged-out, + `--anonymous`, and unreachable runs cannot tell an edited rule from an intact + one, and still pass. That is the existing degrade contract and stays. +- **[Trade-off] Rules generated before v2 reconcile as `unknown`** and cannot be + restored (no backfill). Static ones keep running; runtime ones need + regenerating. Nobody uses remote generation yet. +- **[Risk] The snapshot costs a copy per `check`.** → Rule trees are small + (kilobytes). Measured in the reconcile slice; revisit only if a real corpus + says otherwise. + +## Migration Plan + +1. Land the stack on its bottom branch, merging down (see proposal). +2. Validate end to end from a nightly against production v2: generate → fetch → + reconcile `run` → edit → `unsafe` → restore → `run`. Report the round trip to + the cloud team, which is how they close TSKL-307. +3. Merge to `main`; release 0.12.0. +4. The cloud team sets `V2_CLI_FLOOR = 0.12.0` from the published release. + +Rollback: before step 4, reverting the merge restores 0.11.x behavior, and v1 is +still served. After step 4 a revert would publish a v1 client the server refuses, +so the path forward is a fix, not a revert. + +## Open Questions + +- Whether the cloud team wants an `engine` echo on `unknown` entries. The CLI + already knows each reported rule's engine from its path, so this does not + change the design. diff --git a/openspec/changes/cli-v2-rule-api/proposal.md b/openspec/changes/cli-v2-rule-api/proposal.md new file mode 100644 index 00000000..ad7a6c94 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/proposal.md @@ -0,0 +1,131 @@ +## Why + +Three things are broken or missing in 0.11.2, and the server has shipped the +contract that fixes all three at once: + +- **Restore has never worked.** The CLI posts a rule id to + `POST /cli/api/request//restore`, the server looks it up as a ticket + id, and every repair answers `404` (TSKL-307). The v1 route will not be + repaired. +- **Only a runtime rule's `check.ts` is protected.** sg and vale rules run with + no reconcile at all, and a runtime rule's `captures/*.yml` are unsigned. This + came from an agent editing a rule's `.vale.ini` so its own violation would + pass; nothing today could notice. +- **What runs is not what was judged.** `materializeRuntimeRules` copies a rule + into `.taskless/.run/` after reconcile blessed it, so an edit made in between + runs unverified. + +The v2 rule API (taskless/taskless#229, live at `/cli/api/v2/`) addresses rules +by their own stable id, signs every non-fixture file of every engine, judges a +rule as a whole, and gates restore and rollback on the organization's plan. +**The first CLI release that speaks v2 becomes the server's minimum supported +version**: after it ships, v1 refuses older clients (`400 upgrade_required`) and +answers `410 moved` to newer ones. That release is 0.12.0. + +## What Changes + +- **BREAKING (server-enforced): the CLI speaks only v2.** Every call moves under + `/cli/api/v2/`; `/cli/auth/*` is unchanged. Every request carries + `x-taskless-cli-version`. The vendored schema becomes v2's `__schema`, and v1 + client code is deleted. +- **`check` reports every rule of every engine,** one `{ ruleId, files }` per + `.taskless/rules///`, covering every file except `.tests/**`. +- **Copy, then sign, then run the copy.** `check` snapshots the rules tree into + `.taskless/.run/` first, signs and reports the snapshot, and every engine runs + from it. A verdict describes the bytes that actually ran. +- **BREAKING: an edited sg or vale rule fails `check`.** The per-engine verdict + policy applies: `unsafe` static rules fail the run (naming each differing + path) and do not run; `unknown` static rules still run; `missing` warns; + `withheld` runtime rules fail as they do today. +- **`check` accounts for every rule it reported.** A rule in none of `rules`, + `unknown`, or `entitlement.withheld` fails the run. Two rule directories with + the same id under different engines fail the run before anything is reported. +- **`check` never restores.** It reports and names the command to run. The + current in-`check` repair is removed. +- **New `taskless rule restore ` and `taskless rule rollback +`.** Every served file is checked against its signature and, for + restore, against what reconcile said the rule should be; a mismatch is never + written. On a plan without `restoreRules` the server's git-recovery message is + printed as the answer. +- **`rule create` and `rule improve` use v2 generation.** Poll the request, fetch + each produced rule's head by `ruleId`, confirm the `revisionId`, verify the + signatures, and replace the rule directory. +- **BREAKING: `rule create --json` stops calling the request id `ruleId`.** It + prints `requestId` plus the produced rule ids. `rule improve`'s input + `ruleId` is now the rule's directory name, which is what v2 addresses. + +Nothing changes for `verify`, `test`, `rule delete`, or local-only (anonymous) +authoring. Unauthenticated, `--anonymous`, and unreachable-service runs still +never fail on verification: static rules run unverified and runtime rules are +skipped, as today. + +## Capabilities + +### New Capabilities + +- `cli-rule-recovery`: `rule restore` and `rule rollback`, their verification + of served bytes, and how a plan refusal is presented. + +### Modified Capabilities + +- `cli-rule-reconciliation`: per-rule reporting across every engine, the v2 + verdicts, accounting for every reported rule, rule-id uniqueness, withheld by + rule id, and the v2 hash-vector endpoint. The per-file contract and in-`check` + re-fetch are removed. +- `cli-check`: static rules join reconciliation; the exit code gains edited + static rules, unaccounted rules, and duplicate ids; `check` never writes the + rules tree; `--json` reports rule integrity. +- `cli-runtime-rule-execution`: execution uses the snapshot taken before + signing, not a copy made after blessing. +- `cli-generated-rule-delivery`: every delivery is a v2 file set whose + signatures are verified before writing, and a delivered rule replaces its + directory. +- `cli-rules`: create and improve use the v2 generation flow and publish + `requestId`; the stale v1 server-endpoint requirements are removed. + +## Impact + +- `packages/cli/src/api/*`: new v2 client for request, rule fetch, iterate, + reconcile, restore, rollback, whoami; `entitlement.ts` parses v2 shapes; + `restore.ts` and the v1 `reconcile.ts` / `rules.ts` are replaced. +- `packages/cli/src/rules/runtime/{plan,run-set,repair}.ts`: generalize from + runtime `check.ts` to every engine's rule directory; delete in-`check` repair. +- `packages/cli/src/rules/{assemble,dispatch,engines}.ts`: assemble and run sg + and vale from the snapshot root. +- `packages/cli/src/commands/{check,rules}.ts`, `schemas/*`: verdict policy, + new `--json` fields, new `restore` / `rollback` subcommands, create/improve + output. +- `packages/cli/scripts/fetch-api-schema.ts`, `fetch-rule-hash-vectors.ts`, + `src/generated/api.schema.json`, `api.d.ts`: vendor v2. +- Agent recipes: `check`, `ci`, `create-remote-rule`, `improve-rule`, + `rule-meta`, `create-runtime-rule`, plus a recipe for recovering a rule. +- Cross-repo: the server sets `V2_CLI_FLOOR` from this release. After it ships, + 0.11.x remote generation and reconcile stop working (accepted in #229). + +## Delivery shape + +**Stacked, merging down.** The units are only correct together. A prerelease +resolves to the release it precedes, so any nightly stamped `0.12.0-*` is a v2 +client in the server's eyes. If a partial slice reached `main`, the nightly +would publish a CLI that the gate treats as v2 while it still calls v1 routes, +which answer `410` once the floor is set. The stack therefore merges down to the +bottom branch and reaches `main` in one protected merge. + +Slices, each targeting the one below it: + +1. **Contract** (bottom, targets `main`): this change, the v2 schema vendoring, + the v2 API client, and the `minor` changeset. +2. **Generation**: `rule create` / `rule improve` on v2, verified delivery. +3. **Reconcile**: snapshot, per-rule reporting, verdict policy, accounting, + `check` output. The largest slice. +4. **Recovery**: `rule restore`, `rule rollback`, the refusal. +5. **Retire v1**: delete v1 code and tests, recipes, the end-to-end round trip + against production, and the archive. + +The changeset is `minor` and lives on slice 1. Two reasons, either sufficient: +`check` now fails on an edited sg or vale rule, and `rule create --json` renames +a field consumers read, both of which consumers must react to; and a `patch` +changeset would stamp nightlies +`0.11.3-*`, below the v2 floor. + +Refs TSKL-307 diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-check/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-check/spec.md new file mode 100644 index 00000000..e3cd1d5e --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/specs/cli-check/spec.md @@ -0,0 +1,278 @@ +## MODIFIED Requirements + +### Requirement: Check subcommand exit codes reflect error severity + +The CLI SHALL exit with code 0 when no error-severity matches are found (including when only warnings, info, or hints exist) and no reconcile outcome below requires failure. The CLI SHALL exit with code 1 when at least one error-severity match is found. The CLI SHALL also exit with code 1, whatever the findings and in both human and `--json` modes, when a completed reconcile: + +- returned a non-empty `entitlement.withheld`; +- returned an `unsafe` verdict for an `sg` or `vale` rule; or +- left a reported rule unaccounted for (in none, or more than one, of `rules`, `unknown`, and `entitlement.withheld`). + +The CLI SHALL also exit with code 1 when two rule directories under different engines share an id. Under `--json`, `success` SHALL be `false` whenever the exit code is non-zero. + +#### Scenario: Exit 0 when clean + +- **WHEN** the scanner produces zero results +- **THEN** the process SHALL exit with code 0 + +#### Scenario: Exit 0 when only warnings + +- **WHEN** the scanner produces results but none have severity "error" +- **THEN** the process SHALL exit with code 0 + +#### Scenario: Exit 1 when errors found + +- **WHEN** the scanner produces at least one result with severity "error" +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Exit 1 when a runtime rule is withheld for entitlement + +- **WHEN** reconciliation returns a non-empty `entitlement.withheld` and the scan produces zero results +- **THEN** the process SHALL exit with code 1 +- **AND** under `--json`, `success` SHALL be `false` + +#### Scenario: Entitlement without a withheld file does not fail + +- **WHEN** reconciliation returns `entitlement.runtimeSignatures: false` with an empty or absent `withheld`, and the scan produces zero results +- **THEN** the process SHALL exit with code 0 + +#### Scenario: Exit 1 when a static rule was edited + +- **WHEN** reconciliation returns an `unsafe` verdict for an `sg` or `vale` rule and the scan produces zero results +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Exit 1 when a reported rule is unaccounted for + +- **WHEN** a reported rule appears in none of `rules`, `unknown`, and `entitlement.withheld` +- **THEN** the process SHALL exit with code 1 + +#### Scenario: Missing does not fail + +- **WHEN** reconciliation returns only `run` and `missing` verdicts and the scan produces zero results +- **THEN** the process SHALL exit with code 0 + +### Requirement: Check selects what it runs from auth state + +`taskless check` SHALL NOT require authentication, and it SHALL choose what it verifies from +the current auth state. When a token is available and `--anonymous` is not set, the CLI SHALL +reconcile **every** rule (ast-grep, Vale, and runtime) and apply the verdict policy of the +`cli-rule-reconciliation` capability. When no token is available, when `--anonymous` is set, or +when reconciliation cannot complete, the CLI SHALL run every static rule (ast-grep and Vale) +unverified and SHALL skip runtime execution unless `--dangerously-run-scripts` is set. The +unauthenticated path SHALL succeed with no network access and SHALL NOT emit a warning about +missing authentication. + +#### Scenario: Unauthenticated check runs static rules and skips runtime rules + +- **WHEN** a user runs `taskless check` with no available token +- **THEN** the CLI SHALL scan all static rules +- **AND** SHALL NOT call `POST /cli/api/v2/reconcile` +- **AND** SHALL skip runtime rules +- **AND** SHALL NOT emit a warning about missing authentication + +#### Scenario: Authenticated check reconciles runtime rules + +- **WHEN** a user runs `taskless check` with an available token and without `--anonymous` +- **THEN** the CLI SHALL reconcile its ast-grep, Vale, and runtime rules in one request +- **AND** SHALL run or execute each rule according to its verdict + +#### Scenario: Anonymous forces the logged-out path + +- **WHEN** a user runs `taskless check --anonymous` while a token is available +- **THEN** the CLI SHALL behave exactly as an unauthenticated `check` (static rules run, runtime rules skipped, no reconcile call) + +#### Scenario: Logged out, an edited static rule still runs + +- **WHEN** a user runs `taskless check` with no available token and an issued sg or vale rule has been edited +- **THEN** the edited rule SHALL run with no signature enforcement +- **AND** runtime rules SHALL be skipped +- **AND** the exit code SHALL NOT change because the rule was edited + +#### Scenario: Logged in, an edited rule of any engine does not run + +- **WHEN** an authenticated `check` reconciles and a rule of any engine is `unsafe` +- **THEN** that rule SHALL NOT run or execute + +### Requirement: Check reconciles rule files before scanning + +`taskless check` SHALL reconcile before running any rule whenever a bearer token and a +`repositoryUrl` are resolvable and `--anonymous` is not set. It SHALL snapshot the rules tree, +report every rule directory from the snapshot to `POST /cli/api/v2/reconcile` (per the +`cli-rule-reconciliation` capability), remove from the snapshot every rule its verdict +excludes, and only then assemble engine configs and run. + +#### Scenario: Only rules with a blessed check.ts execute + +- **WHEN** a user runs `taskless check` while authenticated and reconciliation returns `run` for some rules, `unsafe` for a static rule, and `unknown` for a runtime rule +- **THEN** the CLI SHALL run the `run` rules +- **AND** SHALL NOT run the `unsafe` static rule +- **AND** SHALL NOT execute the `unknown` runtime rule + +### Requirement: Check degrades to a local scan when reconciliation cannot complete + +`taskless check` SHALL NOT fail solely because an attempted reconciliation cannot complete. +When a token is available and `--anonymous` is not set but reconciliation cannot complete (no +resolvable git remote, the endpoint unreachable, a `401`, a `404 organization_not_found`, or a +transport error), the CLI SHALL warn that rule verification could not be performed, SHALL run +every **static** rule from the snapshot unverified, and SHALL **skip runtime rules** unless +`--dangerously-run-scripts` is set. The CLI SHALL NOT exit with a non-zero code solely because +reconciliation failed, and the warning SHALL be suppressed under `--json`. + +#### Scenario: Endpoint unreachable degrades static and skips runtime + +- **WHEN** an authenticated `check` attempts reconciliation and the endpoint is unreachable or returns an error +- **THEN** the CLI SHALL warn that verification could not be performed +- **AND** SHALL scan all static rules +- **AND** SHALL NOT execute any runtime rule's `check.ts` +- **AND** SHALL NOT exit with a non-zero code solely due to the reconcile failure + +#### Scenario: Degrade warning is suppressed under --json + +- **WHEN** the CLI degrades and `--json` is set +- **THEN** stdout SHALL contain the machine JSON shape (`{ success, results }` plus the additive optional `skipped` array for the skipped runtime rules) +- **AND** SHALL NOT contain the human-readable degrade warning + +### Requirement: Check runs runtime rules only on a signature-validated path + +`taskless check` SHALL execute a runtime rule's `check.ts` only when reconciliation returned a +`run` verdict for that rule, or when `--dangerously-run-scripts` is set. An API key SHALL be +treated identically to an interactive token. On any path where the rule was not validated — +logged out, `--anonymous`, a reconciliation that cannot complete, or a verdict other than +`run` — the CLI SHALL NOT execute the rule's `check.ts`. + +#### Scenario: Authenticated check runs blessed runtime rules + +- **WHEN** an authenticated `check` reconciles and a runtime rule's verdict is `run` +- **THEN** the CLI SHALL execute that runtime rule through the harness + +#### Scenario: A rule whose check.ts is not blessed is withheld + +- **WHEN** reconciliation returns `unsafe` for a runtime rule, lists it in `unknown`, or withholds it for entitlement +- **THEN** the CLI SHALL NOT execute that runtime rule +- **AND** SHALL report it as skipped with a reason naming the verdict + +#### Scenario: API key behaves like a token + +- **WHEN** `check` runs with an API key +- **THEN** the CLI SHALL reconcile and run validated runtime rules exactly as with an interactive token + +### Requirement: Check reports runtime rules withheld for entitlement as a plan outcome + +When reconciliation completes and returns a non-empty `entitlement.withheld`, `taskless check` SHALL NOT execute any withheld rule, SHALL report each local runtime rule whose `ruleId` appears in `withheld` as skipped with a reason stating that runtime rules are not included in the organization's plan, and SHALL NOT describe it as unsafe, unknown, drifted, or tampered. The human output SHALL include one notice naming the withheld rules, the `entitlement.reason`, and the `entitlement.upgradeUrl` when present. Under `--json`, the output SHALL carry an additive, optional `entitlement` object with `runtimeSignatures`, `reason`, `upgradeUrl`, and `withheld` (the local rule names), present only when reconciliation returned `runtimeSignatures: false`. This is a verified outcome and SHALL NOT be treated as one of the unverified paths that leave the exit code unchanged. + +#### Scenario: Withheld rule is named with its cause + +- **WHEN** an authenticated `check` reconciles and `entitlement.withheld` lists runtime rule `no-env-leak-3fa9c21b` +- **THEN** `no-env-leak-3fa9c21b` SHALL NOT execute +- **AND** its skip reason SHALL state that runtime rules are not included in the plan +- **AND** its skip reason SHALL NOT mention unsafe, unknown, or drift + +#### Scenario: Upgrade URL is shown once + +- **WHEN** two runtime rules are withheld and `entitlement.upgradeUrl` is present +- **THEN** the human output SHALL print the upgrade URL exactly once, in one notice naming both rules and the reason + +#### Scenario: Entitlement appears under --json + +- **WHEN** a runtime rule is withheld and `--json` is set +- **THEN** stdout SHALL include `entitlement` with `runtimeSignatures: false`, `reason`, `upgradeUrl`, and `withheld` naming the rule +- **AND** `skipped` SHALL still list the rule with its plan reason + +#### Scenario: A server without the entitlement object is unchanged + +- **WHEN** reconciliation returns `entitlement: { runtimeSignatures: true }`, or (against its schema) no `entitlement` object at all +- **THEN** `check --json` SHALL omit the `entitlement` field +- **AND** no rule SHALL be skipped for its plan + +#### Scenario: Degrade paths still never fail + +- **WHEN** `check` runs logged out, with `--anonymous`, or reconciliation cannot complete +- **THEN** the exit code SHALL NOT change because runtime rules were skipped, as before this change + +### Requirement: Check accepts --anonymous as a no-op + +The `taskless check` command SHALL accept the global `--anonymous` flag (per the `cli` +capability). Because `check` reconciles against the Taskless API when authenticated, +`--anonymous` SHALL force the logged-out path: it SHALL suppress the reconcile network call +and run all local static rules. Aside from forcing the logged-out path, `--anonymous` SHALL NOT +change scan behavior, output shape, or exit codes relative to an unauthenticated `check`. + +#### Scenario: check --anonymous skips reconciliation + +- **WHEN** a user runs `taskless check --anonymous` +- **THEN** the CLI SHALL NOT call `POST /cli/api/v2/reconcile` +- **AND** SHALL scan all local static rules + +#### Scenario: check --anonymous matches an unauthenticated check + +- **WHEN** a user runs `taskless check --anonymous` +- **THEN** its scan behavior, output, and exit code SHALL match `taskless check` run with no + available token + +### Requirement: Check accepts --dangerously-run-scripts to run runtime rules without server validation + +`taskless check` SHALL accept a `--dangerously-run-scripts` flag that runs **all** rules without +server validation, regardless of auth state. +When the flag is set the CLI SHALL NOT reconcile — it SHALL skip the network entirely (matching +how `--anonymous` forces the no-network path), SHALL compute and enforce no signatures for any +engine, run every present static rule, and execute every present runtime rule. The CLI SHALL +emit a prominent warning that runtime rule code is being executed unverified. The flag SHALL be +the only way to execute runtime rules on an unverified path. + +#### Scenario: Dangerously-run-scripts executes runtime rules offline + +- **WHEN** a user runs `taskless check --dangerously-run-scripts` with no available token +- **THEN** the CLI SHALL execute the present runtime rules' `check.ts` +- **AND** SHALL emit a warning that runtime rule code ran unverified + +#### Scenario: Warning is suppressed under --json + +- **WHEN** `--dangerously-run-scripts` and `--json` are both set +- **THEN** stdout SHALL contain only the existing `{ success, results }` JSON shape +- **AND** the unverified-execution warning SHALL NOT appear in stdout + +#### Scenario: No signature is enforced while logged in + +- **WHEN** an authenticated user runs `taskless check --dangerously-run-scripts` and an issued vale rule has been edited +- **THEN** the CLI SHALL NOT call reconcile +- **AND** the edited rule SHALL run +- **AND** the exit code SHALL NOT change because the rule was edited + +## ADDED Requirements + +### Requirement: Check never writes to the rules tree + +`taskless check` SHALL NOT create, modify, or delete anything under `.taskless/rules/`. It +SHALL NOT call restore, rollback, or rule fetch. For an `unsafe` or `missing` verdict it SHALL +name the command that repairs the rule, `taskless rule restore `. 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 +- **THEN** `.taskless/rules/` SHALL be byte-identical before and after the run +- **AND** the output SHALL name `taskless rule restore ` + +#### Scenario: A missing rule is not fetched + +- **WHEN** reconciliation returns `missing` for a rule +- **THEN** `check` SHALL NOT call any restore or fetch endpoint +- **AND** SHALL NOT create the rule's directory + +### Requirement: Check reports rule integrity under --json + +Under `--json`, `taskless check` SHALL carry an additive, optional `integrity` array with one +entry per rule whose outcome is `unsafe`, `missing`, runtime `unknown`, `unaccounted`, or +`duplicate`, each `{ ruleId, engine?, verdict, files?, revisionId? }`. `files` SHALL list each +differing path with `expected` and `got` as the server returned them. Static `unknown` rules +and `run` rules SHALL NOT appear. The field SHALL be omitted when there is nothing to report. + +#### Scenario: An edited rule appears with its differing files + +- **WHEN** reconciliation returns `unsafe` for vale rule `no-simply-1a2b3c4d` with `.vale.ini` changed +- **THEN** `integrity` SHALL include `{ ruleId: "no-simply-1a2b3c4d", engine: "vale", verdict: "unsafe", files: [{ path: ".vale.ini", expected, got }] }` + +#### Scenario: A clean run omits the field + +- **WHEN** every reported rule is `run` or static `unknown` and nothing is `missing` +- **THEN** `check --json` SHALL NOT include `integrity` diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md new file mode 100644 index 00000000..ac1e9be8 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md @@ -0,0 +1,109 @@ +## MODIFIED Requirements + +### Requirement: A delivered rule is a file set + +The CLI SHALL accept a rule served by the v2 API (`GET /cli/api/v2/rule/{ruleId}`, restore, +or rollback) as exactly one file set `{ id, engine, files, signatures }`, each file with a path +relative to `.taskless/rules///` and its content as text. One shape SHALL serve every +engine, validated against `ENGINE_LAYOUTS` — the table the CLI already holds — so that "is this a +complete rule" is answered from data rather than from per-engine prose. The legacy single +`content` object is not part of v2 and SHALL NOT be accepted. + +#### Scenario: A runtime rule arrives complete + +- **WHEN** a delivered rule declares engine `runtime` and carries `check.ts` and `captures/*.yml` +- **THEN** the CLI SHALL write them under `.taskless/rules/runtime//` +- **AND** the rule SHALL be discoverable and verifiable without further input + +#### Scenario: A Vale rule arrives with its config + +- **WHEN** a delivered rule declares engine `vale` and carries `.yml` and `.vale.ini` +- **THEN** the CLI SHALL write both +- **AND** the rule SHALL be scoped by its own `.vale.ini` rather than by a synthesized default + +#### Scenario: An incomplete file set is refused + +- **WHEN** a delivered file set omits a file the engine layout requires +- **THEN** the CLI SHALL refuse the rule and name what is missing +- **AND** SHALL NOT write a partial rule directory + +#### Scenario: A response with more than one rule is refused + +- **WHEN** a v2 rule response carries a `rules` array whose length is not exactly one, or whose one set's `id` is not the requested rule id +- **THEN** the CLI SHALL refuse the response and write nothing + +### Requirement: A runtime rule written under a plan without runtime signatures says it will not run + +When the CLI writes a runtime rule from a response whose `entitlement.runtimeSignatures` is exactly `false` (from `rule create`, `rule improve`, `rule restore`, or `rule rollback`), it SHALL still write the rule, and SHALL emit a warning that the rule is on disk but will not run on the organization's current plan, including the `upgradeUrl` when it is an absolute `https:` URL. The warning SHALL be carried in the command's existing notices, so it appears in human output and under `--json`. A restore or rollback under such a response SHALL NOT state or imply that the next `check` will run the rule. A response with no `entitlement` object, or with `runtimeSignatures: true`, SHALL produce no such warning. + +#### Scenario: Created runtime rule under an unentitled plan + +- **WHEN** `rule create` receives a generated runtime rule with `entitlement.runtimeSignatures: false` +- **THEN** the CLI SHALL write the rule +- **AND** SHALL warn that it will not run on the current plan +- **AND** under `--json` the warning SHALL appear in `notices` + +#### Scenario: Restore under an unentitled plan does not promise blessing + +- **WHEN** `rule restore` writes a runtime rule and the restore response carries `entitlement.runtimeSignatures: false` +- **THEN** the notice SHALL say the rule's bytes were restored but will not run on the current plan +- **AND** SHALL NOT say the next `check` runs it + +#### Scenario: Entitled or legacy responses do not warn + +- **WHEN** a runtime rule is written from a response with no `entitlement` object or with `runtimeSignatures: true` +- **THEN** the CLI SHALL emit no entitlement warning + +#### Scenario: Static rules never warn + +- **WHEN** `rule create` writes an `sg` or `vale` rule +- **THEN** the CLI SHALL emit no entitlement warning + +## ADDED Requirements + +### Requirement: A delivered file set is verified against its signatures + +Before writing any file of a served rule, the CLI SHALL verify that every signature names a +delivered file, that every delivered file outside `.tests/` has exactly one signature, and that +the algoVersion-1 `canonicalHash` of each such file's content equals its signature. For a +runtime rule it SHALL also verify that the singular `signature` equals the `check.ts` entry. +Any failure SHALL refuse the whole rule and write nothing. + +#### Scenario: A corrupted file is refused + +- **WHEN** a served file's content does not hash to its entry in `signatures` +- **THEN** the CLI SHALL refuse the rule naming the file +- **AND** SHALL write nothing for it + +#### Scenario: An unsigned file is refused + +- **WHEN** a served file outside `.tests/` has no entry in `signatures` +- **THEN** the CLI SHALL refuse the rule + +#### Scenario: Fixtures need no signature + +- **WHEN** a served rule carries files under `.tests/` that have no entry in `signatures` +- **THEN** the CLI SHALL accept them + +### Requirement: A delivered rule replaces its directory + +After verification, the CLI SHALL write a served rule so that its directory holds exactly the +served files: every file in the directory that the served set does not contain SHALL be removed. +Files under `.tests/` SHALL be replaced only when the served set carries at least one `.tests/` +file; otherwise the local `.tests/` SHALL be left in place. When a stale file cannot be removed, the CLI SHALL say that the served +bytes were written and name each entry it could not remove. + +#### Scenario: A local extra file does not survive + +- **WHEN** a rule directory holds `captures/extra.yml` and the served set does not +- **THEN** after the write `captures/extra.yml` SHALL NOT exist + +#### Scenario: Local fixtures survive a set that carries none + +- **WHEN** a served set carries no file under `.tests/` and the local rule has `.tests/` +- **THEN** the local `.tests/` SHALL be left in place + +#### Scenario: A generated rule's revision is confirmed + +- **WHEN** `rule create` or `rule improve` fetches a produced rule whose served `revisionId` differs from the one the request reported +- **THEN** the CLI SHALL refuse that rule and write nothing for it diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-rule-reconciliation/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-rule-reconciliation/spec.md new file mode 100644 index 00000000..3b0384c7 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/specs/cli-rule-reconciliation/spec.md @@ -0,0 +1,326 @@ +## MODIFIED Requirements + +### Requirement: Canonical rule signature envelope + +The CLI SHALL represent a rule file's canonical signature as a single self-describing +string of the form `;h=;d=`. For algoVersion `1` this is +`1;h=sha-256;d=`, where `` is the digest as lowercase hexadecimal. The token +before the **first** `;` is the algoVersion and SHALL be read up to that one delimiter to +detect the version (and therefore the normalization procedure and hash algorithm) before +any `key=value` parameters are parsed. Signatures SHALL be compared as whole strings. + +The algoVersion SHALL also determine **what one signature covers**. A signature at +algoVersion `1` covers exactly one file. **Which** files of a rule are signed is set by the +reconcile contract, not by the envelope: every file in the rule's directory except those +under `.tests/`, each with its own signature. + +**Rationale.** Coverage per signature is a property of the signature scheme; the set of +signed files is a property of the rule contract. Keeping them apart is what lets the rule +contract grow from "`check.ts` only" to "every non-fixture file" without a new algoVersion, +because no single signature's meaning changed. + +#### Scenario: Envelope is emitted for algoVersion 1 + +- **WHEN** the CLI computes a signature for a rule file's bytes using algoVersion 1 +- **THEN** the signature SHALL be the string `1;h=sha-256;d=` for that file's normalized bytes + +#### Scenario: Version is read before parameters + +- **WHEN** the CLI parses a signature string +- **THEN** it SHALL read the algoVersion as the substring before the first `;` +- **AND** SHALL NOT rely on the `key=value` parameter syntax to determine the version + +#### Scenario: Signatures compare as whole strings + +- **WHEN** the CLI compares two signatures for equality +- **THEN** it SHALL compare the full envelope strings, not the bare digests + +#### Scenario: A v1 signature covers exactly one file + +- **WHEN** the CLI computes an algoVersion-1 signature for a rule +- **THEN** that signature SHALL be over one file's bytes and over no other file + +#### Scenario: A v1 signature covers the engine's rule file + +- **WHEN** a served runtime rule carries its singular `signature` +- **THEN** that signature SHALL be over `check.ts` and SHALL equal the `check.ts` entry in `signatures` + +#### Scenario: Every non-fixture file of a rule is signed + +- **WHEN** the CLI signs a rule directory for reconcile +- **THEN** it SHALL compute one signature per file in the directory +- **AND** SHALL NOT sign any file under `.tests/` + +### Requirement: Conformance vectors are fetched and asserted + +The CLI SHALL consume the cross-repo conformance vectors served at +`GET /cli/api/v2/rule-hash-vectors` (unauthenticated) as `{ vectors: [{ name, input, signature }] }`, +commit a copy as a fixture, and assert in its test suite that its independent +`normalize()`-plus-hash reproduces every vector's `signature` exactly. Non-ASCII `input` +SHALL be parsed as JSON (decoding `\uXXXX` escapes) before hashing. A vector mismatch SHALL +be a release blocker (the test SHALL fail the build). + +#### Scenario: Local hasher reproduces every vector + +- **WHEN** the conformance test runs against the committed vectors +- **THEN** the CLI SHALL compute the exact `signature` for every vector entry + +#### Scenario: A mismatch blocks release + +- **WHEN** any vector's computed signature does not match its expected `signature` +- **THEN** the test SHALL fail +- **AND** the build SHALL NOT pass + +#### Scenario: Vectors come from the v2 surface + +- **WHEN** the build refreshes the committed vectors +- **THEN** it SHALL fetch `GET /cli/api/v2/rule-hash-vectors` +- **AND** SHALL NOT call a v1 route + +### Requirement: Reconcile is scoped to the token's organization + +The reconcile endpoint SHALL be authorized by the bearer token and SHALL be scoped to the +organization named by the optional `orgId` (a Taskless org UUID, preferred, or a numeric +GitHub org id) or, when absent, by the token. The CLI SHALL handle the documented edges: a +`401` with `{ error: "unauthorized" }` for a missing or invalid token; a `404` with +`{ error: "organization_not_found" }` when the organization is not accessible or the +repository is not covered by its installation (deliberately indistinguishable); an empty +corpus (every reported rule in `unknown`, nothing runs on a verdict); and an empty report +(every issued rule in `missing`). + +#### Scenario: Unauthorized token + +- **WHEN** the CLI calls reconcile without a valid bearer token +- **THEN** the server SHALL return `401` with `{ error: "unauthorized" }` +- **AND** the CLI SHALL NOT execute any runtime rule on the basis of that call + +#### Scenario: Empty corpus runs nothing + +- **WHEN** the repository has no issued rules and the CLI reports rules +- **THEN** every reported rule SHALL be returned in `unknown` +- **AND** the CLI SHALL execute no runtime rule + +#### Scenario: Organization not found is not a service outage + +- **WHEN** reconcile answers `404` with `{ error: "organization_not_found" }` +- **THEN** the CLI SHALL name both causes (the installation does not cover this repository, or the login lost access) +- **AND** SHALL treat the run as unverified rather than as a verdict + +## ADDED Requirements + +### Requirement: Reconcile reports every rule directory + +When `check` reconciles, the CLI SHALL send `POST /cli/api/v2/reconcile` with +`{ repositoryUrl, orgId?, rules }`, where `rules` holds exactly one +`{ ruleId, files: [{ path, signature }] }` for **every** rule directory under +`.taskless/rules//`, of every engine, whatever `--rule` selects to run. `ruleId` +SHALL be the directory name. `files` SHALL list every regular file under the directory, +recursively, except files under `.tests/` and the operating-system metadata files +`.DS_Store`, `Thumbs.db`, and `desktop.ini`. `path` SHALL be relative to the rule directory +with `/` separators on every platform, and `signature` SHALL be the full algoVersion-1 +envelope of the snapshot's bytes for that file. + +#### Scenario: Every engine is reported + +- **WHEN** `.taskless/rules/sg/`, `.taskless/rules/vale/`, and `.taskless/rules/runtime/` each hold rules +- **THEN** the request SHALL carry one entry per rule directory across all three engines + +#### Scenario: A rule's files are reported relative to its directory + +- **WHEN** runtime rule `no-env-leak-3fa9c21b` holds `check.ts` and `captures/env.yml` +- **THEN** its entry SHALL be `{ ruleId: "no-env-leak-3fa9c21b", files: [{ path: "check.ts", … }, { path: "captures/env.yml", … }] }` + +#### Scenario: Fixtures are not reported + +- **WHEN** a rule directory holds files under `.tests/` +- **THEN** no reported path SHALL begin with `.tests/` + +#### Scenario: --rule does not narrow the report + +- **WHEN** a user runs `check --rule a` in a project holding rules `a` and `b` +- **THEN** the reconcile request SHALL report both `a` and `b` +- **AND** only `a` SHALL run + +### Requirement: Rule ids are unique across engines + +Before reconciling, the CLI SHALL refuse the run when two rule directories under different +engines share a directory name, naming both directories. It SHALL NOT report either rule +and SHALL NOT resolve the collision by skipping one of them. + +#### Scenario: A duplicate id stops the run + +- **WHEN** both `.taskless/rules/sg/foo-3fa9c21b/` and `.taskless/rules/vale/foo-3fa9c21b/` exist +- **THEN** `check` SHALL exit non-zero naming both directories +- **AND** SHALL NOT call reconcile + +#### Scenario: A decoy cannot neutralize an issued rule + +- **WHEN** someone creates a directory under a second engine with the id of an issued rule +- **THEN** the issued rule SHALL NOT run as though it were locally authored + +### Requirement: Reconcile verdicts are applied per engine + +The CLI SHALL read the v2 reconcile response as a list of per-rule verdicts +(`rules[]`, each `{ ruleId, engine, verdict }` with `verdict` one of `run`, `unsafe`, +`missing`), a list of `unknown` rule ids, 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 | + +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 +authorize running a runtime rule only through a `run` verdict, never by local comparison. + +#### Scenario: An edited static rule fails and does not run + +- **WHEN** reconcile returns `{ ruleId: "no-simply-1a2b3c4d", engine: "vale", verdict: "unsafe", files: [{ path: ".vale.ini", expected, got }] }` +- **THEN** the rule SHALL NOT run +- **AND** `check` SHALL exit non-zero naming the rule and `.vale.ini` as changed + +#### Scenario: A locally written static rule runs + +- **WHEN** reconcile lists a static rule's id in `unknown` +- **THEN** that rule SHALL run +- **AND** the CLI SHALL emit no notice for it + +#### Scenario: A locally written runtime rule does not execute + +- **WHEN** reconcile lists a runtime rule's id in `unknown` and `--dangerously-run-scripts` is not set +- **THEN** the rule SHALL NOT execute +- **AND** its skip reason SHALL say it was not issued by the rule service + +#### Scenario: Missing warns and does not fail + +- **WHEN** reconcile returns a `missing` verdict for any engine +- **THEN** the CLI SHALL warn naming the rule and `taskless rule restore ` +- **AND** SHALL NOT change the exit code because of it + +#### Scenario: An edited runtime rule is withheld, not failed + +- **WHEN** reconcile returns an `unsafe` verdict for a runtime rule +- **THEN** the rule SHALL NOT execute +- **AND** the exit code SHALL NOT change because of that verdict alone + +### Requirement: Every reported rule is accounted for + +After a reconcile completes, every `ruleId` the CLI reported SHALL appear in exactly one of +`rules[]`, `unknown[]`, or `entitlement.withheld[]`. A reported rule that appears in none, +or in more than one, SHALL be treated as unaccounted: it SHALL NOT run or execute, and +`check` SHALL fail naming it. `missing` verdicts name rules that were not reported and are +outside this check. + +#### Scenario: A dropped rule fails the run + +- **WHEN** the CLI reports rule `a` and the response names `a` in none of `rules`, `unknown`, or `entitlement.withheld` +- **THEN** `a` SHALL NOT run +- **AND** `check` SHALL exit non-zero naming `a` + +#### Scenario: A rule answered twice fails the run + +- **WHEN** a reported rule appears both in `rules[]` and in `entitlement.withheld` +- **THEN** it SHALL NOT run +- **AND** `check` SHALL exit non-zero naming it + +### Requirement: The CLI runs the bytes it reported + +Before signing anything, `check` SHALL copy `.taskless/rules/` into a snapshot at +`.taskless/.run/rules/`, replacing any previous snapshot and dereferencing symbolic links. +It SHALL compute every reported signature from the snapshot and SHALL run every engine +from the snapshot, with the assembled configs written under `.taskless/.run/`. A rule the +verdict excludes SHALL be removed from the snapshot before any engine configuration is +assembled. The snapshot SHALL be taken on every path, including unauthenticated and +`--anonymous` runs. + +#### Scenario: An edit after signing does not run + +- **WHEN** a rule file under `.taskless/rules/` is edited after `check` has signed the snapshot +- **THEN** the engines SHALL run the snapshot's bytes, not the edited file + +#### Scenario: Static rules run from the snapshot + +- **WHEN** `check` runs sg and vale rules +- **THEN** the ast-grep and Vale configs SHALL point into `.taskless/.run/rules/` +- **AND** SHALL NOT point into `.taskless/rules/` + +#### Scenario: An excluded rule is absent from what runs + +- **WHEN** a static rule's verdict is `unsafe` +- **THEN** its directory SHALL be absent from the snapshot the engines read + +### Requirement: The CLI reads the v2 reconcile entitlement + +The CLI SHALL read the reconcile response's `entitlement` object, which v2 always sends. It +SHALL treat the organization as unentitled only when `entitlement.runtimeSignatures` is +exactly `false`, SHALL read `reason`, `upgradeUrl`, and `withheld` as a list of +`{ ruleId, revisionId }`, SHALL match a withheld entry to a reported rule by `ruleId`, and +SHALL surface `upgradeUrl` only when it is an absolute `https:` URL. A withheld entry SHALL +NOT be dropped for lacking any field other than `ruleId`. A withheld rule SHALL NOT be +executed, SHALL NOT be described as unsafe, unknown, or drifted, and SHALL NOT be offered +restore. + +#### Scenario: Withheld is matched by rule id + +- **WHEN** `entitlement.withheld` lists `{ ruleId: "no-env-leak-3fa9c21b", revisionId }` and the CLI reported that rule +- **THEN** the rule SHALL be classified as withheld for entitlement and SHALL NOT execute + +#### Scenario: Entitled response withholds nothing + +- **WHEN** a reconcile response carries `entitlement: { runtimeSignatures: true }` +- **THEN** no rule SHALL be classified as withheld + +#### Scenario: Withheld is not offered restore + +- **WHEN** a runtime rule is withheld for entitlement +- **THEN** no notice SHALL suggest restoring it + +#### Scenario: A malformed upgrade URL is not shown + +- **WHEN** `entitlement.upgradeUrl` is not an absolute `https:` URL +- **THEN** the CLI SHALL omit it from human and `--json` output + +## REMOVED Requirements + +### Requirement: Reconcile reports every runtime rule's check.ts + +**Reason**: v2 reconciles whole rules of every engine. Reporting only `check.ts` left sg and +vale rules, and a runtime rule's captures, unprotected. +**Migration**: Replaced by "Reconcile reports every rule directory". + +### Requirement: Reconcile response buckets drive execution + +**Reason**: The v1 per-file buckets (`run`, `unsafe`, `unknown`, `missing` as file lists) +do not exist in v2, which returns one verdict per rule. +**Migration**: Replaced by "Reconcile verdicts are applied per engine". + +### Requirement: The CLI executes only the server run set + +**Reason**: Restated for per-rule verdicts. The substance (a runtime rule executes only on a +`run` verdict, never on local comparison) is carried into "Reconcile verdicts are applied +per engine". +**Migration**: See "Reconcile verdicts are applied per engine". + +### Requirement: The reported file path is contractual + +**Reason**: v2 matches by `ruleId` and a path relative to the rule directory, not by a +repo-relative `check.ts` path. +**Migration**: The reported shape is defined by "Reconcile reports every rule directory". + +### Requirement: A withheld rule can be re-fetched + +**Reason**: `check` no longer repairs rules. Re-fetching moves to an explicit command, and +the v1 restore route never worked (TSKL-307). +**Migration**: See the `cli-rule-recovery` capability (`taskless rule restore`). + +### Requirement: The CLI reads the reconcile entitlement object + +**Reason**: The v1 object keyed `withheld` by file and could be absent. v2 always sends +`entitlement` and keys `withheld` by rule id; reusing the v1 parser would drop every +withheld rule. +**Migration**: Replaced by "The CLI reads the v2 reconcile entitlement". diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-rule-recovery/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-rule-recovery/spec.md new file mode 100644 index 00000000..b0a9cc4f --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/specs/cli-rule-recovery/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: Rule restore repairs a rule to its current revision + +The CLI SHALL provide `taskless rule restore `, which requires authentication and a +resolvable repository. It SHALL snapshot and reconcile the rules tree exactly as `check` would, +read only the named rule's outcome, and act on it: + +- `run` or withheld for entitlement: nothing to restore; say so and exit 0. +- `unknown`: say the rule was not issued for this repository and cannot be restored; exit non-zero. +- `unsafe` or `missing`: call `POST /cli/api/v2/rule/{ruleId}/restore` with `{ repositoryUrl, orgId? }`. + +It SHALL NOT change any other rule. + +#### Scenario: An edited rule is restored + +- **WHEN** a user runs `taskless rule restore no-simply-1a2b3c4d` and reconcile returns `unsafe` for it +- **THEN** the CLI SHALL call restore for that rule +- **AND** after a verified write the next `check` SHALL reconcile it as `run` + +#### Scenario: An intact rule is left alone + +- **WHEN** reconcile returns `run` for the named rule +- **THEN** the CLI SHALL NOT call restore +- **AND** SHALL exit 0 saying the rule already matches an issued revision + +#### Scenario: A locally written rule cannot be restored + +- **WHEN** reconcile lists the named rule in `unknown` +- **THEN** the CLI SHALL exit non-zero saying the rule was not issued for this repository + +### Requirement: Restore writes only what reconcile expected + +Before writing a restored rule the CLI SHALL verify the served file set against its own +`signatures` (per `cli-generated-rule-delivery`) and against reconcile's expectation: + +- for `unsafe`: the served signature map SHALL equal the local signature map with each reported + file's `expected` applied and each `got`-only path removed; +- for `missing`: the served `revisionId` SHALL equal the verdict's `revisionId`. + +On any mismatch it SHALL write nothing and exit non-zero, saying restore repairs a rule and does +not upgrade one. + +#### Scenario: A newer revision is not written by restore + +- **WHEN** the served set's signatures differ from reconcile's expected map for an `unsafe` rule +- **THEN** the CLI SHALL write nothing +- **AND** SHALL exit non-zero naming the rule + +#### Scenario: A missing rule is restored to the revision reconcile named + +- **WHEN** reconcile returns `missing` with `revisionId: "r1"` and restore serves `revisionId: "r1"` with valid signatures +- **THEN** the CLI SHALL write the rule directory + +### Requirement: Rule rollback makes an earlier revision current + +The CLI SHALL provide `taskless rule rollback `, which requires +authentication and a resolvable repository and calls +`POST /cli/api/v2/rule/{ruleId}/rollback` with `{ repositoryUrl, revisionId, orgId? }`. It SHALL +write the served set only when the served `revisionId` equals the requested one and every file +verifies against its signature. `404 revision_not_found` and `404 rule_not_found` SHALL each be +reported as such. + +#### Scenario: Rollback writes the requested revision + +- **WHEN** a user rolls rule `no-eval-3fa9c21b` back to revision `r1` and the server serves `r1` with valid signatures +- **THEN** the CLI SHALL replace the rule directory with the served set + +#### Scenario: A different revision is refused + +- **WHEN** rollback serves a `revisionId` other than the one requested +- **THEN** the CLI SHALL write nothing and exit non-zero + +### Requirement: A plan refusal is an answer, not a failure of the service + +When restore or rollback answers `200` with `restoreRules: false`, the CLI SHALL print the +response's `message` verbatim with C0 and C1 control characters other than newline removed, then +`upgradeUrl` when it is an absolute `https:` URL, and SHALL write nothing. It SHALL exit non-zero +with code `RULE_RECOVERY_NOT_IN_PLAN` and SHALL NOT describe the outcome as the service being +unavailable. An unrecognized `reason` SHALL be handled the same way. + +#### Scenario: Free plan gets git guidance + +- **WHEN** restore answers `{ restoreRules: false, reason: "RESTORE_RULES_NOT_IN_PLAN", message, upgradeUrl }` +- **THEN** the CLI SHALL print `message` and the upgrade URL +- **AND** SHALL exit non-zero with `RULE_RECOVERY_NOT_IN_PLAN` + +#### Scenario: Control characters are stripped + +- **WHEN** a refusal `message` contains an ANSI escape sequence +- **THEN** the printed message SHALL NOT contain the escape character + +### Requirement: Recovery commands report under --json + +Under `--json`, `rule restore` and `rule rollback` SHALL print +`{ success: true, ruleId, revisionId, files, notices? }` on success and the standardized error +envelope `{ ok: false, code, message }` otherwise, with `code` distinguishing +`RULE_RECOVERY_NOT_IN_PLAN`, `RULE_NOT_FOUND`, `REVISION_NOT_FOUND`, `RULE_RESTORE_MISMATCH`, +`AUTH_REQUIRED`, and `NETWORK_ERROR`. + +#### Scenario: A refusal is machine-readable + +- **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 diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-rules/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-rules/spec.md new file mode 100644 index 00000000..e4d458d8 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/specs/cli-rules/spec.md @@ -0,0 +1,151 @@ +## MODIFIED Requirements + +### Requirement: Rules create submits to API and polls for results + +`taskless rule create` without `--anonymous` SHALL submit the request to +`POST /cli/api/v2/request`, poll `GET /cli/api/v2/request/{requestId}` until the request reaches +`generated`, `failed`, or `unsupported`, and then fetch each produced rule's head with +`GET /cli/api/v2/rule/{ruleId}` (without `revision`), in parallel, per the +`cli-generated-rule-delivery` capability. On `failed` or `unsupported` it SHALL print the +response's `error` as given. + +#### Scenario: Submission returns a request to poll + +- **WHEN** an authenticated user runs `taskless rule create` without `--anonymous` +- **THEN** the CLI SHALL submit the request to the API +- **AND** it SHALL poll for the result until the generation completes or fails + +#### Scenario: Each produced rule is fetched by its id + +- **WHEN** polling reaches `generated` with `revisions: [{ ruleId, revisionId }, …]` +- **THEN** the CLI SHALL fetch `GET /cli/api/v2/rule/{ruleId}` for each, without `revision` +- **AND** SHALL write each rule only after confirming its served `revisionId` + +#### Scenario: A plan refusal is printed as given + +- **WHEN** polling reaches `failed` or `unsupported` with an `error` +- **THEN** the CLI SHALL print that `error` verbatim (control characters stripped) and exit non-zero + +### Requirement: Rules create outputs results + +`taskless rule create` SHALL output results human-readable by default; `--json` produces +machine-readable output `{ success, requestId, rules, files, notices? }`, where `requestId` is the +generation request id and `rules` lists the produced rule ids (their directory names). It SHALL +NOT emit a field named `ruleId`. On failure with `--json` set, the output SHALL be the +standardized error envelope `{ ok: false, code: "", message: "<...>" }` per the `cli` +capability requirements. + +#### Scenario: Failure under --json uses the error envelope + +- **WHEN** `taskless rule create --json` fails +- **THEN** the CLI SHALL print `{ ok: false, code, message }` rather than prose + +#### Scenario: Success under --json names the request and the rules + +- **WHEN** `taskless rule create --json` produces rule `no-eval-3fa9c21b` +- **THEN** stdout SHALL include `requestId` and `rules: ["no-eval-3fa9c21b"]` +- **AND** SHALL NOT include `ruleId` + +### Requirement: Rules improve reads request from file + +`taskless rule improve` SHALL accept a `--from ` flag specifying a JSON file containing the +iterate request `{ ruleId, guidance, references? }`, where `ruleId` is the rule's directory name +under `.taskless/rules//`, the id v2 addresses a rule by. (Renamed to singular.) + +#### Scenario: The request is read from the named file + +- **WHEN** a user runs `taskless rule improve --from request.json` +- **THEN** the CLI SHALL read the iterate request from that file + +#### Scenario: The rule id is the directory name + +- **WHEN** the request names `ruleId: "no-eval-3fa9c21b"` +- **THEN** the CLI SHALL iterate the rule at `.taskless/rules//no-eval-3fa9c21b/` + +### Requirement: Rules improve submits to iterate API and polls for results + +`taskless rule improve` without `--anonymous` SHALL submit to +`POST /cli/api/v2/rule/{ruleId}/iterate`, poll the returned `requestId` exactly as `rule create` +does, and fetch and write the produced revision the same way. A `404 rule_not_found` SHALL be +reported as `RULE_NOT_FOUND`, not as a network error. + +#### Scenario: Submission returns a request to poll + +- **WHEN** an authenticated user runs `taskless rule improve` without `--anonymous` +- **THEN** the CLI SHALL submit to the iterate API +- **AND** it SHALL poll until the iteration completes or fails + +#### Scenario: An unknown rule id is reported as such + +- **WHEN** iterate answers `404` with `{ error: "rule_not_found" }` +- **THEN** the CLI SHALL fail with code `RULE_NOT_FOUND` + +### Requirement: Whoami endpoint returns user identity and organizations + +The server SHALL expose `GET /cli/api/v2/whoami` that accepts an authenticated request and returns +the user's identity and associated organizations, and the CLI SHALL call it rather than any v1 +route. + +#### Scenario: Authenticated user + +- **WHEN** an authenticated client sends a GET to `/cli/api/v2/whoami` +- **THEN** the server SHALL return `{ user: string, email?: string, orgs: [{ orgId: number, id: string, name: string, source: "github", url: string }] }` + +#### Scenario: Unauthenticated request + +- **WHEN** a client sends a GET without a valid `Authorization: Bearer ` header +- **THEN** the server SHALL return HTTP 401 with `{ error: "unauthorized" }` + +## ADDED Requirements + +### Requirement: Every API call is a v2 call carrying the CLI version + +Every Taskless API call the CLI makes outside `/cli/auth/*` SHALL go to a path under +`/cli/api/v2/` and SHALL carry the `x-taskless-cli-version` header with the running CLI's +version. The CLI SHALL NOT call any v1 data route. + +#### Scenario: A v1 route is never called + +- **WHEN** any command in this release talks to the Taskless API +- **THEN** the request path SHALL begin with `/cli/api/v2/` or `/cli/auth/` + +#### Scenario: The version header is always sent + +- **WHEN** the CLI calls any `/cli/api/v2/` route +- **THEN** the request SHALL carry `x-taskless-cli-version` + +## REMOVED Requirements + +### Requirement: Rules create uses a network interface with stub + +**Reason**: Describes a stub for the v1 routes that the CLI no longer calls. +**Migration**: The v2 calls are defined by "Rules create submits to API and polls for results" +and "Every API call is a v2 call carrying the CLI version". + +### Requirement: Rule generation request endpoint accepts a request and returns a requestId + +**Reason**: A v1 server route the CLI no longer calls. The v2 contract is owned by the server +(taskless/taskless#229) and vendored as `api.schema.json`. +**Migration**: See `POST /cli/api/v2/request` in the vendored schema. + +### Requirement: Iterate endpoint accepts guidance and returns a requestId + +**Reason**: A v1 server route addressed by request id. v2 iterates by rule id. +**Migration**: See `POST /cli/api/v2/rule/{ruleId}/iterate` in the vendored schema. + +### Requirement: Request status endpoint returns generation progress + +**Reason**: The v1 status response carried rule content. v2 polling returns only +`{ ruleId, revisionId }` pairs. +**Migration**: See `GET /cli/api/v2/request/{requestId}` in the vendored schema. + +### Requirement: Generated rule content follows ast-grep schema + +**Reason**: Describes the legacy single `content` object, which v2 does not serve. +**Migration**: Every served rule is a file set (`cli-generated-rule-delivery`). + +### Requirement: Generated rules may include test cases + +**Reason**: Describes the legacy `tests` object beside `content`. v2 serves fixtures as files +under `.tests/`. +**Migration**: Every served rule is a file set (`cli-generated-rule-delivery`). diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-runtime-rule-execution/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-runtime-rule-execution/spec.md new file mode 100644 index 00000000..37d2d6c4 --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/specs/cli-runtime-rule-execution/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Blessed runtime rules execute from the materialized run directory + +When a runtime rule is executed on a validated path, the CLI SHALL execute it from the +snapshot `check` took at `.taskless/.run/rules/runtime/` **before** signing, not from the live +`.taskless/rules/runtime/` tree and not from a copy made after reconciliation, so the bytes +executed are exactly the bytes that were reported and judged (copy, sign, report, execute). + +#### Scenario: Execution uses the blessed bytes + +- **WHEN** a runtime rule is blessed and executed +- **THEN** the CLI SHALL invoke the `check.ts` in the snapshot under `.taskless/.run/rules/runtime/` +- **AND** SHALL NOT execute a copy modified in `.taskless/rules/runtime/` after reconciliation + +#### Scenario: No copy is made between the verdict and execution + +- **WHEN** a runtime rule's `check.ts` is edited in `.taskless/rules/runtime/` after the snapshot was signed and before execution +- **THEN** the executed `check.ts` SHALL be the snapshot's bytes that reconcile judged diff --git a/openspec/changes/cli-v2-rule-api/tasks.md b/openspec/changes/cli-v2-rule-api/tasks.md new file mode 100644 index 00000000..7522096f --- /dev/null +++ b/openspec/changes/cli-v2-rule-api/tasks.md @@ -0,0 +1,185 @@ +Groups map to the stack in proposal.md's delivery shape: 1–2 are slice 1 +(contract), 3 is slice 2 (generation), 4–6 are slice 3 (reconcile), 7 is slice +4 (recovery), 8–9 are slice 5 (retire v1, verify, archive). Each slice is its own +branch targeting the one below; the stack merges down. + +## 1. Contract and changeset (slice 1) + +- [x] 1.1 Dry-run the archive (done on a scratch copy of `openspec/`, which + needs no WIP commit) and confirm every standing scenario title in + `cli-check`, `cli-rule-reconciliation`, `cli-generated-rule-delivery`, + `cli-rules`, and `cli-runtime-rule-execution` survives, and that only the + requirements listed under REMOVED disappear. `pnpm openspec validate + cli-v2-rule-api --strict` passes. +- [x] 1.2 Add the `minor` changeset on this branch. The body says: the CLI now + speaks only the v2 API and 0.11.x stops working once 0.12.0 is the + server's floor; `check` fails on an edited sg or vale rule; `rule create + --json` prints `requestId` instead of `ruleId`; `rule restore` and `rule + rollback` exist. Verify `pnpm changeset status` proposes `0.12.0`. +- [x] 1.3 Confirm the nightly stamp: run `node .github/scripts/nightly-pack.cjs + --print-version --status --sha ` on this branch and check it prints `0.12.0-*`, since the + server resolves a prerelease to the release it precedes. + +## 2. v2 schema and client (slice 1) + +- [ ] 2.1 Point `scripts/fetch-api-schema.ts` at `/cli/api/v2/__schema`, run + `pnpm --filter @taskless/cli generate:api`, and review the + `api.schema.json` diff. Verify every v2 operation (whoami, reconcile, + request, request status, rule fetch, iterate, restore, rollback, + rule-hash-vectors) is present with its documented error responses. +- [ ] 2.2 Point `scripts/fetch-rule-hash-vectors.ts` at + `/cli/api/v2/rule-hash-vectors`; the committed fixture is unchanged + (measured identical to v1) and `rule-hash.test.ts` passes. +- [ ] 2.3 Add `api/v2.ts`: one typed `openapi-fetch` client over the v2 `paths`, + always sending `x-taskless-cli-version`, with one function per operation + returning an outcome union (`ok` / `refused` / typed error codes / + `unauthorized` / `unavailable`) and never throwing for expected + conditions. Unit tests cover each operation's success, each documented + error code, a network failure, and an unparseable body. +- [ ] 2.4 Add `api/refusal.ts`: parse `{ restoreRules: false, reason, message, + upgradeUrl }`, strip C0/C1 control characters except newline from + `message`, keep `upgradeUrl` only as absolute `https:`. Tests cover an + unknown `reason`, an ANSI escape in `message`, and a relative URL. +- [ ] 2.5 Rewrite `api/entitlement.ts` for v2: `withheld` is + `{ ruleId, revisionId }[]`, matched by `ruleId`; no entry is dropped for + lacking `file`. Tests include the #403 hazard: a v2 withheld list parses + to the same number of entries it arrived with. + +## 3. Generation on v2 (slice 2) + +- [ ] 3.1 Add `rules/verify-delivery.ts`: verify a served file set against its + `signatures` (every signature names a file, every non-`.tests/` file has + one, each hash matches, runtime `signature` equals the `check.ts` entry) + and that `rules` holds exactly one set whose `id` is the requested id. + Unit tests for each refusal. +- [ ] 3.2 Make `writeDeliveredFileSet` the only write path for a served rule and + make it replace the directory (purge files the set lacks; purge `.tests/` + only when the set carries a `.tests/` file, pending the rules team's + confirmation that fixtures ship). Drop the legacy single-`content` branch from `deliver.ts` and + `files.ts`. `deliver.test.ts` covers a local extra capture being removed. +- [ ] 3.3 Move `rule create` to v2: submit, poll, fetch each produced + `{ ruleId, revisionId }` head in parallel without `revision`, confirm + `revisionId`, verify, write. Print `error` verbatim (sanitized) on + `failed` / `unsupported`. `--json` prints `requestId` and `rules`, no + `ruleId`; update `schemas/rules-create.ts`. Tests use a stubbed v2 server. +- [ ] 3.4 Move `rule improve` to `POST v2/rule/{ruleId}/iterate`, with the input + `ruleId` meaning the directory name; `404 rule_not_found` → + `RULE_NOT_FOUND`. Tests cover success and the not-found code. +- [ ] 3.5 Keep the write-time entitlement warning for runtime sets served with + `runtimeSignatures: false`; `rule-create-entitlement.test.ts` passes + against v2 fixtures. +- [ ] 3.6 Update the `create-remote-rule`, `improve-rule`, and `rule-meta` + recipes: record the rule ids from `rules`, pass a directory name to + `improve`, never the request id. `recipe-cross-references.test.ts` passes. + +## 4. Snapshot (slice 3) + +- [ ] 4.1 Measure first: run one Vale rule whose `.vale.ini` scopes a + subdirectory glob with its assembled config at `.taskless/.vale.ini` and + at `.taskless/.run/.vale.ini`, and assert identical findings. Do the same + for an ast-grep rule with `.sgconfig.yml`. If either differs, stop and + revise design Decision 2 before continuing. +- [ ] 4.2 Add `rules/snapshot.ts`: replace `.taskless/.run/rules/` with a + dereferencing copy of `.taskless/rules/`, skipping `.DS_Store`, + `Thumbs.db`, `desktop.ini`; a dangling link drops the file. Tests cover a + symlinked capture, a dangling link, and an OS metadata file. +- [ ] 4.3 Parameterize `assembleValeConfig` / `assembleSgConfig` (and what they + read through `engines.ts`) by a root, so `check` assembles into + `.taskless/.run/` from the snapshot while `verify` / `test` keep today's + paths. `assemble.test.ts` covers both roots. +- [ ] 4.4 Run runtime rules from `.taskless/.run/rules/runtime/`; delete + `materializeRuntimeRules` and `RUNTIME_RUN_DIR`. A test edits a live + `check.ts` after signing and asserts the snapshot's bytes executed. + +## 5. Per-rule reconcile and the verdict policy (slice 3) + +- [ ] 5.1 Add `rules/report.ts`: discover every rule directory of every engine + in the snapshot, refuse duplicate ids across engines (naming both + directories), and build `{ ruleId, files: [{ path, signature }] }` with + POSIX paths, excluding `.tests/**`. Tests: all engines reported, + fixtures excluded, a duplicate id refused, `--rule` not narrowing. +- [ ] 5.2 Add `rules/verdicts.ts`: turn a v2 reconcile response into a per-rule + disposition (run / exclude / fail reason / notice) by engine per the + table in design Decision 5, and compute accounting (a reported rule in + zero or several of `rules`, `unknown`, `withheld` is unaccounted). Pure + function, table-driven tests including an `unsafe` sg rule, a static and + a runtime `unknown`, `missing`, withheld, unaccounted, and double-listed. +- [ ] 5.3 Replace `planRuntime` with a `planCheck` that snapshots, reports, + reconciles, applies dispositions, and removes excluded rules from the + snapshot before assembly. Degrade paths (no token, `--anonymous`, no + remote, 401, 404 `organization_not_found`, unreachable) run every static + rule unverified and skip runtime rules, as today. Delete + `repairWithheldRules`, `repair.ts`, and `run-set.ts`'s v1 helpers. +- [ ] 5.4 Rewire `commands/check.ts` onto `planCheck`: exit 1 on withheld, an + `unsafe` static rule, an unaccounted rule, or a duplicate id; one notice + per `unsafe` naming each differing path and `taskless rule restore`; one + notice per `missing`. `check.test.ts`, `runtime-check.test.ts`, and + `mixed-engine-check.test.ts` cover each exit condition. +- [ ] 5.5 Assert `check` never writes `.taskless/rules/`: a test hashes the tree + before and after a run with `unsafe` and `missing` verdicts, and asserts + no restore or fetch route was called. + +## 6. check --json and recipes (slice 3) + +- [ ] 6.1 Add the optional `integrity` array to `schemas/check.ts` and emit it; + keep `skipped`, `failures`, `notices`, and `entitlement` (withheld names + resolved by rule id). Tests cover an `unsafe` entry with files, an + unaccounted entry, and its omission on a clean run. +- [ ] 6.2 Update the `check` and `ci` recipes: an edited static rule and an + unaccounted rule fail the run; the fix is `rule restore`, not editing the + rule back by hand; `missing` only warns. Update `create-runtime-rule` + where it describes reconcile. + +## 7. Recovery commands (slice 4) + +- [ ] 7.1 Add `rule restore ` per the `cli-rule-recovery` spec: reuse + the snapshot, report, and reconcile from group 5, read only the named + rule's verdict, and build the expected signature map (`unsafe`) or + revision (`missing`). Tests cover `run`, withheld, `unknown`, `unsafe`, + and `missing`. +- [ ] 7.2 Verify the served set against both its signatures and the + expectation before writing; a mismatch exits `RULE_RESTORE_MISMATCH` and + writes nothing. A test serves a newer revision for an `unsafe` rule and + asserts the tree is untouched. +- [ ] 7.3 Add `rule rollback `: served `revisionId` must + equal the requested one; `revision_not_found` and `rule_not_found` map to + their codes. Tests for each. +- [ ] 7.4 Handle the refusal in both commands: print the sanitized `message` and + `upgradeUrl`, write nothing, exit with `RULE_RECOVERY_NOT_IN_PLAN`. + Add the new codes to `types/errors.ts`. Tests cover human and `--json` + output. +- [ ] 7.5 Add a `recover-rule` agent recipe (restore versus rollback, what a + refusal means, recovering from git per the refusal's `message`) and link + it from the `check` recipe. `recipe-cross-references.test.ts` passes. + +## 8. Retire v1 (slice 5) + +- [ ] 8.1 Delete `api/rules.ts`, `api/reconcile.ts`, `api/restore.ts`, and every + v1 type use; move `auth/whoami.ts` and `auth/org.ts` to v2 whoami. Verify + `grep -rn "/cli/api/" packages/cli/src` finds only `/cli/api/v2/` paths. +- [ ] 8.2 Add a vite build check (per the code style guide, not a test that + scans output) that fails the build if the bundle contains a `/cli/api/` + string literal not followed by `v2/`, or delete the idea if the grep in + 8.1 plus the types already make a v1 call impossible to write. Record + which, and why, in the PR. +- [ ] 8.3 Remove or rewrite tests that exercised v1 (`api-deprecated-paths`, + `repair`, `repair-integration`, `reconciliation-start`, and the v1 paths + in `entitlement` and `api-rule-errors`). `pnpm test` passes. +- [ ] 8.4 Run `pnpm typecheck` and `pnpm lint` (which rebuilds and runs + `pnpm cli check`) from the repository root; both pass. + +## 9. End to end, then archive (slice 5) + +- [ ] 9.1 From a nightly stamped `0.12.0-*`, against production v2, run the full + round trip in an organization we own: `rule create` → `check` shows `run` + → edit a signed file → `check` fails with `unsafe` → `rule restore` → + `check` shows `run`. Repeat the edit on a vale rule's `.vale.ini`. Record + commands and outputs in the PR. +- [ ] 9.2 Report the round trip to the cloud team so they can close TSKL-307. +- [ ] 9.3 File the follow-ups as issues: a rule-revisions list endpoint (enables + rollback without the dashboard), plan features on `whoami`, the + directory-swap gap, the superseded-revision signal, and the v1 + `Entitlement` type on served file sets. +- [ ] 9.4 Archive the change on the tip branch (`pnpm openspec archive + cli-v2-rule-api`), then re-run the scenario-survival check from 1.1 + against the archived specs. From 1ecbe7d84482f405daa21b449b8c7cbb4b166064 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 28 Sep 2026 20:40:51 -0700 Subject: [PATCH 2/4] feat(api): vendor the v2 rule API and add a typed v2 client --- openspec/changes/cli-v2-rule-api/design.md | 24 +- openspec/changes/cli-v2-rule-api/tasks.md | 36 +- packages/cli/package.json | 2 +- packages/cli/scripts/fetch-api-schema.ts | 10 +- .../cli/scripts/fetch-rule-hash-vectors.ts | 2 +- packages/cli/src/api/entitlement.ts | 60 +- packages/cli/src/api/refusal.ts | 68 + packages/cli/src/api/v2.ts | 455 ++++ packages/cli/src/generated/api-v2.d.ts | 1377 ++++++++++ packages/cli/src/generated/api-v2.schema.json | 2310 +++++++++++++++++ packages/cli/test/api-v2.test.ts | 242 ++ packages/cli/test/entitlement.test.ts | 55 +- packages/cli/test/refusal.test.ts | 89 + 13 files changed, 4696 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/api/refusal.ts create mode 100644 packages/cli/src/api/v2.ts create mode 100644 packages/cli/src/generated/api-v2.d.ts create mode 100644 packages/cli/src/generated/api-v2.schema.json create mode 100644 packages/cli/test/api-v2.test.ts create mode 100644 packages/cli/test/refusal.test.ts diff --git a/openspec/changes/cli-v2-rule-api/design.md b/openspec/changes/cli-v2-rule-api/design.md index 67c6b4f9..04fe2f85 100644 --- a/openspec/changes/cli-v2-rule-api/design.md +++ b/openspec/changes/cli-v2-rule-api/design.md @@ -49,16 +49,20 @@ Where the CLI stands at `86799ef`: ## Decisions -### 1. v2 replaces v1 in the vendored schema; nothing keeps v1 - -`fetch-api-schema.ts` reads `/cli/api/v2/__schema`, and `api.schema.json` / -`api.d.ts` become the v2 document. After this change nothing the CLI calls is -in v1: whoami and the hash vectors have v2 twins (measured byte-identical on -2026-09-29), and `/cli/auth/*` is outside both schemas and already hand-typed. - -_Alternative:_ vendor both schemas side by side through the migration. Rejected -because the stack merges down, so no intermediate state ships, and a second -schema is a second place for a v1 call to hide. +### 1. v2 is vendored beside v1, and v1 is deleted at the tip + +`fetch-api-schema.ts` reads `/cli/api/v2/__schema` into `api-v2.schema.json` / +`api-v2.d.ts`. The v1 `api.schema.json` / `api.d.ts` stay frozen (never +refetched) until slice 5 deletes them with their last caller. After this change +nothing the CLI calls is in v1: whoami and the hash vectors have v2 twins +(measured byte-identical on 2026-09-29), and `/cli/auth/*` is outside both +schemas and already hand-typed. + +_Alternative:_ replace `api.d.ts` with v2 in slice 1. Rejected: every v1 caller +(`api/rules.ts`, `api/restore.ts`, `auth/org.ts`) would fail typecheck until +slice 5, so every PR in the stack would be red and review would happen against +code that does not compile. The v2 name is kept after v1 is gone because the +server versions its API in the path, and the file should say which one it is. All v2 calls go through one `openapi-fetch` client that sets `x-taskless-cli-version` on every request, replacing the hand-rolled `fetch` in diff --git a/openspec/changes/cli-v2-rule-api/tasks.md b/openspec/changes/cli-v2-rule-api/tasks.md index 7522096f..1f88d672 100644 --- a/openspec/changes/cli-v2-rule-api/tasks.md +++ b/openspec/changes/cli-v2-rule-api/tasks.md @@ -10,39 +10,41 @@ branch targeting the one below; the stack merges down. `cli-check`, `cli-rule-reconciliation`, `cli-generated-rule-delivery`, `cli-rules`, and `cli-runtime-rule-execution` survives, and that only the requirements listed under REMOVED disappear. `pnpm openspec validate - cli-v2-rule-api --strict` passes. +cli-v2-rule-api --strict` passes. - [x] 1.2 Add the `minor` changeset on this branch. The body says: the CLI now speaks only the v2 API and 0.11.x stops working once 0.12.0 is the server's floor; `check` fails on an edited sg or vale rule; `rule create - --json` prints `requestId` instead of `ruleId`; `rule restore` and `rule - rollback` exist. Verify `pnpm changeset status` proposes `0.12.0`. +--json` prints `requestId` instead of `ruleId`; `rule restore` and `rule +rollback` exist. Verify `pnpm changeset status` proposes `0.12.0`. - [x] 1.3 Confirm the nightly stamp: run `node .github/scripts/nightly-pack.cjs - --print-version --status --sha ` on this branch and check it prints `0.12.0-*`, since the +--print-version --status --sha ` on this branch and check it prints `0.12.0-*`, since the server resolves a prerelease to the release it precedes. ## 2. v2 schema and client (slice 1) -- [ ] 2.1 Point `scripts/fetch-api-schema.ts` at `/cli/api/v2/__schema`, run - `pnpm --filter @taskless/cli generate:api`, and review the - `api.schema.json` diff. Verify every v2 operation (whoami, reconcile, +- [x] 2.1 Point `scripts/fetch-api-schema.ts` at `/cli/api/v2/__schema`, + writing `api-v2.schema.json` / `api-v2.d.ts` beside the frozen v1 files + (design Decision 1), run `pnpm --filter @taskless/cli generate:api`, and + review the new document. Verify every v2 operation (whoami, reconcile, request, request status, rule fetch, iterate, restore, rollback, rule-hash-vectors) is present with its documented error responses. -- [ ] 2.2 Point `scripts/fetch-rule-hash-vectors.ts` at +- [x] 2.2 Point `scripts/fetch-rule-hash-vectors.ts` at `/cli/api/v2/rule-hash-vectors`; the committed fixture is unchanged (measured identical to v1) and `rule-hash.test.ts` passes. -- [ ] 2.3 Add `api/v2.ts`: one typed `openapi-fetch` client over the v2 `paths`, +- [x] 2.3 Add `api/v2.ts`: one typed `openapi-fetch` client over the v2 `paths`, always sending `x-taskless-cli-version`, with one function per operation returning an outcome union (`ok` / `refused` / typed error codes / `unauthorized` / `unavailable`) and never throwing for expected conditions. Unit tests cover each operation's success, each documented error code, a network failure, and an unparseable body. -- [ ] 2.4 Add `api/refusal.ts`: parse `{ restoreRules: false, reason, message, - upgradeUrl }`, strip C0/C1 control characters except newline from +- [x] 2.4 Add `api/refusal.ts`: parse `{ restoreRules: false, reason, message, +upgradeUrl }`, strip C0/C1 control characters except newline from `message`, keep `upgradeUrl` only as absolute `https:`. Tests cover an unknown `reason`, an ANSI escape in `message`, and a relative URL. -- [ ] 2.5 Rewrite `api/entitlement.ts` for v2: `withheld` is - `{ ruleId, revisionId }[]`, matched by `ruleId`; no entry is dropped for - lacking `file`. Tests include the #403 hazard: a v2 withheld list parses +- [x] 2.5 Add `parseEntitlementV2` beside the v1 parser in + `api/entitlement.ts` (v1 is deleted in 8.1 with its last caller): + `withheld` is `{ ruleId, revisionId }[]`, matched by `ruleId`; no entry is + dropped for lacking `file`. Tests include the #403 hazard: a v2 withheld list parses to the same number of entries it arrived with. ## 3. Generation on v2 (slice 2) @@ -154,8 +156,8 @@ branch targeting the one below; the stack merges down. ## 8. Retire v1 (slice 5) -- [ ] 8.1 Delete `api/rules.ts`, `api/reconcile.ts`, `api/restore.ts`, and every - v1 type use; move `auth/whoami.ts` and `auth/org.ts` to v2 whoami. Verify +- [ ] 8.1 Delete `api/rules.ts`, `api/reconcile.ts`, `api/restore.ts`, the + frozen v1 `api.schema.json` / `api.d.ts`, and every v1 type use; move `auth/whoami.ts` and `auth/org.ts` to v2 whoami. Verify `grep -rn "/cli/api/" packages/cli/src` finds only `/cli/api/v2/` paths. - [ ] 8.2 Add a vite build check (per the code style guide, not a test that scans output) that fails the build if the bundle contains a `/cli/api/` @@ -181,5 +183,5 @@ branch targeting the one below; the stack merges down. directory-swap gap, the superseded-revision signal, and the v1 `Entitlement` type on served file sets. - [ ] 9.4 Archive the change on the tip branch (`pnpm openspec archive - cli-v2-rule-api`), then re-run the scenario-survival check from 1.1 +cli-v2-rule-api`), then re-run the scenario-survival check from 1.1 against the archived specs. diff --git a/packages/cli/package.json b/packages/cli/package.json index 58d19d59..d98470a7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,7 +14,7 @@ "demo:manifests": "tsx scripts/print-demo-manifests.ts", "generate:api": "pnpm generate:api:schema && pnpm generate:api:types", "generate:api:schema": "tsx scripts/fetch-api-schema.ts", - "generate:api:types": "openapi-typescript src/generated/api.schema.json -o src/generated/api.d.ts", + "generate:api:types": "openapi-typescript src/generated/api-v2.schema.json -o src/generated/api-v2.d.ts", "generate:ast-grep-schema": "tsx scripts/fetch-ast-grep-schema.ts", "generate:rule-hash-vectors": "tsx scripts/fetch-rule-hash-vectors.ts", "generate:vale-schema": "tsx scripts/generate-vale-schema.ts", diff --git a/packages/cli/scripts/fetch-api-schema.ts b/packages/cli/scripts/fetch-api-schema.ts index 59fc9a62..234263f8 100644 --- a/packages/cli/scripts/fetch-api-schema.ts +++ b/packages/cli/scripts/fetch-api-schema.ts @@ -37,11 +37,17 @@ import { apiBaseUrl, packageFile, writeJsonArtifact } from "./artifacts"; * "no API changes" when what actually happened was "no API reached". */ -const OUTPUT_PATH = packageFile("src", "generated", "api.schema.json"); +/** + * The v2 document only. The server versions its API in the path, and v2 has + * its own `__schema` listing nothing but v2 routes. The v1 `api.schema.json` + * beside it is frozen: nothing refetches it, and it is deleted with its last + * caller at the tip of the `cli-v2-rule-api` stack. + */ +const OUTPUT_PATH = packageFile("src", "generated", "api-v2.schema.json"); // `__schema` is unauthenticated. `apiBaseUrl` documents the one tier of the // runtime client's origin resolution these scripts skip. -const sourceUrl = `${apiBaseUrl()}/cli/api/__schema`; +const sourceUrl = `${apiBaseUrl()}/cli/api/v2/__schema`; console.log(`Fetching the Taskless CLI API schema...`); console.log(` URL: ${sourceUrl}`); diff --git a/packages/cli/scripts/fetch-rule-hash-vectors.ts b/packages/cli/scripts/fetch-rule-hash-vectors.ts index d5c96fd2..b11424b5 100644 --- a/packages/cli/scripts/fetch-rule-hash-vectors.ts +++ b/packages/cli/scripts/fetch-rule-hash-vectors.ts @@ -34,7 +34,7 @@ function toAsciiJson(value: unknown): string { // The vectors endpoint is unauthenticated and lives under /cli/api/. // `apiBaseUrl` documents the one tier of the runtime client's origin // resolution these scripts skip. -const sourceUrl = `${apiBaseUrl()}/cli/api/rule-hash-vectors`; +const sourceUrl = `${apiBaseUrl()}/cli/api/v2/rule-hash-vectors`; console.log(`Fetching rule-hash conformance vectors...`); console.log(` URL: ${sourceUrl}`); diff --git a/packages/cli/src/api/entitlement.ts b/packages/cli/src/api/entitlement.ts index e8164253..655d8445 100644 --- a/packages/cli/src/api/entitlement.ts +++ b/packages/cli/src/api/entitlement.ts @@ -49,7 +49,7 @@ function isRecord(value: unknown): value is Record { * An upgrade URL is printed as a link the user is invited to follow, so a value * that is not an absolute `https:` URL is dropped rather than shown. */ -function parseUpgradeUrl(value: unknown): string | undefined { +export function parseUpgradeUrl(value: unknown): string | undefined { if (typeof value !== "string") return undefined; try { return new URL(value).protocol === "https:" ? value : undefined; @@ -90,12 +90,68 @@ export function parseEntitlement(value: unknown): Entitlement | undefined { }; } +/** A runtime rule the v2 service withheld for entitlement, not for tampering. */ +export interface WithheldRule { + ruleId: string; + /** The revision it matched exactly. Kept for reporting; never a join key. */ + revisionId?: string; +} + +/** A v2 entitlement outcome. As with v1, only the unentitled case is represented. */ +export interface EntitlementV2 { + runtimeSignatures: false; + reason?: string; + /** Present only when it parsed as an absolute `https:` URL. */ + upgradeUrl?: string; + /** Always an array; empty on served file sets, which never carry it. */ + withheld: WithheldRule[]; +} + +/** + * Normalize an untrusted v2 `entitlement` field. + * + * v2 names withheld RULES, `{ ruleId, revisionId }`, where v1 named files. The + * v1 {@link parseEntitlement} drops any entry without a string `file`, so + * handing it a v2 body drops every withheld rule, `withheld` comes back empty, + * and `check` passes on a plan that ran nothing (taskless/cli#403). This parser + * keys on `ruleId` alone and keeps an entry whatever else it lacks. An entry + * with no `ruleId` cannot be joined to anything reported and is dropped here, + * which is safe only because `check` separately fails any reported rule the + * response did not account for. + */ +export function parseEntitlementV2(value: unknown): EntitlementV2 | undefined { + if (!isRecord(value) || value.runtimeSignatures !== false) return undefined; + + const withheld: WithheldRule[] = []; + if (Array.isArray(value.withheld)) { + for (const entry of value.withheld) { + if (!isRecord(entry) || typeof entry.ruleId !== "string") continue; + withheld.push({ + ruleId: entry.ruleId, + ...(typeof entry.revisionId === "string" + ? { revisionId: entry.revisionId } + : {}), + }); + } + } + + const upgradeUrl = parseUpgradeUrl(value.upgradeUrl); + return { + runtimeSignatures: false, + ...(typeof value.reason === "string" ? { reason: value.reason } : {}), + ...(upgradeUrl === undefined ? {} : { upgradeUrl }), + withheld, + }; +} + /** * Why a runtime rule just written will not run. Shared by `rule create`, * `rule improve`, and restore, which each name the rule their own way, so the * explanation cannot drift between them. */ -export function notRunOnPlanSentence(entitlement: Entitlement): string { +export function notRunOnPlanSentence( + entitlement: Pick +): string { // The URL ends the sentence with no trailing period, so copying it from a // terminal does not copy a `.` into the address. return ( diff --git a/packages/cli/src/api/refusal.ts b/packages/cli/src/api/refusal.ts new file mode 100644 index 00000000..f3647ad3 --- /dev/null +++ b/packages/cli/src/api/refusal.ts @@ -0,0 +1,68 @@ +import { parseUpgradeUrl } from "./entitlement"; + +/** + * A plan refusal from a v2 recovery route: restore, rollback, or fetching a + * revision other than a rule's head. + * + * The service answers these with a `200`, not an error, because the request + * was understood and the rule exists (a nonexistent rule is still a `404`). + * The plan simply does not include recovering it, and `message` says how to + * recover it from git instead. That makes a refusal an answer to relay, never + * a service failure to report: calling it "unavailable" would send the user to + * retry something that will be refused identically every time. + */ +export interface Refusal { + /** The machine-readable cause, e.g. `RESTORE_RULES_NOT_IN_PLAN`. */ + reason: string; + /** Server-authored guidance, already stripped of control characters. */ + message: string; + /** Present only when it parsed as an absolute `https:` URL. */ + upgradeUrl?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Remove C0 and C1 control characters, and DEL, except newline. + * + * `message` is written by the service to be printed verbatim, and it is + * printed to a terminal. A terminal interprets an escape sequence rather than + * showing it, so bytes from across the network could move the cursor, rewrite + * lines already printed, or retitle the window. The service has no reason to + * send any of that, which is why removing it loses nothing. + */ +export function stripControlCharacters(text: string): string { + // eslint-disable-next-line no-control-regex -- matching control characters is the point + return text.replaceAll(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/g, ""); +} + +/** + * Read a refusal from an untrusted response body. + * + * Returns `undefined` unless `restoreRules` is exactly `false`, which is the + * discriminator the service documents. A refusal whose `reason` the CLI does + * not recognize is still a refusal: the service answered, and relaying its + * message is right whatever the code says. A refusal missing its `message` + * gets a generic one rather than being dropped, for the same reason. + */ +export function parseRefusal(value: unknown): Refusal | undefined { + if (!isRecord(value) || value.restoreRules !== false) return undefined; + + const reason = + typeof value.reason === "string" && value.reason !== "" + ? value.reason + : "UNSPECIFIED"; + const message = + typeof value.message === "string" && value.message.trim() !== "" + ? stripControlCharacters(value.message) + : `The rule service declined this request for your organization's plan (${reason}).`; + const upgradeUrl = parseUpgradeUrl(value.upgradeUrl); + + return { + reason, + message, + ...(upgradeUrl === undefined ? {} : { upgradeUrl }), + }; +} diff --git a/packages/cli/src/api/v2.ts b/packages/cli/src/api/v2.ts new file mode 100644 index 00000000..7e4278dd --- /dev/null +++ b/packages/cli/src/api/v2.ts @@ -0,0 +1,455 @@ +import createClient from "openapi-fetch"; + +import type { paths } from "../generated/api-v2"; +import { getApiBaseUrl } from "./config"; +import { parseRefusal, type Refusal } from "./refusal"; +import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; + +/** + * The Taskless v2 rule API (taskless/taskless#229), typed from the vendored + * `api-v2.schema.json`. + * + * Every call returns a {@link V2Outcome} and none throws for a condition the + * service documents. That is the contract the v1 reconcile and restore clients + * kept by staying on raw `fetch`; here it is kept by mapping the typed error + * union to values in one place, so a caller branches on a code instead of + * parsing a message. The version header is set on the client, so no call can + * forget it: the service's version floor reads it, and so does the generator + * when it decides whether to produce runtime rules for this client. + */ + +type Method = "get" | "post"; + +type Operation

= NonNullable< + paths[P][M] +>; + +type Responses

= + Operation extends { responses: infer R } ? R : never; + +type JsonOf = R extends { content: { "application/json": infer B } } + ? B + : never; + +/** The `200` body an operation documents. */ +export type OkBody

= JsonOf< + Responses[200 & keyof Responses] +>; + +type CodeOf = B extends { error: infer E extends string } ? E : never; + +/** + * Every documented `error` code of an operation, except `unauthorized`, which + * every operation shares and which gets an outcome of its own. + */ +export type ErrorCode

= Exclude< + CodeOf[Exclude, 200>]>>, + "unauthorized" +>; + +/** + * The result of a v2 call. + * + * - `ok`: the documented `200` body. + * - `refused`: a recovery route answered `200` with a plan refusal. Only + * restore, rollback, and rule fetch can produce it. + * - `error`: a documented error code. `details` is carried for + * `validation_error`, the one code the service explains. + * - `unauthorized`: `401`. Its own outcome, because every caller answers it the + * same way (log in again) and it is never a verdict about the request. + * - `unavailable`: anything the schema does not describe: a network failure, an + * undocumented status or code, or a body that is not what it says it is. + */ +export type V2Outcome = + | { status: "ok"; data: T } + | { status: "refused"; refusal: Refusal } + | { status: "error"; code: C; httpStatus: number; details?: string[] } + | { status: "unauthorized" } + | { status: "unavailable"; reason: string }; + +/** + * A list of an operation's error codes, checked for completeness at compile + * time. The types are erased at runtime, so the codes a call recognizes have to + * exist as values; this keeps that list from quietly falling behind the schema + * when a refresh adds a code. A missing code is a type error here, not an + * `unavailable` at 2am. + */ +function errorCodes() { + return ( + codes: A & ([C] extends [A[number]] ? unknown : "missing a documented code") + ): readonly C[] => codes; +} + +/** Create the v2 client. Exported for tests that assert on the wire. */ +export function createV2Client(token: string) { + // Schema paths include the /cli/ prefix, so the base URL is the origin. + const baseUrl = getApiBaseUrl().replace(/\/cli\/?$/, ""); + return createClient({ + baseUrl, + headers: { + Authorization: `Bearer ${token}`, + [CLI_VERSION_HEADER]: CLI_VERSION, + }, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +type Fetched = { data?: unknown; error?: unknown; response: Response }; + +/** + * Turn an `openapi-fetch` result into an outcome. + * + * The thrown cases are the transport's: a network failure, and a `200` whose + * body is not JSON (openapi-fetch parses a success body and throws on garbage, + * where it hands an error body back as text). Both are `unavailable`. + */ +async function settle( + call: () => Promise, + codes: readonly C[], + accept: (data: unknown) => V2Outcome +): Promise> { + let fetched: Fetched; + try { + fetched = await call(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { status: "unavailable", reason: `network error: ${message}` }; + } + + const { response } = fetched; + if (response.status === 401) return { status: "unauthorized" }; + if (response.ok) return accept(fetched.data); + + const body = fetched.error; + const code = isRecord(body) ? body.error : undefined; + if (typeof code === "string" && (codes as readonly string[]).includes(code)) { + const details = + isRecord(body) && Array.isArray(body.details) + ? body.details.filter( + (detail): detail is string => typeof detail === "string" + ) + : undefined; + return { + status: "error", + code: code as C, + httpStatus: response.status, + ...(details === undefined ? {} : { details }), + }; + } + return { + status: "unavailable", + reason: + typeof code === "string" + ? `HTTP ${String(response.status)} (${code})` + : `HTTP ${String(response.status)}`, + }; +} + +/** Accept any object body as the documented shape. */ +function acceptObject(data: unknown): V2Outcome { + if (!isRecord(data)) { + return { status: "unavailable", reason: "invalid response body" }; + } + return { status: "ok", data: data as T }; +} + +// --- Served rules: fetch, restore, rollback --- + +type RestoreOk = OkBody<"/cli/api/v2/rule/{ruleId}/restore", "post">; + +/** A served rule: the `200` body of fetch, restore, or rollback, minus the refusal. */ +export type ServedRule = Omit< + Extract, + "restoreRules" +>; + +/** One engine's file set within a {@link ServedRule}. */ +export type ServedFileSet = ServedRule["rules"][number]; + +/** + * Split a recovery route's `200` into a served rule or a refusal. + * + * Restore and rollback mark both with `restoreRules`. A fetch marks only the + * refusal, so the served shape is recognized by carrying `rules` instead. A + * body that is neither is `unavailable`, never an empty success. + */ +function acceptServed( + data: unknown +): V2Outcome { + const refusal = parseRefusal(data); + if (refusal !== undefined) return { status: "refused", refusal }; + if ( + !isRecord(data) || + !Array.isArray(data.rules) || + typeof data.ruleId !== "string" || + typeof data.revisionId !== "string" + ) { + return { + status: "unavailable", + reason: "the response carried neither a rule nor a refusal", + }; + } + const { restoreRules: _marker, ...served } = data; + return { status: "ok", data: served as unknown as ServedRule }; +} + +export type FetchRuleCode = ErrorCode<"/cli/api/v2/rule/{ruleId}", "get">; + +const FETCH_RULE_CODES = errorCodes< + ErrorCode<"/cli/api/v2/rule/{ruleId}", "get"> +>()([ + "validation_error", + "organization_not_found", + "rule_not_found", + "revision_not_found", + "rule_not_restorable", +]); + +/** + * Fetch a rule's file set: its head by default, or `revision`. + * + * Omit `revision` for a rule a request just produced. The head is served on + * every plan, while naming a revision, even the head's own, is a recovery read + * that a plan without `restoreRules` is refused. + */ +export function fetchRule( + token: string, + ruleId: string, + query: { repositoryUrl: string; orgId?: string | number; revision?: string } +): Promise> { + const client = createV2Client(token); + return settle( + () => + client.GET("/cli/api/v2/rule/{ruleId}", { + params: { + path: { ruleId }, + query: { + repositoryUrl: query.repositoryUrl, + ...(query.orgId === undefined + ? {} + : { orgId: String(query.orgId) }), + ...(query.revision === undefined + ? {} + : { revision: query.revision }), + }, + }, + }), + FETCH_RULE_CODES, + acceptServed + ); +} + +export type RestoreCode = ErrorCode< + "/cli/api/v2/rule/{ruleId}/restore", + "post" +>; + +const RESTORE_CODES = errorCodes< + ErrorCode<"/cli/api/v2/rule/{ruleId}/restore", "post"> +>()([ + "validation_error", + "organization_not_found", + "rule_not_found", + "rule_not_restorable", +]); + +/** Ask for a rule's current revision. Never called by `check`. */ +export function restoreRule( + token: string, + ruleId: string, + body: { repositoryUrl: string; orgId?: string | number } +): Promise> { + const client = createV2Client(token); + return settle( + () => + client.POST("/cli/api/v2/rule/{ruleId}/restore", { + params: { path: { ruleId } }, + body, + }), + RESTORE_CODES, + acceptServed + ); +} + +export type RollbackCode = ErrorCode< + "/cli/api/v2/rule/{ruleId}/rollback", + "post" +>; + +const ROLLBACK_CODES = errorCodes< + ErrorCode<"/cli/api/v2/rule/{ruleId}/rollback", "post"> +>()([ + "validation_error", + "organization_not_found", + "rule_not_found", + "revision_not_found", + "rule_not_restorable", +]); + +/** Make an earlier revision current and receive its files. */ +export function rollbackRule( + token: string, + ruleId: string, + body: { repositoryUrl: string; revisionId: string; orgId?: string | number } +): Promise> { + const client = createV2Client(token); + return settle( + () => + client.POST("/cli/api/v2/rule/{ruleId}/rollback", { + params: { path: { ruleId } }, + body, + }), + ROLLBACK_CODES, + acceptServed + ); +} + +// --- Generation: request, poll, iterate --- + +export type RequestBody = NonNullable< + Operation<"/cli/api/v2/request", "post">["requestBody"] +>["content"]["application/json"]; + +export type RequestAccepted = OkBody<"/cli/api/v2/request", "post">; + +export type RequestCode = ErrorCode<"/cli/api/v2/request", "post">; + +const REQUEST_CODES = errorCodes>()([ + "validation_error", + "organization_not_found", + "enqueue_failed", +]); + +/** Create a generation request. */ +export function submitRequest( + token: string, + body: RequestBody +): Promise> { + const client = createV2Client(token); + return settle( + () => client.POST("/cli/api/v2/request", { body }), + REQUEST_CODES, + acceptObject + ); +} + +export type RequestStatus = OkBody<"/cli/api/v2/request/{requestId}", "get">; + +export type RequestStatusCode = ErrorCode< + "/cli/api/v2/request/{requestId}", + "get" +>; + +const REQUEST_STATUS_CODES = errorCodes< + ErrorCode<"/cli/api/v2/request/{requestId}", "get"> +>()(["validation_error", "organization_not_found", "request_not_found"]); + +/** Poll a generation request. Returns rule ids and revisions, never content. */ +export function getRequestStatus( + token: string, + requestId: string, + query: { repositoryUrl: string; orgId?: string | number } +): Promise> { + const client = createV2Client(token); + return settle( + () => + client.GET("/cli/api/v2/request/{requestId}", { + params: { + path: { requestId }, + query: { + repositoryUrl: query.repositoryUrl, + ...(query.orgId === undefined + ? {} + : { orgId: String(query.orgId) }), + }, + }, + }), + REQUEST_STATUS_CODES, + acceptObject + ); +} + +export type IterateBody = NonNullable< + Operation<"/cli/api/v2/rule/{ruleId}/iterate", "post">["requestBody"] +>["content"]["application/json"]; + +export type IterateCode = ErrorCode< + "/cli/api/v2/rule/{ruleId}/iterate", + "post" +>; + +const ITERATE_CODES = errorCodes< + ErrorCode<"/cli/api/v2/rule/{ruleId}/iterate", "post"> +>()([ + "validation_error", + "organization_not_found", + "rule_not_found", + "enqueue_failed", +]); + +/** Ask for a new revision of a rule. Creates a new request to poll. */ +export function iterateRule( + token: string, + ruleId: string, + body: IterateBody +): Promise> { + const client = createV2Client(token); + return settle( + () => + client.POST("/cli/api/v2/rule/{ruleId}/iterate", { + params: { path: { ruleId } }, + body, + }), + ITERATE_CODES, + acceptObject + ); +} + +// --- Reconcile and identity --- + +export type ReconcileBody = NonNullable< + Operation<"/cli/api/v2/reconcile", "post">["requestBody"] +>["content"]["application/json"]; + +export type ReconcileResult = OkBody<"/cli/api/v2/reconcile", "post">; + +export type ReconcileCode = ErrorCode<"/cli/api/v2/reconcile", "post">; + +const RECONCILE_CODES = errorCodes< + ErrorCode<"/cli/api/v2/reconcile", "post"> +>()(["validation_error", "organization_not_found"]); + +/** + * Report every rule the client holds and receive a verdict for each. + * + * The body is returned as the service sent it, checked only for being an + * object. Turning it into dispositions, including the accounting that fails a + * reported rule the response never mentions, belongs to the caller: a + * shape-check here would be a second, weaker version of that accounting. + */ +export function reconcileRules( + token: string, + body: ReconcileBody +): Promise> { + const client = createV2Client(token); + return settle( + () => client.POST("/cli/api/v2/reconcile", { body }), + RECONCILE_CODES, + acceptObject + ); +} + +export type WhoamiResult = OkBody<"/cli/api/v2/whoami", "get">; + +/** Identity and organizations for the token. */ +export function whoami(token: string): Promise> { + const client = createV2Client(token); + return settle( + () => client.GET("/cli/api/v2/whoami"), + [] as readonly never[], + acceptObject + ); +} diff --git a/packages/cli/src/generated/api-v2.d.ts b/packages/cli/src/generated/api-v2.d.ts new file mode 100644 index 00000000..51f17e78 --- /dev/null +++ b/packages/cli/src/generated/api-v2.d.ts @@ -0,0 +1,1377 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/cli/api/v2/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Returns authenticated user identity and organizations */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Display name */ + user: string; + /** @description Email address */ + email?: string; + orgs: { + /** @description GitHub org ID */ + orgId: number; + /** @description Taskless organization UUID */ + id: string; + /** @description Organization name */ + name: string; + /** + * @description Identity provider + * @constant + */ + source: "github"; + /** @description Canonical owner URL the client matches its repository against */ + url: 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"; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Judge each rule the client holds, as a whole: run only on an exact match with one issued revision + * @description Every reported rule is placed in exactly one of `rules`, `unknown`, or `entitlement.withheld`; a client must fail its check on any reported rule the response does not place. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description OK */ + requestBody?: { + content: { + "application/json": { + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string | number; + /** @description Full repository URL */ + repositoryUrl: string; + /** @description Every rule directory the client holds */ + rules: { + /** @description The rule directory name */ + ruleId: string; + /** @description Every file of the rule directory except its test fixtures, with the signature the client computed for its local copy */ + files: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + }[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description A verdict for each reported rule that is not unknown or withheld, and each missing rule */ + rules: ( + | { + ruleId: string; + /** + * @description The rule’s engine + * @enum {string} + */ + engine: "sg" | "vale" | "runtime"; + /** + * @description The reported files exactly equal one issued revision + * @constant + */ + verdict: "run"; + /** @description The revision they equal */ + revisionId: string; + } + | { + ruleId: string; + /** + * @description The rule’s engine + * @enum {string} + */ + engine: "sg" | "vale" | "runtime"; + /** + * @description The reported files equal no issued revision. Never run it; offer restore. For sg and vale, fail the check. + * @constant + */ + verdict: "unsafe"; + /** @description The diff against the current (or newest) revision: changed, removed, and added files */ + files: { + path: string; + /** @description The issued signature, when the file was issued */ + expected?: string; + /** @description The reported signature, when the file was reported */ + got?: string; + }[]; + } + | { + ruleId: string; + /** + * @description The rule’s engine + * @enum {string} + */ + engine: "sg" | "vale" | "runtime"; + /** + * @description Issued and current, but not reported. Warn. + * @constant + */ + verdict: "missing"; + /** @description The current revision to restore */ + revisionId: string; + } + )[]; + /** @description Reported rules that are not rules of this repository (locally written, or issued before rule storage) */ + unknown: { + ruleId: string; + }[]; + entitlement: components["schemas"]["EntitlementV2"]; + }; + }; + }; + /** @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'. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "organization_not_found"; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/request": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request a new rule + * @description A request the organization's plan does not permit is not refused with an HTTP error: it is accepted, and polling returns status `unsupported` with its reason. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description OK */ + requestBody?: { + content: { + "application/json": { + /** @description Full repository URL */ + repositoryUrl: string; + /** @description Description of the rule to generate */ + prompt: string; + /** @description Examples that should PASS the rule (optional). Each entry is either a code string (one file, named for you) or a case — `{ name?, files: [{ path, content }] }` — whose files are written into one directory, which is how an example spans more than one file */ + successCases?: ( + | string + | { + /** @description The case's directory name, e.g. `undeclared`. Generated positionally when absent */ + name?: string; + /** @description The files in this case */ + files: { + /** @description Path relative to the case root, e.g. `src/config.ts`. Required: a file in a multi-file case is identified by where it sits */ + path: string; + /** @description The file contents */ + content: string; + }[]; + } + )[]; + /** @description Examples that should FAIL the rule (optional). Same shape as `successCases`: a code string, or a case of files. A case spanning files is what a `runtime` rule is for — the read in `src/config.ts` judged against the `.env` beside it */ + failureCases?: ( + | string + | { + /** @description The case's directory name, e.g. `undeclared`. Generated positionally when absent */ + name?: string; + /** @description The files in this case */ + files: { + /** @description Path relative to the case root, e.g. `src/config.ts`. Required: a file in a multi-file case is identified by where it sits */ + path: string; + /** @description The file contents */ + content: string; + }[]; + } + )[]; + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string | number; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description UUID of the generation request, for polling GET /cli/api/v2/request/{requestId} */ + requestId: string; + /** + * @description Initial status + * @constant + */ + status: "accepted"; + }; + }; + }; + /** @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'. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "organization_not_found"; + }; + }; + }; + /** @description `enqueue_failed`: The request was recorded but could not be queued, and polls as `failed`. Retryable: send the request again. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "enqueue_failed"; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/request/{requestId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A request’s status and the rule revisions it produced (poll until terminal) */ + get: { + parameters: { + query: { + /** @description Full repository URL the request was for */ + 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 UUID of the generation request. Poll it until terminal, then fetch each rule it produced by its rule id. */ + requestId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + requestId: string; + /** @enum {string} */ + status: + | "accepted" + | "classifying" + | "building" + | "generated" + | "failed" + | "pr" + | "merged" + | "closed" + | "unsupported"; + /** @description The rule revisions this request produced. Fetch each with GET /cli/api/v2/rule/{ruleId}?revision={revisionId}, which serves them on every plan while they are the head. */ + revisions: { + /** @description The rule id: fetch it by this */ + ruleId: string; + /** @description The revision this request produced */ + revisionId: string; + }[]; + /** @description Why the request ended the way it did (failed or unsupported). Print it rather than substituting your own. */ + error?: string; + /** @description Machine-readable code for error */ + errorCode?: string; + }; + }; + }; + /** @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'. + * + * `request_not_found`: Not a request of this repository. + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "organization_not_found" | "request_not_found"; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/rule/{ruleId}/iterate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request a new revision of a rule; creates a new request targeting it */ + post: { + parameters: { + query?: never; + 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; + }; + /** @description OK */ + requestBody?: { + content: { + "application/json": { + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string | number; + /** @description Full repository URL the rule belongs to */ + repositoryUrl: string; + /** @description What to change about the rule */ + guidance: string; + /** @description Reference files to include as context (optional) */ + references?: { + /** @description File path relative to .taskless/ */ + filename: string; + /** @description File content */ + content: string; + }[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description UUID of the NEW request this iteration created, for polling */ + requestId: string; + /** @constant */ + status: "accepted"; + }; + }; + }; + /** @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"; + }; + }; + }; + /** @description `enqueue_failed`: The request was recorded but could not be queued, and polls as `failed`. Retryable: send the request again. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "enqueue_failed"; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/rule/{ruleId}/rollback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Make an earlier revision current now and return its files; a 200 refusal when the plan lacks restoreRules */ + post: { + parameters: { + query?: never; + 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; + }; + /** @description OK */ + requestBody?: { + content: { + "application/json": { + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string | number; + /** @description Full repository URL the rule belongs to */ + repositoryUrl: string; + /** @description The revision of this rule to make current again */ + revisionId: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | { + /** @description The rule id: its directory name, stable for life */ + ruleId: string; + /** @description The revision these bytes are */ + revisionId: string; + /** @description Exactly one file set: the requested rule, never its siblings */ + rules: ( + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description ast-grep — inert declarative rules + * @constant + */ + engine: "sg"; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description Vale — inert prose/markup rules + * @constant + */ + engine: "vale"; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description Executable — check.ts runs against a file tree + * @constant + */ + engine: "runtime"; + /** @description REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run. */ + signature: string; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + )[]; + entitlement?: components["schemas"]["Entitlement"]; + /** + * @description Rolled back: write this file set in place of the rule directory + * @constant + */ + restoreRules: true; + } + | { + /** @constant */ + restoreRules: false; + /** @constant */ + reason: "RESTORE_RULES_NOT_IN_PLAN"; + /** @description Print verbatim: names the plan, says the rule is in the repository’s git history, and links the pull request that delivered it when one is recorded. */ + message: string; + /** @description The absolute URL of the organization's upgrade page */ + upgradeUrl: string; + }; + }; + }; + /** @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. + * + * `revision_not_found`: Not a revision of this rule. + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: + | "organization_not_found" + | "rule_not_found" + | "revision_not_found"; + }; + }; + }; + /** @description `rule_not_restorable`: A stored revision cannot be served as a file set; `details` says why. Should not happen: report it. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "rule_not_restorable"; + /** @description Human-readable reasons, when the code carries them */ + details?: string[]; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/rule/{ruleId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A rule by its id: its head on every plan, or an older revision with restoreRules */ + 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; + /** @description A revision of this rule to fetch instead of its head. Any revision other than the head requires the plan's restoreRules entitlement. */ + revision?: 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 id: its directory name, stable for life */ + ruleId: string; + /** @description The revision these bytes are */ + revisionId: string; + /** @description Exactly one file set: the requested rule, never its siblings */ + rules: ( + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description ast-grep — inert declarative rules + * @constant + */ + engine: "sg"; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description Vale — inert prose/markup rules + * @constant + */ + engine: "vale"; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description Executable — check.ts runs against a file tree + * @constant + */ + engine: "runtime"; + /** @description REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run. */ + signature: string; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + )[]; + entitlement?: components["schemas"]["Entitlement"]; + } + | { + /** @constant */ + restoreRules: false; + /** @constant */ + reason: "RESTORE_RULES_NOT_IN_PLAN"; + /** @description Print verbatim: names the plan, says the rule is in the repository’s git history, and links the pull request that delivered it when one is recorded. */ + message: string; + /** @description The absolute URL of the organization's upgrade page */ + upgradeUrl: string; + }; + }; + }; + /** @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. + * + * `revision_not_found`: Not a revision of this rule. + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: + | "organization_not_found" + | "rule_not_found" + | "revision_not_found"; + }; + }; + }; + /** @description `rule_not_restorable`: A stored revision cannot be served as a file set; `details` says why. Should not happen: report it. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "rule_not_restorable"; + /** @description Human-readable reasons, when the code carries them */ + details?: string[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/rule/{ruleId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a rule’s current revision by its id; a 200 refusal when the plan lacks restoreRules */ + post: { + parameters: { + query?: never; + 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; + }; + /** @description OK */ + requestBody?: { + content: { + "application/json": { + /** @description Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim */ + orgId?: string | number; + /** @description Full repository URL the rule belongs to */ + repositoryUrl: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | { + /** @description The rule id: its directory name, stable for life */ + ruleId: string; + /** @description The revision these bytes are */ + revisionId: string; + /** @description Exactly one file set: the requested rule, never its siblings */ + rules: ( + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description ast-grep — inert declarative rules + * @constant + */ + engine: "sg"; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description Vale — inert prose/markup rules + * @constant + */ + engine: "vale"; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + | { + /** @description The rule directory name under .taskless/rules// */ + id: string; + /** @description Every file the rule directory must contain */ + files: { + /** @description Path relative to .taskless/rules/// */ + path: string; + /** @description The file’s exact bytes */ + content: string; + }[]; + /** + * @description Executable — check.ts runs against a file tree + * @constant + */ + engine: "runtime"; + /** @description REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run. */ + signature: string; + /** @description REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile. */ + signatures: { + /** @description Path relative to the rule directory */ + path: string; + /** @description Canonical signature of the file (`1;h=sha-256;d=`) */ + signature: string; + }[]; + } + )[]; + entitlement?: components["schemas"]["Entitlement"]; + /** + * @description The rule was restored: its file set follows + * @constant + */ + restoreRules: true; + } + | { + /** @constant */ + restoreRules: false; + /** @constant */ + reason: "RESTORE_RULES_NOT_IN_PLAN"; + /** @description Print verbatim: names the plan, says the rule is in the repository’s git history, and links the pull request that delivered it when one is recorded. */ + message: string; + /** @description The absolute URL of the organization's upgrade page */ + upgradeUrl: string; + }; + }; + }; + /** @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"; + }; + }; + }; + /** @description `rule_not_restorable`: A stored revision cannot be served as a file set; `details` says why. Should not happen: report it. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Machine-readable error code + * @enum {string} + */ + error: "rule_not_restorable"; + /** @description Human-readable reasons, when the code carries them */ + details?: string[]; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/api/v2/rule-hash-vectors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public: canonical rule-hash conformance vectors for cross-repo validation */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Canonical conformance vectors every implementation must reproduce */ + vectors: { + /** @description Human-readable case name, e.g. "crlf-equals-lf" */ + name: string; + /** @description Raw rule text to hash */ + input: string; + /** @description Canonical signature envelope: ;h=;d= */ + signature: string; + }[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description The organization's entitlement to have runtime rules blessed. Additive: the rest of the response means what it did without it. Always present on reconcile; on restore and request retrieval, present whenever the response carries a runtime file set. */ + Entitlement: { + /** @description Whether the organization's plan includes runtime signatures. When false, no runtime rule is placed in `run`. */ + runtimeSignatures: boolean; + /** + * @description Present iff `runtimeSignatures` is false: the plan does not include runtime signatures. + * @constant + */ + reason?: "RUNTIME_SIGNATURES_NOT_IN_PLAN"; + /** @description Present iff `runtimeSignatures` is false: the absolute URL of the organization's upgrade page. */ + upgradeUrl?: string; + /** @description Reconcile only, present iff `runtimeSignatures` is false: reported runtime files whose content matches the issued rule but that were not placed in `run`, `unsafe`, `unknown`, or `missing`. A client should report these as not run because of the plan, and fail. */ + withheld?: { + /** @description The model-assigned rule identifier */ + ruleId: string; + /** @description Delivered rule filename the client reported */ + file: string; + }[]; + }; + EntitlementV2: { + runtimeSignatures: components["schemas"]["Entitlement"]["runtimeSignatures"]; + reason?: components["schemas"]["Entitlement"]["reason"]; + upgradeUrl?: components["schemas"]["Entitlement"]["upgradeUrl"]; + /** @description Reconcile only, present iff `runtimeSignatures` is false: reported runtime rules that exactly match an issued revision but are not run on this plan. A client reports these as not run because of the plan, and fails. */ + withheld?: { + /** @description The rule id (its directory name) */ + ruleId: string; + /** @description The revision it matched exactly */ + revisionId: string; + }[]; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/cli/src/generated/api-v2.schema.json b/packages/cli/src/generated/api-v2.schema.json new file mode 100644 index 00000000..54b66ea1 --- /dev/null +++ b/packages/cli/src/generated/api-v2.schema.json @@ -0,0 +1,2310 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Taskless CLI API v2", + "version": "2.0.0-private", + "description": "The v2 CLI API: rules addressed by their own id. Authentication (/cli/auth/*) is shared with every version and not listed here." + }, + "components": { + "schemas": { + "Entitlement": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "runtimeSignatures": { + "type": "boolean", + "description": "Whether the organization's plan includes runtime signatures. When false, no runtime rule is placed in `run`." + }, + "reason": { + "description": "Present iff `runtimeSignatures` is false: the plan does not include runtime signatures.", + "type": "string", + "const": "RUNTIME_SIGNATURES_NOT_IN_PLAN" + }, + "upgradeUrl": { + "description": "Present iff `runtimeSignatures` is false: the absolute URL of the organization's upgrade page.", + "type": "string" + }, + "withheld": { + "description": "Reconcile only, present iff `runtimeSignatures` is false: reported runtime files whose content matches the issued rule but that were not placed in `run`, `unsafe`, `unknown`, or `missing`. A client should report these as not run because of the plan, and fail.", + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The model-assigned rule identifier" + }, + "file": { + "type": "string", + "description": "Delivered rule filename the client reported" + } + }, + "required": ["ruleId", "file"], + "additionalProperties": false + } + } + }, + "required": ["runtimeSignatures"], + "additionalProperties": false, + "description": "The organization's entitlement to have runtime rules blessed. Additive: the rest of the response means what it did without it. Always present on reconcile; on restore and request retrieval, present whenever the response carries a runtime file set." + }, + "EntitlementV2": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "runtimeSignatures": { + "$ref": "#/components/schemas/Entitlement/properties/runtimeSignatures" + }, + "reason": { + "$ref": "#/components/schemas/Entitlement/properties/reason" + }, + "upgradeUrl": { + "$ref": "#/components/schemas/Entitlement/properties/upgradeUrl" + }, + "withheld": { + "description": "Reconcile only, present iff `runtimeSignatures` is false: reported runtime rules that exactly match an issued revision but are not run on this plan. A client reports these as not run because of the plan, and fails.", + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule id (its directory name)" + }, + "revisionId": { + "type": "string", + "description": "The revision it matched exactly" + } + }, + "required": ["ruleId", "revisionId"], + "additionalProperties": false + } + } + }, + "required": ["runtimeSignatures"], + "additionalProperties": false + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT issued via the device authorization flow (POST /cli/auth/token), shared by every API version." + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/cli/api/v2/whoami": { + "get": { + "summary": "Returns authenticated user identity and organizations", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "user": { + "type": "string", + "description": "Display name" + }, + "email": { + "description": "Email address", + "type": "string" + }, + "orgs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "orgId": { + "type": "number", + "description": "GitHub org ID" + }, + "id": { + "type": "string", + "description": "Taskless organization UUID" + }, + "name": { + "type": "string", + "description": "Organization name" + }, + "source": { + "type": "string", + "const": "github", + "description": "Identity provider" + }, + "url": { + "type": "string", + "description": "Canonical owner URL the client matches its repository against" + } + }, + "required": ["orgId", "id", "name", "source", "url"], + "additionalProperties": false + } + } + }, + "required": ["user", "orgs"], + "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 + } + } + } + } + } + } + }, + "/cli/api/v2/reconcile": { + "post": { + "summary": "Judge each rule the client holds, as a whole: run only on an exact match with one issued revision", + "description": "Every reported rule is placed in exactly one of `rules`, `unknown`, or `entitlement.withheld`; a client must fail its check on any reported rule the response does not place.", + "requestBody": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "orgId": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "repositoryUrl": { + "type": "string", + "description": "Full repository URL" + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule directory name" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "Every file of the rule directory except its test fixtures, with the signature the client computed for its local copy" + } + }, + "required": ["ruleId", "files"], + "additionalProperties": false + }, + "description": "Every rule directory the client holds" + } + }, + "required": ["repositoryUrl", "rules"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": ["sg", "vale", "runtime"], + "description": "The rule’s engine" + }, + "verdict": { + "type": "string", + "const": "run", + "description": "The reported files exactly equal one issued revision" + }, + "revisionId": { + "type": "string", + "description": "The revision they equal" + } + }, + "required": [ + "ruleId", + "engine", + "verdict", + "revisionId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": ["sg", "vale", "runtime"], + "description": "The rule’s engine" + }, + "verdict": { + "type": "string", + "const": "unsafe", + "description": "The reported files equal no issued revision. Never run it; offer restore. For sg and vale, fail the check." + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "expected": { + "description": "The issued signature, when the file was issued", + "type": "string" + }, + "got": { + "description": "The reported signature, when the file was reported", + "type": "string" + } + }, + "required": ["path"], + "additionalProperties": false + }, + "description": "The diff against the current (or newest) revision: changed, removed, and added files" + } + }, + "required": [ + "ruleId", + "engine", + "verdict", + "files" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": ["sg", "vale", "runtime"], + "description": "The rule’s engine" + }, + "verdict": { + "type": "string", + "const": "missing", + "description": "Issued and current, but not reported. Warn." + }, + "revisionId": { + "type": "string", + "description": "The current revision to restore" + } + }, + "required": [ + "ruleId", + "engine", + "verdict", + "revisionId" + ], + "additionalProperties": false + } + ] + }, + "description": "A verdict for each reported rule that is not unknown or withheld, and each missing rule" + }, + "unknown": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + } + }, + "required": ["ruleId"], + "additionalProperties": false + }, + "description": "Reported rules that are not rules of this repository (locally written, or issued before rule storage)" + }, + "entitlement": { + "$ref": "#/components/schemas/EntitlementV2" + } + }, + "required": ["rules", "unknown", "entitlement"], + "additionalProperties": false + } + } + } + }, + "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'.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["organization_not_found"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, + "/cli/api/v2/request": { + "post": { + "summary": "Request a new rule", + "description": "A request the organization's plan does not permit is not refused with an HTTP error: it is accepted, and polling returns status `unsupported` with its reason.", + "requestBody": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "repositoryUrl": { + "type": "string", + "description": "Full repository URL" + }, + "prompt": { + "type": "string", + "description": "Description of the rule to generate" + }, + "successCases": { + "description": "Examples that should PASS the rule (optional). Each entry is either a code string (one file, named for you) or a case — `{ name?, files: [{ path, content }] }` — whose files are written into one directory, which is how an example spans more than one file", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "name": { + "description": "The case's directory name, e.g. `undeclared`. Generated positionally when absent", + "type": "string" + }, + "files": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Path relative to the case root, e.g. `src/config.ts`. Required: a file in a multi-file case is identified by where it sits" + }, + "content": { + "type": "string", + "description": "The file contents" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "The files in this case" + } + }, + "required": ["files"], + "additionalProperties": false + } + ] + } + }, + "failureCases": { + "description": "Examples that should FAIL the rule (optional). Same shape as `successCases`: a code string, or a case of files. A case spanning files is what a `runtime` rule is for — the read in `src/config.ts` judged against the `.env` beside it", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "name": { + "description": "The case's directory name, e.g. `undeclared`. Generated positionally when absent", + "type": "string" + }, + "files": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Path relative to the case root, e.g. `src/config.ts`. Required: a file in a multi-file case is identified by where it sits" + }, + "content": { + "type": "string", + "description": "The file contents" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "The files in this case" + } + }, + "required": ["files"], + "additionalProperties": false + } + ] + } + }, + "orgId": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": ["repositoryUrl", "prompt"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "UUID of the generation request, for polling GET /cli/api/v2/request/{requestId}" + }, + "status": { + "type": "string", + "const": "accepted", + "description": "Initial status" + } + }, + "required": ["requestId", "status"], + "additionalProperties": false + } + } + } + }, + "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'.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["organization_not_found"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "502": { + "description": "`enqueue_failed`: The request was recorded but could not be queued, and polls as `failed`. Retryable: send the request again.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["enqueue_failed"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, + "/cli/api/v2/request/{requestId}": { + "get": { + "summary": "A request’s status and the rule revisions it produced (poll until terminal)", + "parameters": [ + { + "name": "requestId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the generation request. Poll it until terminal, then fetch each rule it produced by its rule id." + }, + { + "name": "repositoryUrl", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Full repository URL the request was for" + }, + "description": "Full repository URL the request was for" + }, + { + "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": { + "requestId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "classifying", + "building", + "generated", + "failed", + "pr", + "merged", + "closed", + "unsupported" + ] + }, + "revisions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule id: fetch it by this" + }, + "revisionId": { + "type": "string", + "description": "The revision this request produced" + } + }, + "required": ["ruleId", "revisionId"], + "additionalProperties": false + }, + "description": "The rule revisions this request produced. Fetch each with GET /cli/api/v2/rule/{ruleId}?revision={revisionId}, which serves them on every plan while they are the head." + }, + "error": { + "description": "Why the request ended the way it did (failed or unsupported). Print it rather than substituting your own.", + "type": "string" + }, + "errorCode": { + "description": "Machine-readable code for error", + "type": "string" + } + }, + "required": ["requestId", "status", "revisions"], + "additionalProperties": false + } + } + } + }, + "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`request_not_found`: Not a request of this repository.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["organization_not_found", "request_not_found"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, + "/cli/api/v2/rule/{ruleId}/iterate": { + "post": { + "summary": "Request a new revision of a rule; creates a new request targeting it", + "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}`." + } + ], + "requestBody": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "orgId": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "repositoryUrl": { + "type": "string", + "description": "Full repository URL the rule belongs to" + }, + "guidance": { + "type": "string", + "description": "What to change about the rule" + }, + "references": { + "description": "Reference files to include as context (optional)", + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "File path relative to .taskless/" + }, + "content": { + "type": "string", + "description": "File content" + } + }, + "required": ["filename", "content"], + "additionalProperties": false + } + } + }, + "required": ["repositoryUrl", "guidance"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "UUID of the NEW request this iteration created, for polling" + }, + "status": { + "type": "string", + "const": "accepted" + } + }, + "required": ["requestId", "status"], + "additionalProperties": false + } + } + } + }, + "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 + } + } + } + }, + "502": { + "description": "`enqueue_failed`: The request was recorded but could not be queued, and polls as `failed`. Retryable: send the request again.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["enqueue_failed"], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, + "/cli/api/v2/rule/{ruleId}/rollback": { + "post": { + "summary": "Make an earlier revision current now and return its files; a 200 refusal when the plan lacks restoreRules", + "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}`." + } + ], + "requestBody": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "orgId": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "repositoryUrl": { + "type": "string", + "description": "Full repository URL the rule belongs to" + }, + "revisionId": { + "type": "string", + "description": "The revision of this rule to make current again" + } + }, + "required": ["repositoryUrl", "revisionId"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule id: its directory name, stable for life" + }, + "revisionId": { + "type": "string", + "description": "The revision these bytes are" + }, + "rules": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "sg", + "description": "ast-grep — inert declarative rules" + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signatures" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "vale", + "description": "Vale — inert prose/markup rules" + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signatures" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "runtime", + "description": "Executable — check.ts runs against a file tree" + }, + "signature": { + "type": "string", + "description": "REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run." + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signature", + "signatures" + ], + "additionalProperties": false + } + ], + "description": "A rule directory as files, with the signatures of its surface" + }, + "description": "Exactly one file set: the requested rule, never its siblings" + }, + "entitlement": { + "$ref": "#/components/schemas/Entitlement" + }, + "restoreRules": { + "type": "boolean", + "const": true, + "description": "Rolled back: write this file set in place of the rule directory" + } + }, + "required": [ + "ruleId", + "revisionId", + "rules", + "restoreRules" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "restoreRules": { + "type": "boolean", + "const": false + }, + "reason": { + "type": "string", + "const": "RESTORE_RULES_NOT_IN_PLAN" + }, + "message": { + "type": "string", + "description": "Print verbatim: names the plan, says the rule is in the repository’s git history, and links the pull request that delivered it when one is recorded." + }, + "upgradeUrl": { + "type": "string", + "description": "The absolute URL of the organization's upgrade page" + } + }, + "required": [ + "restoreRules", + "reason", + "message", + "upgradeUrl" + ], + "additionalProperties": false, + "description": "The organization's plan does not include restoring rules. Only given for a rule that exists and belongs to the caller; a rule that does not is a 404." + } + ], + "description": "The revision rolled back to, or the refusal when the plan lacks it" + } + } + } + }, + "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.\n\n`revision_not_found`: Not a revision of this rule.", + "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", + "revision_not_found" + ], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "422": { + "description": "`rule_not_restorable`: A stored revision cannot be served as a file set; `details` says why. Should not happen: report it.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["rule_not_restorable"], + "description": "Machine-readable error code" + }, + "details": { + "description": "Human-readable reasons, when the code carries them", + "type": "array", + "items": { + "type": "string" + } + } + }, + "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", + "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" + }, + { + "name": "revision", + "in": "query", + "required": false, + "schema": { + "description": "A revision of this rule to fetch instead of its head. Any revision other than the head requires the plan's restoreRules entitlement.", + "type": "string" + }, + "description": "A revision of this rule to fetch instead of its head. Any revision other than the head requires the plan's restoreRules entitlement." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule id: its directory name, stable for life" + }, + "revisionId": { + "type": "string", + "description": "The revision these bytes are" + }, + "rules": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "sg", + "description": "ast-grep — inert declarative rules" + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signatures" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "vale", + "description": "Vale — inert prose/markup rules" + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signatures" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "runtime", + "description": "Executable — check.ts runs against a file tree" + }, + "signature": { + "type": "string", + "description": "REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run." + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signature", + "signatures" + ], + "additionalProperties": false + } + ], + "description": "A rule directory as files, with the signatures of its surface" + }, + "description": "Exactly one file set: the requested rule, never its siblings" + }, + "entitlement": { + "$ref": "#/components/schemas/Entitlement" + } + }, + "required": ["ruleId", "revisionId", "rules"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "restoreRules": { + "type": "boolean", + "const": false + }, + "reason": { + "type": "string", + "const": "RESTORE_RULES_NOT_IN_PLAN" + }, + "message": { + "type": "string", + "description": "Print verbatim: names the plan, says the rule is in the repository’s git history, and links the pull request that delivered it when one is recorded." + }, + "upgradeUrl": { + "type": "string", + "description": "The absolute URL of the organization's upgrade page" + } + }, + "required": [ + "restoreRules", + "reason", + "message", + "upgradeUrl" + ], + "additionalProperties": false, + "description": "The organization's plan does not include restoring rules. Only given for a rule that exists and belongs to the caller; a rule that does not is a 404." + } + ], + "description": "The rule, or, for a non-head revision on a plan without restoreRules, the refusal" + } + } + } + }, + "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.\n\n`revision_not_found`: Not a revision of this rule.", + "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", + "revision_not_found" + ], + "description": "Machine-readable error code" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "422": { + "description": "`rule_not_restorable`: A stored revision cannot be served as a file set; `details` says why. Should not happen: report it.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["rule_not_restorable"], + "description": "Machine-readable error code" + }, + "details": { + "description": "Human-readable reasons, when the code carries them", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, + "/cli/api/v2/rule/{ruleId}/restore": { + "post": { + "summary": "Restore a rule’s current revision by its id; a 200 refusal when the plan lacks restoreRules", + "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}`." + } + ], + "requestBody": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "orgId": { + "description": "Taskless org UUID (preferred) or numeric GitHub org id; falls back to the deprecated token claim", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "repositoryUrl": { + "type": "string", + "description": "Full repository URL the rule belongs to" + } + }, + "required": ["repositoryUrl"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "ruleId": { + "type": "string", + "description": "The rule id: its directory name, stable for life" + }, + "revisionId": { + "type": "string", + "description": "The revision these bytes are" + }, + "rules": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "sg", + "description": "ast-grep — inert declarative rules" + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signatures" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "vale", + "description": "Vale — inert prose/markup rules" + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signatures" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The rule directory name under .taskless/rules//" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to .taskless/rules///" + }, + "content": { + "type": "string", + "description": "The file’s exact bytes" + } + }, + "required": ["path", "content"], + "additionalProperties": false + }, + "description": "Every file the rule directory must contain" + }, + "engine": { + "type": "string", + "const": "runtime", + "description": "Executable — check.ts runs against a file tree" + }, + "signature": { + "type": "string", + "description": "REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run." + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path relative to the rule directory" + }, + "signature": { + "type": "string", + "description": "Canonical signature of the file (`1;h=sha-256;d=`)" + } + }, + "required": ["path", "signature"], + "additionalProperties": false + }, + "description": "REQUIRED on every engine. The signatures of every file of the rule except its test fixtures, as issued. A record of what was issued, never a grant: whether a rule runs is decided by reconcile." + } + }, + "required": [ + "id", + "files", + "engine", + "signature", + "signatures" + ], + "additionalProperties": false + } + ], + "description": "A rule directory as files, with the signatures of its surface" + }, + "description": "Exactly one file set: the requested rule, never its siblings" + }, + "entitlement": { + "$ref": "#/components/schemas/Entitlement" + }, + "restoreRules": { + "type": "boolean", + "const": true, + "description": "The rule was restored: its file set follows" + } + }, + "required": [ + "ruleId", + "revisionId", + "rules", + "restoreRules" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "restoreRules": { + "type": "boolean", + "const": false + }, + "reason": { + "type": "string", + "const": "RESTORE_RULES_NOT_IN_PLAN" + }, + "message": { + "type": "string", + "description": "Print verbatim: names the plan, says the rule is in the repository’s git history, and links the pull request that delivered it when one is recorded." + }, + "upgradeUrl": { + "type": "string", + "description": "The absolute URL of the organization's upgrade page" + } + }, + "required": [ + "restoreRules", + "reason", + "message", + "upgradeUrl" + ], + "additionalProperties": false, + "description": "The organization's plan does not include restoring rules. Only given for a rule that exists and belongs to the caller; a rule that does not is a 404." + } + ], + "description": "The restored rule, or the refusal when the plan lacks it" + } + } + } + }, + "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 + } + } + } + }, + "422": { + "description": "`rule_not_restorable`: A stored revision cannot be served as a file set; `details` says why. Should not happen: report it.", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["rule_not_restorable"], + "description": "Machine-readable error code" + }, + "details": { + "description": "Human-readable reasons, when the code carries them", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + } + } + } + }, + "/cli/api/v2/rule-hash-vectors": { + "get": { + "summary": "Public: canonical rule-hash conformance vectors for cross-repo validation", + "security": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "vectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable case name, e.g. \"crlf-equals-lf\"" + }, + "input": { + "type": "string", + "description": "Raw rule text to hash" + }, + "signature": { + "type": "string", + "description": "Canonical signature envelope: ;h=;d=" + } + }, + "required": ["name", "input", "signature"], + "additionalProperties": false + }, + "description": "Canonical conformance vectors every implementation must reproduce" + } + }, + "required": ["vectors"], + "additionalProperties": false + } + } + } + } + } + } + } + } +} diff --git a/packages/cli/test/api-v2.test.ts b/packages/cli/test/api-v2.test.ts new file mode 100644 index 00000000..b43994b7 --- /dev/null +++ b/packages/cli/test/api-v2.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + fetchRule, + getRequestStatus, + iterateRule, + reconcileRules, + restoreRule, + rollbackRule, + submitRequest, + whoami, +} from "../src/api/v2"; +import { CLI_VERSION, CLI_VERSION_HEADER } from "../src/version"; + +const REPO = "https://github.com/acme/app"; + +const SERVED = { + ruleId: "no-eval-3fa9c21b", + revisionId: "rev-1", + rules: [ + { + id: "no-eval-3fa9c21b", + engine: "sg", + files: [{ path: "no-eval-3fa9c21b.yml", content: "id: x\n" }], + signatures: [ + { path: "no-eval-3fa9c21b.yml", signature: "1;h=sha-256;d=00" }, + ], + }, + ], +}; + +const REFUSAL = { + restoreRules: false, + reason: "RESTORE_RULES_NOT_IN_PLAN", + message: "Restoring rules is not included in your Free plan.", + upgradeUrl: "https://app.taskless.io/org/1/upgrade?from=restore", +}; + +let fetchMock: ReturnType; + +function respond(status: number, body: unknown): void { + fetchMock.mockResolvedValue( + typeof body === "string" + ? new Response(body, { status }) + : Response.json(body, { status }) + ); +} + +/** The single request the call made. */ +function sent(): Request { + expect(fetchMock).toHaveBeenCalledTimes(1); + return fetchMock.mock.calls[0]?.[0] as Request; +} + +describe("v2 client", () => { + const originalUrl = process.env.TASKLESS_API_URL; + + beforeEach(() => { + process.env.TASKLESS_API_URL = "https://example.invalid/cli"; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + if (originalUrl === undefined) delete process.env.TASKLESS_API_URL; + else process.env.TASKLESS_API_URL = originalUrl; + vi.unstubAllGlobals(); + }); + + describe("the wire", () => { + it("sends every call under /cli/api/v2/ with the CLI version and the token", async () => { + respond(200, { rules: [], unknown: [], entitlement: {} }); + await reconcileRules("tok", { repositoryUrl: REPO, rules: [] }); + + const request = sent(); + expect(new URL(request.url).pathname).toBe("/cli/api/v2/reconcile"); + expect(request.headers.get(CLI_VERSION_HEADER)).toBe(CLI_VERSION); + expect(request.headers.get("authorization")).toBe("Bearer tok"); + }); + + it("puts the repository in the query for a GET, never the path", async () => { + respond(200, SERVED); + await fetchRule("tok", "no-eval-3fa9c21b", { repositoryUrl: REPO }); + + const url = new URL(sent().url); + expect(url.pathname).toBe("/cli/api/v2/rule/no-eval-3fa9c21b"); + expect(url.searchParams.get("repositoryUrl")).toBe(REPO); + expect(url.searchParams.has("revision")).toBe(false); + }); + + it("addresses restore by rule id, with the repository in the body", async () => { + respond(200, { ...SERVED, restoreRules: true }); + await restoreRule("tok", "no-eval-3fa9c21b", { repositoryUrl: REPO }); + + const request = sent(); + expect(new URL(request.url).pathname).toBe( + "/cli/api/v2/rule/no-eval-3fa9c21b/restore" + ); + expect(await request.json()).toEqual({ repositoryUrl: REPO }); + }); + }); + + describe("served rules", () => { + it("returns a restored rule without its restoreRules marker", async () => { + respond(200, { ...SERVED, restoreRules: true }); + const outcome = await restoreRule("tok", "no-eval-3fa9c21b", { + repositoryUrl: REPO, + }); + expect(outcome).toEqual({ status: "ok", data: SERVED }); + }); + + it("returns a fetched head, which carries no marker at all", async () => { + respond(200, SERVED); + const outcome = await fetchRule("tok", "no-eval-3fa9c21b", { + repositoryUrl: REPO, + }); + expect(outcome).toEqual({ status: "ok", data: SERVED }); + }); + + it.each([ + ["restore", () => restoreRule("tok", "r", { repositoryUrl: REPO })], + [ + "rollback", + () => + rollbackRule("tok", "r", { repositoryUrl: REPO, revisionId: "v" }), + ], + [ + "fetch", + () => fetchRule("tok", "r", { repositoryUrl: REPO, revision: "v" }), + ], + ])( + "reads a 200 refusal from %s as an answer, not a failure", + async (_, call) => { + respond(200, REFUSAL); + const outcome = await call(); + expect(outcome).toEqual({ + status: "refused", + refusal: { + reason: REFUSAL.reason, + message: REFUSAL.message, + upgradeUrl: REFUSAL.upgradeUrl, + }, + }); + } + ); + + it("never reads a body that is neither a rule nor a refusal as success", async () => { + respond(200, { restoreRules: true }); + const outcome = await restoreRule("tok", "r", { repositoryUrl: REPO }); + expect(outcome.status).toBe("unavailable"); + }); + }); + + describe("errors", () => { + it("maps a documented code to an error outcome, keeping details", async () => { + respond(400, { + error: "validation_error", + details: ["prompt: required"], + }); + const outcome = await submitRequest("tok", { + repositoryUrl: REPO, + prompt: "", + }); + expect(outcome).toEqual({ + status: "error", + code: "validation_error", + httpStatus: 400, + details: ["prompt: required"], + }); + }); + + it("maps rule_not_found on iterate, so a caller can report RULE_NOT_FOUND", async () => { + respond(404, { error: "rule_not_found" }); + const outcome = await iterateRule("tok", "gone-00000000", { + repositoryUrl: REPO, + guidance: "tighten", + }); + expect(outcome).toMatchObject({ + status: "error", + code: "rule_not_found", + }); + }); + + it("maps revision_not_found on rollback", async () => { + respond(404, { error: "revision_not_found" }); + const outcome = await rollbackRule("tok", "r", { + repositoryUrl: REPO, + revisionId: "other-rule-rev", + }); + expect(outcome).toMatchObject({ + status: "error", + code: "revision_not_found", + }); + }); + + it("gives 401 its own outcome", async () => { + respond(401, { error: "unauthorized" }); + expect(await whoami("tok")).toEqual({ status: "unauthorized" }); + }); + + it("reads a code the operation does not document as unavailable, naming it", async () => { + respond(404, { error: "rule_not_found" }); + const outcome = await getRequestStatus("tok", "req-1", { + repositoryUrl: REPO, + }); + expect(outcome).toEqual({ + status: "unavailable", + reason: "HTTP 404 (rule_not_found)", + }); + }); + + it("reads an undocumented status as unavailable", async () => { + respond(503, "upstream gone"); + const outcome = await reconcileRules("tok", { + repositoryUrl: REPO, + rules: [], + }); + expect(outcome).toEqual({ status: "unavailable", reason: "HTTP 503" }); + }); + + it("reads a network failure as unavailable, never a throw", async () => { + fetchMock.mockRejectedValue(new TypeError("fetch failed")); + const outcome = await reconcileRules("tok", { + repositoryUrl: REPO, + rules: [], + }); + expect(outcome).toEqual({ + status: "unavailable", + reason: "network error: fetch failed", + }); + }); + + it("reads a 200 whose body is not JSON as unavailable, never a throw", async () => { + respond(200, "proxy error"); + const outcome = await reconcileRules("tok", { + repositoryUrl: REPO, + rules: [], + }); + expect(outcome.status).toBe("unavailable"); + }); + }); +}); diff --git a/packages/cli/test/entitlement.test.ts b/packages/cli/test/entitlement.test.ts index 22536907..58e79e51 100644 --- a/packages/cli/test/entitlement.test.ts +++ b/packages/cli/test/entitlement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseEntitlement } from "../src/api/entitlement"; +import { parseEntitlement, parseEntitlementV2 } from "../src/api/entitlement"; const UPGRADE = "https://app.taskless.io/o/acme/upgrade?from=reconcile"; @@ -73,3 +73,56 @@ describe("parseEntitlement", () => { } }); }); + +describe("parseEntitlementV2", () => { + it("keeps every withheld rule, which the v1 parser would have dropped (#403)", () => { + const withheld = [ + { ruleId: "no-env-leak-3fa9c21b", revisionId: "rev-1" }, + { ruleId: "no-eval-00000000", revisionId: "rev-2" }, + ]; + const body = { + runtimeSignatures: false, + reason: "RUNTIME_SIGNATURES_NOT_IN_PLAN", + upgradeUrl: UPGRADE, + withheld, + }; + + // The hazard: v1's parser keys on `file`, which v2 entries do not carry. + expect(parseEntitlement(body)?.withheld).toEqual([]); + + expect(parseEntitlementV2(body)).toEqual({ + runtimeSignatures: false, + reason: "RUNTIME_SIGNATURES_NOT_IN_PLAN", + upgradeUrl: UPGRADE, + withheld, + }); + }); + + it("keeps an entry that lacks a revision id", () => { + expect( + parseEntitlementV2({ + runtimeSignatures: false, + withheld: [{ ruleId: "a" }], + })?.withheld + ).toEqual([{ ruleId: "a" }]); + }); + + it("drops only an entry with no rule id, since nothing can be joined to it", () => { + expect( + parseEntitlementV2({ + runtimeSignatures: false, + withheld: [{ revisionId: "r" }, "junk", { ruleId: "a" }], + })?.withheld + ).toEqual([{ ruleId: "a" }]); + }); + + it("is undefined for an entitled organization or anything but exactly false", () => { + for (const value of [ + undefined, + { runtimeSignatures: true }, + { runtimeSignatures: "false" }, + ]) { + expect(parseEntitlementV2(value)).toBeUndefined(); + } + }); +}); diff --git a/packages/cli/test/refusal.test.ts b/packages/cli/test/refusal.test.ts new file mode 100644 index 00000000..4ea29bd1 --- /dev/null +++ b/packages/cli/test/refusal.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { parseRefusal, stripControlCharacters } from "../src/api/refusal"; + +const UPGRADE = "https://app.taskless.io/org/1/upgrade?from=restore"; + +describe("parseRefusal", () => { + it("is undefined unless restoreRules is exactly false", () => { + for (const value of [ + undefined, + null, + {}, + { restoreRules: true }, + { restoreRules: "false" }, + [], + ]) { + expect(parseRefusal(value)).toBeUndefined(); + } + }); + + it("reads the documented refusal", () => { + expect( + parseRefusal({ + restoreRules: false, + reason: "RESTORE_RULES_NOT_IN_PLAN", + message: "Recover it with git.\nSee the delivering PR.", + upgradeUrl: UPGRADE, + }) + ).toEqual({ + reason: "RESTORE_RULES_NOT_IN_PLAN", + message: "Recover it with git.\nSee the delivering PR.", + upgradeUrl: UPGRADE, + }); + }); + + it("keeps a refusal whose reason it does not recognize", () => { + expect( + parseRefusal({ + restoreRules: false, + reason: "SOMETHING_NEW", + message: "No.", + }) + ).toEqual({ reason: "SOMETHING_NEW", message: "No." }); + }); + + it("gives a refusal with no message a generic one rather than dropping it", () => { + const refusal = parseRefusal({ restoreRules: false, reason: "X" }); + expect(refusal?.message).toContain("(X)"); + }); + + it("drops an upgrade URL that is not absolute https", () => { + for (const upgradeUrl of [ + "/org/1/upgrade", + "http://app.taskless.io/x", + "not a url", + ]) { + expect( + parseRefusal({ + restoreRules: false, + reason: "X", + message: "m", + upgradeUrl, + })?.upgradeUrl + ).toBeUndefined(); + } + }); + + it("strips an ANSI escape from the message", () => { + const refusal = parseRefusal({ + restoreRules: false, + reason: "X", + message: "\u001B[2J\u001B[31mcleared\u001B[0m", + }); + expect(refusal?.message).not.toContain("\u001B"); + expect(refusal?.message).toBe("[2J[31mcleared[0m"); + }); +}); + +describe("stripControlCharacters", () => { + it("keeps newlines and removes every other C0, DEL, and C1 character", () => { + expect(stripControlCharacters("a\nb\tc\rd\u0007e\u007Ff\u009Bg")).toBe( + "a\nbcdefg" + ); + }); + + it("leaves ordinary Unicode alone", () => { + expect(stripControlCharacters("restaurée — ✓")).toBe("restaurée — ✓"); + }); +}); From 06ea0e8ad2a71f3d3f98db8314d4ca7cca842897 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 28 Sep 2026 20:40:55 -0700 Subject: [PATCH 3/4] docs(openspec): served rules always carry their fixtures --- openspec/changes/cli-v2-rule-api/design.md | 8 +++----- .../specs/cli-generated-rule-delivery/spec.md | 13 +++++++------ openspec/changes/cli-v2-rule-api/tasks.md | 6 +++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/openspec/changes/cli-v2-rule-api/design.md b/openspec/changes/cli-v2-rule-api/design.md index 04fe2f85..5d6f6c8b 100644 --- a/openspec/changes/cli-v2-rule-api/design.md +++ b/openspec/changes/cli-v2-rule-api/design.md @@ -232,11 +232,9 @@ must have a signature. Then the rule directory is replaced, reusing reporting included), so a stale local file cannot survive and make the rule `unsafe` on the next run. -**Fixtures (pending the rules team's confirmation that served sets include `.tests/`):** -the purge covers `.tests/` only when the served set carries at least one `.tests/` file. If a -served set carries none, local fixtures are left in place rather than deleted. When fixtures are -confirmed to always ship, this guard is harmless; if they turn out not to ship, it is what keeps -every restore from deleting them. +**Fixtures ship with every served set** (confirmed with the rules team, +2026-09-29), so the replace covers `.tests/` like everything else, and each file's +parent directories are created as it is written. For create and improve the CLI also checks that the fetched `revisionId` equals the one the request produced. The head is fetched without `revision=`, so a diff --git a/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md b/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md index ac1e9be8..a62861ad 100644 --- a/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md +++ b/openspec/changes/cli-v2-rule-api/specs/cli-generated-rule-delivery/spec.md @@ -88,9 +88,10 @@ Any failure SHALL refuse the whole rule and write nothing. ### Requirement: A delivered rule replaces its directory After verification, the CLI SHALL write a served rule so that its directory holds exactly the -served files: every file in the directory that the served set does not contain SHALL be removed. -Files under `.tests/` SHALL be replaced only when the served set carries at least one `.tests/` -file; otherwise the local `.tests/` SHALL be left in place. When a stale file cannot be removed, the CLI SHALL say that the served +served files: every file in the directory that the served set does not contain SHALL be removed, +including under `.tests/`, since a served set always carries the rule's fixtures. The CLI SHALL +create each served file's parent directories as needed, so a nested path such as +`captures/env.yml` or `.tests/fail/case.ts` is written wherever it lands. When a stale file cannot be removed, the CLI SHALL say that the served bytes were written and name each entry it could not remove. #### Scenario: A local extra file does not survive @@ -98,10 +99,10 @@ bytes were written and name each entry it could not remove. - **WHEN** a rule directory holds `captures/extra.yml` and the served set does not - **THEN** after the write `captures/extra.yml` SHALL NOT exist -#### Scenario: Local fixtures survive a set that carries none +#### Scenario: Nested paths are written -- **WHEN** a served set carries no file under `.tests/` and the local rule has `.tests/` -- **THEN** the local `.tests/` SHALL be left in place +- **WHEN** a served set carries `.tests/fail/case.ts` and the rule directory does not exist +- **THEN** the CLI SHALL create `.tests/fail/` and write the file #### Scenario: A generated rule's revision is confirmed diff --git a/openspec/changes/cli-v2-rule-api/tasks.md b/openspec/changes/cli-v2-rule-api/tasks.md index 1f88d672..45632c37 100644 --- a/openspec/changes/cli-v2-rule-api/tasks.md +++ b/openspec/changes/cli-v2-rule-api/tasks.md @@ -55,9 +55,9 @@ upgradeUrl }`, strip C0/C1 control characters except newline from and that `rules` holds exactly one set whose `id` is the requested id. Unit tests for each refusal. - [ ] 3.2 Make `writeDeliveredFileSet` the only write path for a served rule and - make it replace the directory (purge files the set lacks; purge `.tests/` - only when the set carries a `.tests/` file, pending the rules team's - confirmation that fixtures ship). Drop the legacy single-`content` branch from `deliver.ts` and + make it replace the directory (purge files the set lacks, `.tests/` + included; create each file's parent directories). + Drop the legacy single-`content` branch from `deliver.ts` and `files.ts`. `deliver.test.ts` covers a local extra capture being removed. - [ ] 3.3 Move `rule create` to v2: submit, poll, fetch each produced `{ ruleId, revisionId }` head in parallel without `revision`, confirm From 08415943c0aa0d06579537d5f276ca73964601e9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 29 Sep 2026 15:31:15 -0700 Subject: [PATCH 4/4] refactor(api): share one isRecord guard Three identical private copies in api/ become one util/is-record.ts. --- packages/cli/src/api/entitlement.ts | 6 ++---- packages/cli/src/api/refusal.ts | 5 +---- packages/cli/src/api/v2.ts | 5 +---- packages/cli/src/util/is-record.ts | 4 ++++ 4 files changed, 8 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/util/is-record.ts diff --git a/packages/cli/src/api/entitlement.ts b/packages/cli/src/api/entitlement.ts index 655d8445..0a9fd955 100644 --- a/packages/cli/src/api/entitlement.ts +++ b/packages/cli/src/api/entitlement.ts @@ -10,6 +10,8 @@ * tell "the server declined to run this for your plan" apart from drift. */ +import { isRecord } from "../util/is-record"; + /** A reported file the service withheld for entitlement, not for tampering. */ export interface WithheldEntry { ruleId?: string; @@ -41,10 +43,6 @@ export interface Entitlement { */ export type MayCarryEntitlement = T & { entitlement?: unknown }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - /** * An upgrade URL is printed as a link the user is invited to follow, so a value * that is not an absolute `https:` URL is dropped rather than shown. diff --git a/packages/cli/src/api/refusal.ts b/packages/cli/src/api/refusal.ts index f3647ad3..7512f309 100644 --- a/packages/cli/src/api/refusal.ts +++ b/packages/cli/src/api/refusal.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../util/is-record"; import { parseUpgradeUrl } from "./entitlement"; /** @@ -20,10 +21,6 @@ export interface Refusal { upgradeUrl?: string; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - /** * Remove C0 and C1 control characters, and DEL, except newline. * diff --git a/packages/cli/src/api/v2.ts b/packages/cli/src/api/v2.ts index 7e4278dd..401bc8bb 100644 --- a/packages/cli/src/api/v2.ts +++ b/packages/cli/src/api/v2.ts @@ -3,6 +3,7 @@ import createClient from "openapi-fetch"; import type { paths } from "../generated/api-v2"; import { getApiBaseUrl } from "./config"; import { parseRefusal, type Refusal } from "./refusal"; +import { isRecord } from "../util/is-record"; import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; /** @@ -93,10 +94,6 @@ export function createV2Client(token: string) { }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - type Fetched = { data?: unknown; error?: unknown; response: Response }; /** diff --git a/packages/cli/src/util/is-record.ts b/packages/cli/src/util/is-record.ts new file mode 100644 index 00000000..1b5d78d3 --- /dev/null +++ b/packages/cli/src/util/is-record.ts @@ -0,0 +1,4 @@ +/** A non-null, non-array object, readable as a string-keyed record. */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}