Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
673724a
feat(cli): add canonical rule-hash signature module
thecodedrift Jul 3, 2026
840c729
feat(cli): add rule-hash conformance vectors with resilient fetch
thecodedrift Jul 3, 2026
e9b4911
feat(cli): add reconcile API client
thecodedrift Jul 3, 2026
d157178
feat(cli): gate check on server reconciliation by auth state
thecodedrift Jul 3, 2026
ebf410e
docs(cli): document reconciliation auth-state behavior in help
thecodedrift Jul 3, 2026
3618204
test(cli): cover reconciliation gating, auth state, and warnings
thecodedrift Jul 3, 2026
efb5eb5
chore(openspec): sync + archive server-owned-rule-reconciliation
thecodedrift Jul 3, 2026
959c43e
docs(openspec): propose runtime rule execution
thecodedrift Jul 3, 2026
537345a
docs(openspec): confirm runtime rule layout against generator
thecodedrift Jul 3, 2026
fa9e836
feat(cli): discover runtime rules under .taskless/runtime-rules
thecodedrift Jul 3, 2026
01bb831
feat(cli): add the runtime-rule narrow -> gate -> check harness
thecodedrift Jul 3, 2026
7ff6ca0
feat(cli): scope reconcile to runtime check.ts and materialize blesse…
thecodedrift Jul 3, 2026
0bad732
feat(cli): dispatch static vs runtime rules in check; cut over reconc…
thecodedrift Jul 3, 2026
cf9901d
test(cli): cover runtime rule execution; document the two rule kinds
thecodedrift Jul 3, 2026
829d2a0
docs(openspec): correct narrow spec to match the config-based impleme…
thecodedrift Jul 3, 2026
9bc23ea
chore(openspec): sync + archive runtime-rule-execution
thecodedrift Jul 3, 2026
5a7e18c
fix(cli): harden vectors prebuild and correct parseSignature doc
thecodedrift Jul 4, 2026
0343af7
docs(openspec): fix live-tree path in the materialize requirement
thecodedrift Jul 4, 2026
f3c5dc1
fix(cli): address Copilot review on runtime rule execution
thecodedrift Jul 4, 2026
e61fb12
docs(openspec): tighten runtime-rule specs per Copilot review
thecodedrift Jul 4, 2026
949565d
docs(openspec): sync runtime-rule spec tightening into canonical specs
thecodedrift Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-03
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
## Context

`taskless check` today (`packages/cli/src/commands/check.ts`) enumerates every `*.yml` under
`.taskless/rules/`, writes a fixed `.taskless/sgconfig.yml` with `ruleDirs: [rules]`
(`src/filesystem/sgconfig.ts`), and shells out to `sg scan` (`src/rules/scan.ts`). It has no
notion of authenticity, no signing, no network call, and no auth dependency — `--anonymous`
is a pure no-op. There is no rule-hash code, no conformance harness, and the generated API
schema (`src/generated/api.d.ts`) exposes only `whoami` / `rule` / `rule/{id}` /
`rule/{id}/iterate`. Reconciliation is entirely greenfield.

The backend (TSKL-270) is introducing `POST /cli/api/reconcile`: the CLI reports the rule
files it holds as `{ file, signature }`, and the server returns the exact subset that may run.
The signature format, the `normalize()` procedure, and the conformance vectors are **frozen**
and specified in `tmp/rule-signatures.md`; the endpoint may not be live in every environment
on day one. The existing plumbing we build on: `resolveIdentity(cwd)` →
`{ token, orgId, repositoryUrl }` (`src/auth/identity.ts`), `getToken(cwd)`
(`src/auth/token.ts`), the `openapi-fetch` client + base-URL resolution
(`src/api/client.ts`, `src/api/config.ts`), and the stable error envelope
(`src/types/errors.ts`).

## Goals / Non-Goals

**Goals:**

- A signature module that reproduces the server's `rule-hash.ts` byte-for-byte, verified by
the shared conformance vectors.
- A reconcile client for `POST /cli/api/reconcile` returning typed `run`/`unsafe`/`unknown`/
`missing` buckets.
- `check` executes only the `run` set (matched by signature) when it can reconcile, and
surfaces the other buckets as advisory.
- A fallback that keeps `check` working offline / logged-out / `--anonymous` / before the
endpoint ships, preserving today's linter posture.

**Non-Goals:**

- Defining or implementing how a **server-owned/runtime rule executes**. The reconciliation
contract is rule-type-agnostic (it gates which files run). Rule execution is a deliberate,
team-owned follow-up shipped as a **separate proposal stacked on top of this change**; this
change only makes such rules gate-able.
- A `sync` / `pull` / `list` command to bulk-download rules (none exists today; reconciliation
does not require one — the CLI reports what is already on disk).
- Server-side approval of `unknown` files (explicitly server-side future work).
- Changing the on-disk rule naming scheme. The reconcile join key is the signature, not the
path, so `<id>.yml` naming is fine as-is.

## Decisions

### Decision: New `rule-hash.ts` mirroring the server, web-standard crypto only

Add `packages/cli/src/rules/rule-hash.ts` exporting `ALGO_VERSION`, `normalize(text)`,
`canonicalHash(bytes|text)` (returns the envelope string), and `parseSignature(sig)`. Use
`crypto.subtle.digest('SHA-256', …)` + `TextEncoder` exclusively — no `node:crypto`. This
matches the server reference (`packages/shared/src/rule-hash.ts`) and runs identically in
workerd and Node 20+. `normalize()` operates on raw decoded text (BOM strip, CRLF/CR→LF,
collapse trailing newlines to one LF) and never parses YAML, so it is future-proof for any
rule type.

_Alternative rejected:_ `node:crypto` `createHash`. Simpler locally but diverges from the
web-standard reference the server pins, and risks subtle cross-repo drift the vectors exist to
prevent.

### Decision: Build-wired resilient fetch with a committed cache; test asserts exact reproduction

Commit the vectors as `packages/cli/test/fixtures/rule-hash.vectors.json` in the cross-repo
source-of-truth format (a bare `[{ name, input, signature }]` array kept pure-ASCII with
`\uXXXX` escapes, so git stays byte-stable). `scripts/fetch-rule-hash-vectors.ts` (npm
`generate:rule-hash-vectors`) refreshes it from `GET /cli/api/rule-hash-vectors`, unwrapping
the endpoint's `{ vectors: [...] }` and re-escaping to ASCII. It is wired as `prebuild`, so
every build/CI run tries to refresh but falls back to the committed cache on any
network/HTTP/shape failure (only a missing cache is fatal). A vitest test
(`test/rule-hash.test.ts`) parses each `input` as JSON (decoding `\uXXXX`) and asserts
`canonicalHash(input) === signature` for every entry; a mismatch fails the build. CI gets the
freshest vectors when reachable while offline builds and the unit suite stay hermetic.

_Alternatives rejected:_ (a) fetch live during the test run — couples the unit suite to
endpoint availability; (b) a purely manual refresh — drifts silently from the server.

### Decision: Reconcile client via a hand-typed request over the existing fetch layer

The reconcile endpoint is not in the generated `paths`, and the handoff says it may not be
deployed everywhere yet. Add `packages/cli/src/api/reconcile.ts` exporting
`reconcile(token, { repositoryUrl, files })`. Reuse `createApiClient(token)` where possible;
because the path is absent from the generated schema, type the request/response with local
interfaces (`ReconcileRequest`, `ReconcileResponse` with `run`/`unsafe`/`unknown`/`missing`)
and issue the call with an explicit `Authorization: Bearer` header against `getApiBaseUrl()`'s
origin. Map a `401` to an unauthorized signal and any transport/`404`/not-deployed outcome to
a distinct "reconcile unavailable" signal that drives the fallback (never a hard failure).
When the endpoint lands in the published schema, this can be migrated onto the typed client
without changing `check`.

_Alternative rejected:_ add the path to `api.d.ts` by hand — the file is generated
(`pnpm generate:api`) and hand-edits would be clobbered; wait for the server schema to expose
it, then regenerate.

### Decision: Gate the scan by materializing a run-set rule directory

`sg scan` selects rules via `ruleDirs` in `sgconfig.yml`, so limiting execution to the `run`
set means pointing ast-grep at only those files. Materialize an ephemeral, gitignored
`.taskless/.run/rules/` containing just the blessed files (copied from their local matches by
signature), generate an sgconfig whose `ruleDirs` points there, and scan that. Keeps
`src/rules/scan.ts` unchanged (it already accepts a config path and positional paths) and
avoids mutating the user's `.taskless/rules/`. On fallback, generate the current sgconfig
(`ruleDirs: [rules]`) and scan everything, exactly as today.

_Alternatives considered:_ (a) delete non-run files in place — destructive, unacceptable;
(b) pass each blessed rule as an inline `--rule` arg — brittle across ast-grep versions and
loses `testConfigs`. The ephemeral-dir approach is the least invasive and reuses existing
config generation.

### Decision: Auth state is the behavior axis; degrade (not fail) when authed reconcile can't complete

`check` requires no auth and picks its path from auth state:

- **No token** (or `--anonymous`) → run all local rules with no network. This is the normal
offline linter posture, so it emits no warning (an informational line is optional).
- **Authenticated** (token + resolvable `repositoryUrl`, `--anonymous` unset) → reconcile,
run only the `run` set, and **warn** on `unsafe`/`unknown`/`missing` mismatches.
- **Authenticated but reconcile can't complete** (no git remote, endpoint unreachable /
not-deployed, transport error) → **degrade**: warn that verification couldn't be performed
and scan all local rules, without a non-zero exit.

All warnings are human-output only and suppressed under `--json` to keep the machine shape
stable. This honors the handoff's trust model — local `check` stays advisory like a linter,
the server-owned allow-list applies when authenticated, and the paid-plan CI backstop is the
real enforcement point — and prevents the not-yet-live endpoint from bricking `check`.
Separating "no auth" (silent, expected) from "authed-but-couldn't-verify" (warned) keeps
routine offline use quiet while still flagging a genuine verification gap.

### Decision: Add `RECONCILE_FAILED` to the error enum, used sparingly

Extend `CLIErrorCode` in `src/types/errors.ts` with `RECONCILE_FAILED`. Because reconcile
failure normally triggers the fallback (not an error), this code is reserved for the case
where reconciliation itself is the requested operation and hard-fails in a way the user asked
to be surfaced. Adding a code is permitted by the `cli` capability without a major bump.

## Risks / Trade-offs

- **[Endpoint not deployed on day one]** → Fallback treats unreachable/404 as "reconcile
unavailable" and scans locally; no behavior regression versus today until the endpoint
ships.
- **[Signature drift from the server reference]** → Committed conformance vectors + a
build-blocking test catch any divergence in `normalize()`/hashing before release.
- **[`check` gains a network + auth dependency]** → Reconciliation is strictly additive and
gated; unauthenticated and `--anonymous` runs keep working with no auth via the fallback.
- **[Ephemeral run-dir leaks into git]** → Write under `.taskless/.run/` and ensure
`.taskless/.gitignore` covers it (same mechanism that hides `sgconfig.yml`); regenerate on
every run.
- **[Users read the fallback as "verified"]** → The one-line notice explicitly states rules
are unverified and names the CI backstop as the enforcement point.
- **[Reconcile latency on large corpora]** → One request per `check`; signatures are cheap
SHA-256 over small files. Acceptable; can batch/cache later if needed.

## Open Questions

- **Warning verbosity**: how loudly to warn on `unknown`/`missing` in human output (always,
or behind a `--verbose`/`--strict` flag) — resolve during implementation against the help
copy for `check`/`ci`. `unsafe` (tamper/drift) should warn by default.
- **CI `--strict` mode**: whether the CI backstop invocation should turn `unsafe` into a
non-zero exit (the enforcement posture) versus advisory locally. Defer unless the backstop
spec requires it here.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Why

The backend is moving to **server-owned rule reconciliation** (TSKL-270): the server, not
the CLI, decides which rule files may run. This is the CLI-side enabler for a new class of
server-blessed rules (including runtime rules) — instead of executing whatever YAML happens
to sit in `.taskless/rules/`, the CLI must report the rule files it holds and execute only
the subset the server returns as `run`. Today `taskless check` runs every `.yml` in the
rules directory with no notion of authenticity or drift; the frozen `POST /cli/api/reconcile`
contract lets us close that gap now, ahead of the endpoint going live in every environment.

## What Changes

- Add a canonical **rule-signature** module (`normalize()` + `canonicalHash()` +
`parseSignature()`, algoVersion `1`, `1;h=sha-256;d=<hex>`) built on web-standard
`crypto.subtle` + `TextEncoder`, byte-for-byte matching the server reference.
- Add a **conformance test** that fetches `GET /cli/api/rule-hash-vectors`, commits the
fixture, and asserts the local hasher reproduces every vector's signature exactly (a
mismatch is a release blocker).
- Add a **reconcile API client** for `POST /cli/api/reconcile` that reports every rule file
under `.taskless/rules/` as `{ file, signature }` and parses the `run` / `unsafe` /
`unknown` / `missing` response.
- **Make `taskless check` behavior depend on auth state** (it still requires no auth):
- **No token** → run all local rules, no network (today's offline linter posture).
- **Authenticated** → reconcile, execute **exactly** the server's `run` set (matched back
to local files by signature), and **warn on mismatches** (`unsafe` / `unknown` /
`missing`) without changing the exit code.
- **`--anonymous`** → force the logged-out path (run local, no reconcile call).
- Add a **degrade path**: when an authenticated reconciliation cannot complete (no git
remote, or the endpoint is unreachable / not yet deployed), `check` warns that verification
could not be performed and falls back to a local scan without failing — so the not-yet-live
endpoint never bricks `check`.
- Add a stable `RECONCILE_FAILED` error code to the CLI error enum for `--json` callers.

Out of scope (deliberately, by team decision): how a server-owned rule (including a runtime
rule) _executes_. The reconciliation contract is rule-type-agnostic — it decides which files
run, not how a runtime rule is evaluated. Rule execution is a **separate proposal, landed as a
stacked PR on top of this work**; this change only makes such rules gate-able.

## Capabilities

### New Capabilities

- `cli-rule-reconciliation`: the rule-signature envelope and `normalize()` procedure, the
conformance-vector contract, the `POST /cli/api/reconcile` client, the four response
buckets and their required CLI actions, and the run-set-only execution rule.

### Modified Capabilities

- `cli-check`: `check` chooses its behavior from auth state — unauthenticated/`--anonymous`
runs all local rules; authenticated reconciles and executes only the `run` set, warning on
`unsafe` / `unknown` / `missing`, and degrading to a local scan if reconciliation can't complete.

## Impact

- **Code:** new `packages/cli/src/rules/rule-hash.ts` and reconcile client under
`packages/cli/src/api/`; changes to `src/commands/check.ts` (reconcile-then-gate flow) and
`src/filesystem/sgconfig.ts` / rule enumeration (scan only the blessed set); new error code
in `src/types/errors.ts`.
- **APIs consumed:** `POST /cli/api/reconcile` (Bearer `<cli-token>`, `repositoryUrl` +
reported files) and `GET /cli/api/rule-hash-vectors` (unauthenticated, for conformance).
- **Behavioral shift:** `check` gains an auth-state-dependent path — authenticated runs get a
server-owned allow-list and mismatch warnings; unauthenticated/`--anonymous` runs are
unchanged (all local rules, no network).
- **Tests:** new conformance-vector test and reconcile/gating tests (vitest, subprocess
against `dist/`, temp-dir fixtures per existing conventions).
- **Docs:** `check.txt` / `ci.txt` help updated to explain the run-set gate and the CI backstop.
Loading
Loading