Skip to content

Commit 07ef558

Browse files
authored
Merge pull request #47 from taskless/jakob/server-owned-rule-reconciliation
feat(cli): server-owned rule reconciliation
2 parents f764540 + 949565d commit 07ef558

37 files changed

Lines changed: 4069 additions & 35 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-07-03
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
## Context
2+
3+
`taskless check` today (`packages/cli/src/commands/check.ts`) enumerates every `*.yml` under
4+
`.taskless/rules/`, writes a fixed `.taskless/sgconfig.yml` with `ruleDirs: [rules]`
5+
(`src/filesystem/sgconfig.ts`), and shells out to `sg scan` (`src/rules/scan.ts`). It has no
6+
notion of authenticity, no signing, no network call, and no auth dependency — `--anonymous`
7+
is a pure no-op. There is no rule-hash code, no conformance harness, and the generated API
8+
schema (`src/generated/api.d.ts`) exposes only `whoami` / `rule` / `rule/{id}` /
9+
`rule/{id}/iterate`. Reconciliation is entirely greenfield.
10+
11+
The backend (TSKL-270) is introducing `POST /cli/api/reconcile`: the CLI reports the rule
12+
files it holds as `{ file, signature }`, and the server returns the exact subset that may run.
13+
The signature format, the `normalize()` procedure, and the conformance vectors are **frozen**
14+
and specified in `tmp/rule-signatures.md`; the endpoint may not be live in every environment
15+
on day one. The existing plumbing we build on: `resolveIdentity(cwd)`
16+
`{ token, orgId, repositoryUrl }` (`src/auth/identity.ts`), `getToken(cwd)`
17+
(`src/auth/token.ts`), the `openapi-fetch` client + base-URL resolution
18+
(`src/api/client.ts`, `src/api/config.ts`), and the stable error envelope
19+
(`src/types/errors.ts`).
20+
21+
## Goals / Non-Goals
22+
23+
**Goals:**
24+
25+
- A signature module that reproduces the server's `rule-hash.ts` byte-for-byte, verified by
26+
the shared conformance vectors.
27+
- A reconcile client for `POST /cli/api/reconcile` returning typed `run`/`unsafe`/`unknown`/
28+
`missing` buckets.
29+
- `check` executes only the `run` set (matched by signature) when it can reconcile, and
30+
surfaces the other buckets as advisory.
31+
- A fallback that keeps `check` working offline / logged-out / `--anonymous` / before the
32+
endpoint ships, preserving today's linter posture.
33+
34+
**Non-Goals:**
35+
36+
- Defining or implementing how a **server-owned/runtime rule executes**. The reconciliation
37+
contract is rule-type-agnostic (it gates which files run). Rule execution is a deliberate,
38+
team-owned follow-up shipped as a **separate proposal stacked on top of this change**; this
39+
change only makes such rules gate-able.
40+
- A `sync` / `pull` / `list` command to bulk-download rules (none exists today; reconciliation
41+
does not require one — the CLI reports what is already on disk).
42+
- Server-side approval of `unknown` files (explicitly server-side future work).
43+
- Changing the on-disk rule naming scheme. The reconcile join key is the signature, not the
44+
path, so `<id>.yml` naming is fine as-is.
45+
46+
## Decisions
47+
48+
### Decision: New `rule-hash.ts` mirroring the server, web-standard crypto only
49+
50+
Add `packages/cli/src/rules/rule-hash.ts` exporting `ALGO_VERSION`, `normalize(text)`,
51+
`canonicalHash(bytes|text)` (returns the envelope string), and `parseSignature(sig)`. Use
52+
`crypto.subtle.digest('SHA-256', …)` + `TextEncoder` exclusively — no `node:crypto`. This
53+
matches the server reference (`packages/shared/src/rule-hash.ts`) and runs identically in
54+
workerd and Node 20+. `normalize()` operates on raw decoded text (BOM strip, CRLF/CR→LF,
55+
collapse trailing newlines to one LF) and never parses YAML, so it is future-proof for any
56+
rule type.
57+
58+
_Alternative rejected:_ `node:crypto` `createHash`. Simpler locally but diverges from the
59+
web-standard reference the server pins, and risks subtle cross-repo drift the vectors exist to
60+
prevent.
61+
62+
### Decision: Build-wired resilient fetch with a committed cache; test asserts exact reproduction
63+
64+
Commit the vectors as `packages/cli/test/fixtures/rule-hash.vectors.json` in the cross-repo
65+
source-of-truth format (a bare `[{ name, input, signature }]` array kept pure-ASCII with
66+
`\uXXXX` escapes, so git stays byte-stable). `scripts/fetch-rule-hash-vectors.ts` (npm
67+
`generate:rule-hash-vectors`) refreshes it from `GET /cli/api/rule-hash-vectors`, unwrapping
68+
the endpoint's `{ vectors: [...] }` and re-escaping to ASCII. It is wired as `prebuild`, so
69+
every build/CI run tries to refresh but falls back to the committed cache on any
70+
network/HTTP/shape failure (only a missing cache is fatal). A vitest test
71+
(`test/rule-hash.test.ts`) parses each `input` as JSON (decoding `\uXXXX`) and asserts
72+
`canonicalHash(input) === signature` for every entry; a mismatch fails the build. CI gets the
73+
freshest vectors when reachable while offline builds and the unit suite stay hermetic.
74+
75+
_Alternatives rejected:_ (a) fetch live during the test run — couples the unit suite to
76+
endpoint availability; (b) a purely manual refresh — drifts silently from the server.
77+
78+
### Decision: Reconcile client via a hand-typed request over the existing fetch layer
79+
80+
The reconcile endpoint is not in the generated `paths`, and the handoff says it may not be
81+
deployed everywhere yet. Add `packages/cli/src/api/reconcile.ts` exporting
82+
`reconcile(token, { repositoryUrl, files })`. Reuse `createApiClient(token)` where possible;
83+
because the path is absent from the generated schema, type the request/response with local
84+
interfaces (`ReconcileRequest`, `ReconcileResponse` with `run`/`unsafe`/`unknown`/`missing`)
85+
and issue the call with an explicit `Authorization: Bearer` header against `getApiBaseUrl()`'s
86+
origin. Map a `401` to an unauthorized signal and any transport/`404`/not-deployed outcome to
87+
a distinct "reconcile unavailable" signal that drives the fallback (never a hard failure).
88+
When the endpoint lands in the published schema, this can be migrated onto the typed client
89+
without changing `check`.
90+
91+
_Alternative rejected:_ add the path to `api.d.ts` by hand — the file is generated
92+
(`pnpm generate:api`) and hand-edits would be clobbered; wait for the server schema to expose
93+
it, then regenerate.
94+
95+
### Decision: Gate the scan by materializing a run-set rule directory
96+
97+
`sg scan` selects rules via `ruleDirs` in `sgconfig.yml`, so limiting execution to the `run`
98+
set means pointing ast-grep at only those files. Materialize an ephemeral, gitignored
99+
`.taskless/.run/rules/` containing just the blessed files (copied from their local matches by
100+
signature), generate an sgconfig whose `ruleDirs` points there, and scan that. Keeps
101+
`src/rules/scan.ts` unchanged (it already accepts a config path and positional paths) and
102+
avoids mutating the user's `.taskless/rules/`. On fallback, generate the current sgconfig
103+
(`ruleDirs: [rules]`) and scan everything, exactly as today.
104+
105+
_Alternatives considered:_ (a) delete non-run files in place — destructive, unacceptable;
106+
(b) pass each blessed rule as an inline `--rule` arg — brittle across ast-grep versions and
107+
loses `testConfigs`. The ephemeral-dir approach is the least invasive and reuses existing
108+
config generation.
109+
110+
### Decision: Auth state is the behavior axis; degrade (not fail) when authed reconcile can't complete
111+
112+
`check` requires no auth and picks its path from auth state:
113+
114+
- **No token** (or `--anonymous`) → run all local rules with no network. This is the normal
115+
offline linter posture, so it emits no warning (an informational line is optional).
116+
- **Authenticated** (token + resolvable `repositoryUrl`, `--anonymous` unset) → reconcile,
117+
run only the `run` set, and **warn** on `unsafe`/`unknown`/`missing` mismatches.
118+
- **Authenticated but reconcile can't complete** (no git remote, endpoint unreachable /
119+
not-deployed, transport error) → **degrade**: warn that verification couldn't be performed
120+
and scan all local rules, without a non-zero exit.
121+
122+
All warnings are human-output only and suppressed under `--json` to keep the machine shape
123+
stable. This honors the handoff's trust model — local `check` stays advisory like a linter,
124+
the server-owned allow-list applies when authenticated, and the paid-plan CI backstop is the
125+
real enforcement point — and prevents the not-yet-live endpoint from bricking `check`.
126+
Separating "no auth" (silent, expected) from "authed-but-couldn't-verify" (warned) keeps
127+
routine offline use quiet while still flagging a genuine verification gap.
128+
129+
### Decision: Add `RECONCILE_FAILED` to the error enum, used sparingly
130+
131+
Extend `CLIErrorCode` in `src/types/errors.ts` with `RECONCILE_FAILED`. Because reconcile
132+
failure normally triggers the fallback (not an error), this code is reserved for the case
133+
where reconciliation itself is the requested operation and hard-fails in a way the user asked
134+
to be surfaced. Adding a code is permitted by the `cli` capability without a major bump.
135+
136+
## Risks / Trade-offs
137+
138+
- **[Endpoint not deployed on day one]** → Fallback treats unreachable/404 as "reconcile
139+
unavailable" and scans locally; no behavior regression versus today until the endpoint
140+
ships.
141+
- **[Signature drift from the server reference]** → Committed conformance vectors + a
142+
build-blocking test catch any divergence in `normalize()`/hashing before release.
143+
- **[`check` gains a network + auth dependency]** → Reconciliation is strictly additive and
144+
gated; unauthenticated and `--anonymous` runs keep working with no auth via the fallback.
145+
- **[Ephemeral run-dir leaks into git]** → Write under `.taskless/.run/` and ensure
146+
`.taskless/.gitignore` covers it (same mechanism that hides `sgconfig.yml`); regenerate on
147+
every run.
148+
- **[Users read the fallback as "verified"]** → The one-line notice explicitly states rules
149+
are unverified and names the CI backstop as the enforcement point.
150+
- **[Reconcile latency on large corpora]** → One request per `check`; signatures are cheap
151+
SHA-256 over small files. Acceptable; can batch/cache later if needed.
152+
153+
## Open Questions
154+
155+
- **Warning verbosity**: how loudly to warn on `unknown`/`missing` in human output (always,
156+
or behind a `--verbose`/`--strict` flag) — resolve during implementation against the help
157+
copy for `check`/`ci`. `unsafe` (tamper/drift) should warn by default.
158+
- **CI `--strict` mode**: whether the CI backstop invocation should turn `unsafe` into a
159+
non-zero exit (the enforcement posture) versus advisory locally. Defer unless the backstop
160+
spec requires it here.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
## Why
2+
3+
The backend is moving to **server-owned rule reconciliation** (TSKL-270): the server, not
4+
the CLI, decides which rule files may run. This is the CLI-side enabler for a new class of
5+
server-blessed rules (including runtime rules) — instead of executing whatever YAML happens
6+
to sit in `.taskless/rules/`, the CLI must report the rule files it holds and execute only
7+
the subset the server returns as `run`. Today `taskless check` runs every `.yml` in the
8+
rules directory with no notion of authenticity or drift; the frozen `POST /cli/api/reconcile`
9+
contract lets us close that gap now, ahead of the endpoint going live in every environment.
10+
11+
## What Changes
12+
13+
- Add a canonical **rule-signature** module (`normalize()` + `canonicalHash()` +
14+
`parseSignature()`, algoVersion `1`, `1;h=sha-256;d=<hex>`) built on web-standard
15+
`crypto.subtle` + `TextEncoder`, byte-for-byte matching the server reference.
16+
- Add a **conformance test** that fetches `GET /cli/api/rule-hash-vectors`, commits the
17+
fixture, and asserts the local hasher reproduces every vector's signature exactly (a
18+
mismatch is a release blocker).
19+
- Add a **reconcile API client** for `POST /cli/api/reconcile` that reports every rule file
20+
under `.taskless/rules/` as `{ file, signature }` and parses the `run` / `unsafe` /
21+
`unknown` / `missing` response.
22+
- **Make `taskless check` behavior depend on auth state** (it still requires no auth):
23+
- **No token** → run all local rules, no network (today's offline linter posture).
24+
- **Authenticated** → reconcile, execute **exactly** the server's `run` set (matched back
25+
to local files by signature), and **warn on mismatches** (`unsafe` / `unknown` /
26+
`missing`) without changing the exit code.
27+
- **`--anonymous`** → force the logged-out path (run local, no reconcile call).
28+
- Add a **degrade path**: when an authenticated reconciliation cannot complete (no git
29+
remote, or the endpoint is unreachable / not yet deployed), `check` warns that verification
30+
could not be performed and falls back to a local scan without failing — so the not-yet-live
31+
endpoint never bricks `check`.
32+
- Add a stable `RECONCILE_FAILED` error code to the CLI error enum for `--json` callers.
33+
34+
Out of scope (deliberately, by team decision): how a server-owned rule (including a runtime
35+
rule) _executes_. The reconciliation contract is rule-type-agnostic — it decides which files
36+
run, not how a runtime rule is evaluated. Rule execution is a **separate proposal, landed as a
37+
stacked PR on top of this work**; this change only makes such rules gate-able.
38+
39+
## Capabilities
40+
41+
### New Capabilities
42+
43+
- `cli-rule-reconciliation`: the rule-signature envelope and `normalize()` procedure, the
44+
conformance-vector contract, the `POST /cli/api/reconcile` client, the four response
45+
buckets and their required CLI actions, and the run-set-only execution rule.
46+
47+
### Modified Capabilities
48+
49+
- `cli-check`: `check` chooses its behavior from auth state — unauthenticated/`--anonymous`
50+
runs all local rules; authenticated reconciles and executes only the `run` set, warning on
51+
`unsafe` / `unknown` / `missing`, and degrading to a local scan if reconciliation can't complete.
52+
53+
## Impact
54+
55+
- **Code:** new `packages/cli/src/rules/rule-hash.ts` and reconcile client under
56+
`packages/cli/src/api/`; changes to `src/commands/check.ts` (reconcile-then-gate flow) and
57+
`src/filesystem/sgconfig.ts` / rule enumeration (scan only the blessed set); new error code
58+
in `src/types/errors.ts`.
59+
- **APIs consumed:** `POST /cli/api/reconcile` (Bearer `<cli-token>`, `repositoryUrl` +
60+
reported files) and `GET /cli/api/rule-hash-vectors` (unauthenticated, for conformance).
61+
- **Behavioral shift:** `check` gains an auth-state-dependent path — authenticated runs get a
62+
server-owned allow-list and mismatch warnings; unauthenticated/`--anonymous` runs are
63+
unchanged (all local rules, no network).
64+
- **Tests:** new conformance-vector test and reconcile/gating tests (vitest, subprocess
65+
against `dist/`, temp-dir fixtures per existing conventions).
66+
- **Docs:** `check.txt` / `ci.txt` help updated to explain the run-set gate and the CI backstop.

0 commit comments

Comments
 (0)