Skip to content

feat(cli): server-owned rule reconciliation - #47

Merged
thecodedrift merged 21 commits into
mainfrom
jakob/server-owned-rule-reconciliation
Jul 6, 2026
Merged

feat(cli): server-owned rule reconciliation#47
thecodedrift merged 21 commits into
mainfrom
jakob/server-owned-rule-reconciliation

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Jul 3, 2026

Copy link
Copy Markdown
Member

Add the CLI side of server-owned rule reconciliation (TSKL-270). Instead of running whatever YAML sits in .taskless/rules/, taskless check now reports the rule files it holds to the server and executes only the subset the server returns as run.

What changed

  • Canonical rule-signature module (src/rules/rule-hash.ts) — normalize() + canonicalHash() + parseSignature() for the 1;h=sha-256;d=<hex> envelope, built on web-standard crypto.subtle + TextEncoder (no node:crypto) so it reproduces the server reference byte-for-byte.
  • Conformance vectors — committed fixture (test/fixtures/rule-hash.vectors.json) plus a prebuild-wired resilient fetch that refreshes from GET /cli/api/rule-hash-vectors and falls back to the committed cache offline. A test asserts our hasher reproduces all 13 vectors exactly (release-blocking on mismatch).
  • Reconcile client (src/api/reconcile.ts) — POST /cli/api/reconcile returning a discriminated ok / unauthorized / unavailable outcome that never throws on expected network/auth/not-deployed conditions.
  • check gating — behavior is driven by auth state: logged out (or --anonymous) scans all local rules silently; authenticated reconciles and runs only the blessed run set (materialized into a gitignored .taskless/.run/rules/), warning on unsafe/unknown/missing; a failed reconcile degrades to a local scan with a notice. Warnings are stderr-only, suppressed under --json, and never affect the exit code.
  • Helpcheck.txt gains a "What runs (auth state)" section; ci.txt documents the optional TASKLESS_TOKEN backstop as the enforcement point.

Why

The backend is moving to server-owned reconciliation: the server, not the CLI, decides which rule files may run. This closes the gap where check executed any YAML on disk with no notion of authenticity, and is the enabler for a new class of server-blessed rules.

Notes for reviewers

  • check still requires no auth. The new network dependency is strictly additive and gated on auth state; the offline/anonymous path is unchanged (all local rules, no network).
  • The reconcile endpoint may not be deployed in every environment on day one — the client degrades (never fails) on 404/405/5xx/transport errors, so check keeps working. Verified against the live origin (401 for a bad token, 405 on one probe; both degrade).
  • Out of scope by design: how a server-owned/runtime rule executes. Reconciliation gates which files run; execution is a separate follow-up (stacked on this).

Refs TSKL-270

🤖 Generated with Claude Code

Stack (root → tip):

@thecodedrift
thecodedrift marked this pull request as ready for review July 3, 2026 19:18
Copilot AI review requested due to automatic review settings July 3, 2026 19:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds the CLI implementation for server-owned rule reconciliation: taskless check can now (when authenticated) report local rule signatures to the server, receive a blessed run set, and run only that subset; it also introduces a canonical hashing/signature format with conformance vectors and supporting docs/spec updates.

Changes:

  • Added canonical rule signature hashing (normalize + canonicalHash + parseSignature) plus conformance vectors fixture + tests to ensure byte-for-byte compatibility with the server reference.
  • Implemented a reconcile client (POST /cli/api/reconcile) and updated check to gate rule execution to the server-provided run set when authenticated, with a degrade-to-local-scan path on expected failures.
  • Added run-set materialization into .taskless/.run/rules, updated sgconfig generation to point at alternate rule dirs, and expanded CLI help + OpenSpec specs accordingly.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/cli/src/rules/rule-hash.ts Canonical normalization + hashing and signature parsing for reconciliation.
packages/cli/test/rule-hash.test.ts Conformance + invariant tests to ensure hash compatibility.
packages/cli/test/fixtures/rule-hash.vectors.json Committed cross-repo vectors fixture used as release gate.
packages/cli/scripts/fetch-rule-hash-vectors.ts Best-effort refresh of vectors from the API with offline cache fallback.
packages/cli/package.json Wires vectors refresh into prebuild and adds a generate script.
packages/cli/src/api/reconcile.ts Hand-typed reconcile client returning ok/unauthorized/unavailable.
packages/cli/src/rules/run-set.ts Signs rule files, selects run set by signature, materializes .run/ rules dir.
packages/cli/src/filesystem/sgconfig.ts Allows targeting ruleDirs at a configurable rules directory.
packages/cli/src/commands/check.ts Auth-state-driven gating/degrade behavior; warnings suppressed under --json.
packages/cli/test/run-set.test.ts Unit tests for signing, selection, and run-dir materialization.
packages/cli/test/reconcile-check.test.ts Integration tests for gating/degrade behavior via mock server + dist CLI.
packages/cli/src/help/check.txt Documents “What runs (auth state)” behavior.
packages/cli/src/help/ci.txt Documents optional CI token backstop as enforcement point.
packages/cli/src/types/errors.ts Adds RECONCILE_FAILED error code (enum extension).
openspec/specs/cli-rule-reconciliation/spec.md New spec for hashing + reconcile buckets + execution rules.
openspec/specs/cli-check/spec.md Updates check spec for auth-state gating and reconcile/degrade behavior.
openspec/changes/archive/2026-07-02-server-owned-rule-reconciliation/* Archived proposal/design/tasks/spec deltas for this change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/scripts/fetch-rule-hash-vectors.ts
Comment thread packages/cli/src/rules/rule-hash.ts
thecodedrift added a commit that referenced this pull request Jul 4, 2026
Address Copilot review on #47:

- fetch-rule-hash-vectors.ts: a 200 response with a non-JSON body threw an
  uncaught error and broke the prebuild even when a committed cache existed.
  Wrap response.json() and fall back to the cache like every other failure.
- rule-hash.ts: the parseSignature JSDoc claimed it throws on an 'unsupported'
  envelope, but it deliberately tolerates unknown future algoVersions
  (forward-compat). Correct the comment to match the implementation and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thecodedrift and others added 21 commits July 6, 2026 13:48
normalize() + canonicalHash() + parseSignature() implementing the
algoVersion-1 signature envelope for server-owned rule reconciliation
(TSKL-270), on web-standard crypto only to match the server reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Commit the cross-repo vectors fixture and a prebuild-wired fetch that
refreshes from GET /cli/api/rule-hash-vectors, falling back to the
committed cache offline. Conformance test asserts our hasher reproduces
every vector exactly (release-blocking on mismatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /cli/api/reconcile client returning a discriminated
ok/unauthorized/unavailable outcome that never throws on expected
network/auth/not-deployed conditions, so check can degrade to a local
scan. Adds the RECONCILE_FAILED error code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
check now picks its behavior from auth state: unauthenticated/--anonymous
scans all local rules silently; authenticated reconciles and runs only
the blessed run set, warning on unsafe/unknown/missing; a failed reconcile
degrades to a local scan with a notice. Warnings are suppressed under
--json and never affect the exit code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
check.txt gains a "What runs (auth state)" section; ci.txt step 7
documents the optional TASKLESS_TOKEN backstop as the enforcement point
over the server-blessed run set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unit tests for the run-set helpers and mock-server integration tests
proving check runs only the blessed run set, degrades on an unavailable
endpoint, stays silent when logged out or --anonymous, and warns on
unsafe/unknown/missing without affecting the exit code or --json output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies the delta specs to the living specs (new cli-rule-reconciliation
capability; cli-check gains auth-state gating, warnings, degrade, and
run-set-only execution) and archives the completed change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the OpenSpec change defining how a runtime rule executes, stacked on
server-owned rule reconciliation. Reconciliation gates which files may run;
this proposal defines the local harness that evaluates a runtime rule.

A runtime rule is a directory under .taskless/runtime-rules/ (capture *.yml +
a check.ts). The harness assembles the capture rules into one ast-grep narrow,
gates on matches, then invokes check.ts's default export via a bundled tsx.
check.ts is arbitrary code execution, so it runs only when its signature is
validated by reconciliation, or under --dangerously-run-scripts. Static
ast-grep rules and inert capture *.yml are never gated. Adds the five auth/flag
modes, a --timeout bound, and an additive --json skipped field.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified against workers/generator/src/actions/add-runtime-rule.ts: runtime
rules are written to .taskless/runtime-rules/<slug>-<suffix>/ (one <name>.yml
per capture rule + a check.ts), with fixtures under
.taskless/runtime-rule-tests/. The check file is always check.ts, and its bytes
are hashed with the same canonicalHash envelope reconcile uses — so gating on
check.ts matches what the server captures. Tighten the recognition spec (drop
the metadata.taskless.check locator claim; check.ts is fixed) and record the
confirmation in design.md.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group 1 of runtime-rule-execution. Add the harness-side recognition of runtime
rules: a directory under .taskless/runtime-rules/ holding kind: runtime capture
*.yml plus a check.ts.

- src/types/runtime-rule.ts mirrors the structural harness<->check contract
  (Finding, Match, CheckFunction, CaptureRule, metadata block) from the
  generator's @taskless/types; a delivered check imports nothing, so the
  contract is structural.
- src/rules/runtime/discover.ts enumerates .taskless/runtime-rules/, parses each
  capture *.yml, confirms kind: runtime, and returns a typed RuntimeRule
  (capture rules with id/name/language/match + the check.ts path).
  .taskless/runtime-rule-tests/ is never enumerated.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Groups 2-3 of runtime-rule-execution. Implement the local harness that
evaluates a runtime rule, plus the bundled tsx loader it runs check.ts under.

- narrow.ts: run a rule's capture rules as ONE ast-grep scan (anchor
  --json=stream, broad --files-with-matches) and normalize matches to the
  contract shape (0-indexed -> 1-indexed, ruleId -> model name, captures from
  metaVariables). Uses a temp --config rules dir so multiple captures + full
  ast-grep config run in a single scan.
- invoke.ts: run check.ts's default export (root, matches) via a pinned tsx
  resolved at runtime (no repo toolchain). An embedded ESM runner writes the
  returned Finding[] to an out-file; a throw, non-zero exit, or timeout is
  isolated to an error result. Default 10s bound, overridable.
- harness.ts: narrow -> gate-on-matches -> invoke -> map Finding to CheckResult
  (source: taskless-runtime); process-per-check, sequential.
- Add tsx to the CLI dependencies (externalized from the Vite bundle).

Verified end-to-end against a temp-dir fixture: discovery, narrow (correct
line normalization + rule attribution), tsx invocation, and finding mapping.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d rules

Group 4 of runtime-rule-execution. Add the runtime reconcile-set: sign each
rule's check.ts (only) for reconciliation, select the rules the server blessed,
and materialize them for read-hash-execute.

- src/rules/runtime/run-set.ts: signRuntimeChecks (check.ts only — capture *.yml
  are inert), reportRuntimeChecks -> { file, signature }, selectBlessedRuntimeRules
  (content-join: a rule runs iff its check.ts signature is in the run set; the
  rest are withheld/advisory), and materializeRuntimeRules (copy blessed rule
  dirs into .taskless/.run/runtime-rules/ and re-discover so execution uses the
  blessed bytes; .run/ stays gitignored).
- discover.ts: extract discoverRuntimeRulesIn(root) so materialized rules can be
  re-discovered from .run/.
- narrow.ts: copy the original capture *.yml bytes into the temp config instead
  of re-serializing the parsed object — a YAML round-trip can alter an exotic
  ast-grep config.

Verified end-to-end: report only check.ts, bless on signature match, materialize
to .run/, execute the materialized copy; empty run set withholds the rule.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ile gating

Group 5 of runtime-rule-execution. Rewire `taskless check` around the two rule
classes and complete the cutover to signing only untrusted code.

- Static ast-grep rules under .taskless/rules/ always run (trusted, no network).
- Runtime rules run only on a validated path: planRuntime resolves the mode from
  auth state + flags — authed reconcile runs blessed rules and withholds the rest
  (advisory); logged-out / --anonymous / no-remote / reconcile-unavailable skip
  runtime with a notice; --dangerously-run-scripts runs all runtime rules with no
  network behind a loud warning.
- Add --dangerously-run-scripts and --timeout <seconds>; runtime findings merge
  into the same results and exit-code logic; --json gains an additive optional
  `skipped` array (schema updated), warnings/notices stay stderr-only.
- Fix a Finding->CheckResult off-by-one: findings are 1-indexed, CheckResult.range
  is 0-indexed (display/json add 1).

Cutover: remove the stacked-under static-reconcile gating — delete
src/rules/run-set.ts and the now-obsolete test/reconcile-check.test.ts +
test/run-set.test.ts (runtime-dispatch tests land in Group 7). Static rules are
no longer signed or gated; only runtime check.ts is.

Verified end-to-end via the built CLI: static-only-runs (runtime skipped +
notice), --dangerously-run-scripts (both run), and --json (skipped array,
warnings suppressed). Full CLI suite green (326 tests).

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Groups 6-7 of runtime-rule-execution.

Docs (Group 6):
- check.txt (topic v2): static rules always run; runtime check.ts runs only when
  server-verified; the mode table, --dangerously-run-scripts, --timeout, and the
  --json skipped array.
- ci.txt: unauthenticated CI runs static rules and skips runtime; the
  TASKLESS_TOKEN backstop is the enforcement point for runtime check.ts.

Tests (Group 7):
- runtime-harness.test.ts: discovery, gate-on-zero-matches (check never invoked),
  match normalization + Finding->CheckResult indexing, throwing-check isolation,
  timeout -> error finding.
- runtime-check.test.ts: end-to-end dispatch via the built CLI with a mock
  reconcile server + git origin — authed-blessed, empty-run withheld, logged-out
  and --anonymous skip + report, reconcile-unavailable skips, dangerously-run-
  scripts runs offline; asserts static always runs and only check.ts is reported.

Also fix a real timeout bug found by the harness test: tsx re-execs node as a
grandchild, so spawn detached and SIGKILL the whole process group — otherwise a
runaway check keeps running past the timeout. Full suite green (338 tests).

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntation

Verification (opsx:verify) caught a spec-vs-impl divergence: the narrow
requirement, proposal, and design named `--inline-rules --json=stream`, but the
harness assembles the capture rules into a temp `--config` (--inline-rules
carries only one rule; a runtime rule has multiple capture rules + full ast-grep
config). Reworded to "one scan per mode" via a generated config so the spec that
gets synced to canonical on archive matches reality.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync the runtime-rule-execution delta specs into the canonical specs and archive
the change (verify → sync → archive).

- cli-runtime-rule-execution: new canonical spec (the runtime harness contract).
- cli-check: static-vs-runtime dispatch, the validated-path rule, skip+report,
  and --dangerously-run-scripts added; auth-state/reconcile/degrade requirements
  updated for the cutover; the now-obsolete 'warns on reconciliation mismatches'
  and 'exits cleanly when the run set is empty' requirements removed (their
  static-reconcile behavior was deleted in the cutover — the delta records the
  removal with reasons).
- cli-rule-reconciliation: reporting + run-set requirements rescoped to each
  runtime rule's check.ts.

Change archived to openspec/changes/archive/2026-07-03-runtime-rule-execution/;
no unarchived changes remain, so the tip's check-openspec-archived job passes.

Refs TSKL-245
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Copilot review on #47:

- fetch-rule-hash-vectors.ts: a 200 response with a non-JSON body threw an
  uncaught error and broke the prebuild even when a committed cache existed.
  Wrap response.json() and fall back to the cache like every other failure.
- rule-hash.ts: the parseSignature JSDoc claimed it throws on an 'unsupported'
  envelope, but it deliberately tolerates unknown future algoVersions
  (forward-compat). Correct the comment to match the implementation and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Copilot review on #49: the 'Blessed runtime rules execute from the
materialized run directory' requirement, its scenario, and the design decision
referred to the live tree as `.taskless/rules/`, but runtime rules live under
`.taskless/runtime-rules/`. Correct all three so the read-hash-execute guarantee
names the right tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six fixes from Copilot's review of #50, plus the canonical-spec twin of the #49
doc fix:

- invoke.ts: on Windows, kill the check via taskkill /T (negative PIDs aren't
  supported), so a timeout actually terminates the tsx+node tree.
- discover.ts: pin the check file to check.ts inside the rule dir; do not resolve
  metadata.taskless.check as a path (prevents escaping the dir via ../).
- run-set.ts: signRuntimeChecks is now per-rule resilient (returns unreadable
  rules instead of throwing); reported reconcile paths are POSIX-normalized so
  Windows backslashes don't defeat the server-side path match.
- check.ts: a missing/unreadable check.ts is reported as skipped and
  materialization errors degrade to a runtime-skip — a malformed runtime rule no
  longer aborts the whole check (static keeps running).
- narrow.ts: run one broad scan per broad capture rule so matches are attributed
  to the right rule (was mislabeling all broad matches as the first rule); treat
  a signal-killed narrow (null exit code) as a failure instead of success.
- specs/cli-runtime-rule-execution: fix the materialize requirement's live-tree
  path (.taskless/rules/ -> .taskless/runtime-rules/) to match #49.

Add an integration test: a runtime rule missing check.ts is skipped (not fatal)
and static rules still run. Full suite green (339).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the remaining #49 review comments (items 1-4, 6-7):

- cli-check: the degrade --json scenario now allows the additive `skipped`
  array (not 'only { success, results }'); --dangerously-run-scripts is
  described as 'without server validation' rather than 'trusting local
  signatures' (which the reconciliation spec forbids as an auth mechanism).
- cli-rule-reconciliation: rename the requirement heading to 'Reconcile reports
  every runtime rule's check.ts' (RENAMED op) so the title matches the scoped
  body instead of the old 'every held rule file'.
- cli-runtime-rule-execution: clarify that match mode is read per capture rule
  (rules may mix modes); state that a broad path-only match carries line/column
  = 1 and empty text/captures.
- design: note the generator path lives in the internal taskless/taskless repo,
  not this one.

(Item 5 — the 'exactly as before' wording — is left as-is by decision.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Propagate the #49 review-comment fixes into the canonical specs (the archived
change copy came via merge):

- cli-check: degrade --json scenario allows the additive skipped array;
  --dangerously-run-scripts described as 'without server validation'.
- cli-rule-reconciliation: heading renamed to 'Reconcile reports every runtime
  rule's check.ts'.
- cli-runtime-rule-execution: per-capture match mode; broad matches are
  path-only (line/column 1, empty text/captures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thecodedrift
thecodedrift force-pushed the jakob/server-owned-rule-reconciliation branch from 44cfaa5 to 949565d Compare July 6, 2026 20:48
@thecodedrift
thecodedrift merged commit 07ef558 into main Jul 6, 2026
3 checks passed
@thecodedrift
thecodedrift deleted the jakob/server-owned-rule-reconciliation branch July 6, 2026 20:49
@github-actions github-actions Bot mentioned this pull request Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants