diff --git a/.captain/review/loop-log.md b/.captain/review/loop-log.md new file mode 100644 index 0000000..9d68fd8 --- /dev/null +++ b/.captain/review/loop-log.md @@ -0,0 +1,10 @@ +# Review loop log + +Track escapes, fixer regressions, and routing misses so the review policy +can be recalibrated with evidence (not vibes). One row per non-trivial +incident or completed high-risk unit. + +| Date | Unit / PR | Tier | Reviewers used | Escaped bug? | Fixer regression? | Notes / policy change | +|---|---|---|---|---|---|---| +| | | | | | | | +Also see specs/reviews/loop-log.md for historical review notes. diff --git a/.captain/review/prompts/fix-verify.md b/.captain/review/prompts/fix-verify.md new file mode 100644 index 0000000..6b1efb9 --- /dev/null +++ b/.captain/review/prompts/fix-verify.md @@ -0,0 +1,23 @@ +# Fix verify — finding-owner re-check + +You filed or owned the original finding(s). A fixer has landed changes. Your +job is to verify each finding is actually resolved **without new regressions**. +You are not the implementer of the fix. + +## For each original finding +1. Restate the finding in one line. +2. Point at the fix (path + symbol). +3. Verdict: **resolved** | **partial** | **not fixed** | **regressed** +4. Evidence: test/command output, or a concrete scenario that still fails. +5. If severity was critical/high: confirm a **regression test** exists and fails + without the fix (or explain why an oracle already covered it). + +## Also check +- Fix did not weaken the oracle to go green +- Fix did not expand scope beyond the finding +- No new trust-boundary holes adjacent to the change + +## Output +A short checklist per finding + an overall **approve-to-gate** or +**return-to-fixer** (with remaining items). After two fixer cycles without +clearance, escalate to a human — do not start a third cycle. diff --git a/.captain/review/prompts/implementation-adversary.md b/.captain/review/prompts/implementation-adversary.md new file mode 100644 index 0000000..66e5bfc --- /dev/null +++ b/.captain/review/prompts/implementation-adversary.md @@ -0,0 +1,28 @@ +# Pass B — implementation adversary + +You are an adversarial reviewer focused on **how** the change is built. You +did **not** write this code. Assume Pass A (or the spec) already covers +product intent — you hunt integration and engineering defects. + +## Inputs you must use +1. Diff / changed files +2. Surrounding call sites and existing primitives (reuse-first) +3. Spec only where needed to judge API contracts +4. Bugbot flags as a suspect map + +## Look for +- API / lifecycle misuse (init order, cancel, resource cleanup, thread/actor isolation) +- Integration assumptions that can fail in production +- Duplicated or near-duplicated primitives that should have been reused +- Error handling that swallows, misclassifies, or loses context at boundaries +- Maintainability traps that will force a paragraph-long comment later + (if justifying a workaround needs a paragraph, the code is wrong — flag it) + +## Output format +For each finding (or "no findings"): +- **Severity:** critical | high | medium | low +- **Location:** path + symbol / line range +- **Failure mode:** what breaks, under what condition +- **Fix direction:** one sentence (do not implement) + +Do **not** re-litigate product scope. Do **not** implement fixes on this pass. diff --git a/.captain/review/prompts/semantic-adversary.md b/.captain/review/prompts/semantic-adversary.md new file mode 100644 index 0000000..c96b482 --- /dev/null +++ b/.captain/review/prompts/semantic-adversary.md @@ -0,0 +1,30 @@ +# Pass A — semantic adversary + +You are an adversarial reviewer. You did **not** write this code. Your job is +to find real correctness bugs against the **locked spec and oracle**, not to +nitpick style. + +## Inputs you must use +1. Spec / plan sections named in the PR +2. Oracle (tests, goldens, property cases) — ground truth +3. Diff / changed files only (unless a dependency is required to understand a call) +4. Bugbot flags (suspect map only — do not treat as settled) + +## Look for +- Spec mismatch / missing states / wrong transitions +- Failure and recovery paths (crash, retry, partial write, cancel) +- Concurrency / ordering / idempotency hazards +- Privilege / trust-boundary violations (authz, grants, egress, redaction) +- Oracle gaps: behavior the spec requires but no test asserts + +## Output format +For each finding (or "no findings"): +- **Severity:** critical | high | medium | low +- **Location:** path + symbol / line range +- **Failing scenario:** concrete inputs → wrong observable outcome +- **Spec/oracle cite:** which requirement or test is violated (or missing) +- **Fix direction:** one sentence (do not implement) + +Do **not** rewrite the code. Do **not** weaken the oracle. If the oracle is +wrong, say so as a finding ("oracle amendment required") rather than blessing +the implementation. diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 0000000..6a94598 --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,21 @@ +# Bugbot project context (captain-workflow) + +Mechanical first-pass only. Do **not** spend review budget on style, naming +nits, or formatting — the deterministic gate already owns those. + +## Prioritize +- Correctness bugs with a concrete failing scenario +- Security / trust-boundary issues (authz, grants, secrets, egress, injection) +- Missing or weakened tests for new behavior +- Crash / data-loss / concurrency hazards + +## How to report +- Severity + confidence +- Exact location +- Concrete inputs → wrong observable outcome +- Skip low-confidence style comments + +## Never treat this pass as +Semantic approval, spec conformance, or a substitute for Pass A / Pass B / +human Judge on trust-critical paths. See `review-policy.yaml` and +`~/.claude/skills/captain-workflow/REVIEW.md`. diff --git a/.cursor/rules/model-routing.mdc b/.cursor/rules/model-routing.mdc new file mode 100644 index 0000000..0c1c355 --- /dev/null +++ b/.cursor/rules/model-routing.mdc @@ -0,0 +1,42 @@ +--- +description: Model routing + gate discipline for this repo (captain-workflow) +alwaysApply: true +--- + +# Model routing (captain-workflow) + +This repo is gated by `scripts/check_all.py` / `scripts/check-all.mjs`, +wired to run on every `git commit` via `core.hooksPath`. That gate applies +no matter which model generated the diff — never bypass it (`--no-verify`) +without a declared emergency. + +Full policy + rationale: `~/.claude/skills/captain-workflow/MODEL-ROUTING.md`. + +## Default model choice here + +- **Routine/small build tasks** (extends an existing pattern, one reasonable + approach, contained to one file or a small set): default to Grok 4.5 or + GPT-5.6 Terra. Plan and build in one prompt — no separate planning pass + needed for this class. +- **Correctness-critical pieces** (auth, payments, data integrity, a tricky + algorithm, anything the spec doc flags as load-bearing or risky): escalate + to Opus 4.8, selected manually. +- **Stuck / hard cases** (Grok/Terra already failed 1-2 tries, math-heavy or + algorithm-heavy work, a large multi-file refactor): escalate to GPT-5.6 + Sol, selected manually — never as an Auto default. +- Run Bugbot before human review on `semantic` / `trust-critical` changes + (and on routine PRs ≥50 lines). Bugbot is mechanical only — never + semantic approval. See `review-policy.yaml` + `.cursor/rules/review.mdc`. + +## Scope discipline + +Treat any spec/plan doc in this repo's spec/plan directory (e.g. `specs/`, +`docs/specs/` — check what this repo actually uses) as locked scope — it was +written in the Frame/Spec phase, typically in Claude. Don't improvise +outside it. + +If a task turns out to have no precedent in the codebase, real tradeoffs +between valid approaches, multi-file/system reach, or touches +auth/payments/data-integrity — that's "big/new," not routine. Flag it back +rather than building ad hoc; that class of work should get planned first +(Claude, Frame/Spec phases), not executed straight from a prompt here. diff --git a/.cursor/rules/review.mdc b/.cursor/rules/review.mdc new file mode 100644 index 0000000..db16976 --- /dev/null +++ b/.cursor/rules/review.mdc @@ -0,0 +1,36 @@ +--- +description: Adversarial review loop + risk tiers (captain-workflow) +alwaysApply: true +--- + +# Review discipline (captain-workflow) + +Full policy: `~/.claude/skills/captain-workflow/REVIEW.md`. +Per-repo path → risk map: `review-policy.yaml` at repo root. + +## Loop (do not skip) + +`implementer → Pass A (semantic) + Pass B (impl) → fixer → finding-owner +verifies → gate → human Judge if trust-critical` + +- Implementers do not review their own unit. +- Fixer never has the last word — original finding-owner verifies. +- Max 2 fixer cycles, then human escalate. +- Critical findings need a regression test with the fix. + +## Risk (path floor, not LOC) + +Compute minimum tier from `review-policy.yaml` globs. Trust-critical paths +(grants/policy/auth/event-log/protocol/redaction/payments) **cannot** be +declared `routine`. Bugbot is mechanical only — never semantic approval. + +## Oracle + +For `semantic` / `trust-critical`: oracle author ≠ implementer. Do not +weaken tests to go green; amend oracle + spec as a separate change. + +## Prompts + +Use `.captain/review/prompts/` — `semantic-adversary.md`, +`implementation-adversary.md`, `fix-verify.md`. Do not run two identical +“review this diff” agents. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..808073a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,35 @@ +# CODEOWNERS — human approval on trust-critical paths +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# +# Replace @akminx with your GitHub username or team (captain-init tries to +# fill this from `gh api user` when available). Customize path globs to match +# review-policy.yaml trust_critical_paths for this repo. + +# Default owner for everything (optional — remove if you only want critical paths) +# * @akminx + +# Trust-critical defaults — edit to match this repo +**/auth/** @akminx +**/authz/** @akminx +**/grants/** @akminx +**/policy/** @akminx +**/permissions/** @akminx +**/billing/** @akminx +**/payment*/** @akminx +**/redaction/** @akminx +**/eventlog/** @akminx +**/event-log/** @akminx +**/protocol/** @akminx + +# Gilly trust-critical (INVARIANTS) +specs/grants.md @akminx +core/**/grants/** @akminx +core/**/policy/** @akminx +core/**/eventlog/** @akminx +core/**/event-log/** @akminx +core/src/session/** @akminx +specs/protocol/** @akminx +**/redaction/** @akminx +**/redaction*.ts @akminx +core/src/surface/redaction.ts @akminx +INVARIANTS.md @akminx diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c8f2471 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## Summary + + +## Risk tier + + +- [ ] `routine` +- [ ] `semantic` +- [ ] `trust-critical` + +Path-derived minimum (if known): ___________ + +## Oracle / evidence + + +- Spec section(s): +- Oracle path (tests/goldens/properties): +- Command run + result: + +## Review topology (from review-policy.yaml) + +- [ ] Deterministic gate green (`check_all` / CI) +- [ ] Bugbot (mechanical only — not semantic approval) +- [ ] Pass A — semantic adversary +- [ ] Pass B — implementation adversary (required for semantic+) +- [ ] Finding-owner verified fixer changes (if any) +- [ ] Human Judge (required for trust-critical) +- [ ] CODEOWNERS approval (trust-critical paths) + +## Implementer model family + + +## Unresolved assumptions + + +## Test plan +- [ ] diff --git a/.github/branch-protection.md b/.github/branch-protection.md new file mode 100644 index 0000000..2b2f461 --- /dev/null +++ b/.github/branch-protection.md @@ -0,0 +1,36 @@ +# Branch protection checklist (GitHub) + +Local hooks are bypassable (`--no-verify`). Mirror the gate in CI and lock +the default branch so trust-critical review cannot be skipped when someone +is tired. + +Run once per repo (Settings → Branches → Branch protection rules), or via +`gh api` if you prefer automation. captain-init does **not** apply these +automatically — they need repo admin rights. + +## Required settings + +- [ ] Require a pull request before merging +- [ ] Require status checks to pass: `check-all-node` and `check-all-swift` + (the jobs `.github/workflows/check-all.yml` defines — the Swift job runs + the client gate on macOS) +- [ ] Require branches to be up to date before merging (optional but good) +- [ ] Require review from Code Owners (uses `CODEOWNERS`) +- [ ] Dismiss stale pull request approvals when new commits are pushed +- [ ] Do **not** allow author self-approval (GitHub: disable bypass for + admins on critical repos if you can live with it) +- [ ] Block force pushes and deletions on the default branch + +## Trust-critical extra + +For repos with money / authz / safety surface: require at least one +approving review from someone other than the PR author, even when CI is +green. CODEOWNERS on paths listed in `review-policy.yaml` is the mechanism. + +## Verify + +```sh +gh api "repos/{owner}/{repo}/branches/{branch}/protection" --jq . +``` + +If this 404s, protection is not configured yet. diff --git a/.github/workflows/check-all.yml b/.github/workflows/check-all.yml index 6423825..68c115f 100644 --- a/.github/workflows/check-all.yml +++ b/.github/workflows/check-all.yml @@ -5,6 +5,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: # core/package.json requires >=22; the Pi SDK (0.80.10) needs @@ -13,8 +15,57 @@ jobs: node-version: "22" - name: Install project deps (CI parity with local node_modules) run: | - for pj in $(find . -name node_modules -prune -o -name package.json -print); do + for pj in $(find . \( -name node_modules -o -name .git -o -name .claude -o -name .worktrees -o -name worktrees \) -prune -o -name package.json -print); do (cd "$(dirname "$pj")" && (npm ci || npm install)) done - run: node scripts/check-all.mjs - run: node scripts/arch-checks.mjs + - name: Path-derived risk tier + if: github.event_name == 'pull_request' + env: + GILLY_TIER_RANGE: "origin/${{ github.base_ref }}...HEAD" + run: node scripts/check-tier.mjs --ci + - name: Path-derived risk tier (push) + if: github.event_name == 'push' + env: + BEFORE: "${{ github.event.before }}" + # Diff the pushed commits (not the clean checkout working tree). + GILLY_TIER_RANGE: "${{ github.event.before }}...${{ github.sha }}" + run: | + # Brand-new branch: github.event.before is all-zeros and the range is + # unresolvable. Skip here (informational) and rely on the pull_request + # tier check, which resolves against base_ref. Any other unresolvable + # range still fails loudly inside check-tier.mjs. + case "$BEFORE" in + ""|0000000000000000000000000000000000000000) + echo "check-tier: new-branch push (before=$BEFORE); skipping — the PR tier check enforces the floor." + exit 0 + ;; + esac + # Direct pushes must declare via commit trailer when above routine: + # Risk-Tier: semantic|trust-critical + TIER=$(git log -1 --pretty=%B | sed -nE 's/^Risk-Tier:[[:space:]]*(routine|semantic|trust-critical)[[:space:]]*$/\1/ip' | head -1) + if [ -n "$TIER" ]; then + node scripts/check-tier.mjs --ci --declare="$TIER" + else + # --ci with GILLY_TIER_RANGE set: still uses the range for files; + # missing declaration fails when path minimum > routine. + node scripts/check-tier.mjs --ci + fi + + check-all-swift: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install Swift lint tooling + run: | + brew install swiftlint || true + # swift-format ships with the Xcode/Swift toolchain on macos-latest + - name: Install Node deps for protocol codegen parity + run: | + (cd core && (npm ci || npm install)) + - name: Swift gate (full client) + run: ./scripts/check-swift.sh --all diff --git a/CLAUDE.md b/CLAUDE.md index 9c1acf5..cc58520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,16 +43,24 @@ distinctions that must never collapse. lands — Node first). Swift side: `scripts/check-swift.sh` (SwiftLint, swift-format, `swift build`, `swift test` + llvm-cov coverage ratchet, suppression ceiling on `swiftlint:disable`/`try!`/force-unwraps, - osv-scanner on Package.resolved; semgrep for SAST best-effort). Client is + osv-scanner on Package.resolved; semgrep advisory-only — missing + tools never count as a green pass). Client is split into SPM packages so the pre-commit gate builds/tests only touched - packages; CI builds the full app. Never `--no-verify`. + packages; CI builds the full app (Node + macOS Swift jobs). Never `--no-verify`. +- Path-derived risk tier is machine-checked (`scripts/check-tier.mjs` + + `review-policy.yaml`); trust-critical paths cannot self-declare `routine`. - Model routing changes require an eval run; cite the numbers in the PR. ## Build loop (from DESIGN.md §9.5) 1 implementer (context: spec + task + PRIMITIVES.md) → 2 adversarial -reviewers (diff + the oracle's ground truth, never only the code; assume -it's wrong; implementers never review) → 1 fixer → gate. Hard stop: -2 fixer cycles then human escalation; scope mismatch escalates -immediately. Pilot every workflow on one small unit before fanning out. -Recurring bad output ⇒ fix the loop's prompt, not the output. +reviewers with distinct roles (Pass A semantic + Pass B implementation; +diff + the oracle's ground truth, never only the code; assume it's wrong; +implementers never review) → 1 fixer → **finding-owner verifies each fix** +(+ regression test for critical findings) → gate. Hard stop: 2 fixer cycles +then human escalation; scope mismatch escalates immediately. Risk tier is +path-derived (`review-policy.yaml`), not LOC — trust-critical paths always +get human Judge + CODEOWNERS. Full policy: +`~/.claude/skills/captain-workflow/REVIEW.md`. Pilot every workflow on one +small unit before fanning out. Recurring bad output ⇒ fix the loop's +prompt, not the output. diff --git a/DESIGN.md b/DESIGN.md index 619a549..431973a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -349,10 +349,16 @@ Swift actor/concurrency topology. Each spec gets its own adversarial review round before implementation starts. **The loop (every work item):** 1 implementer (context: spec + task + -PRIMITIVES.md) → 2 adversarial reviewers (context: diff + the oracle's -ground truth — spec section, test output, goldens — never only the code; -assume it's wrong; implementers never review, reviewers never implement) -→ 1 fixer → deterministic gate. +PRIMITIVES.md) → 2 adversarial reviewers with **distinct roles** — Pass A +semantic (spec/oracle/privilege) and Pass B implementation (API/lifecycle/ +reuse); context: diff + the oracle's ground truth — spec section, test +output, goldens — never only the code; assume it's wrong; implementers +never review, reviewers never implement) → 1 fixer → **finding-owner +verifies each fix** (critical findings need a regression test) → +deterministic gate. Risk tier is path-derived (`review-policy.yaml`), not +LOC; trust-critical paths always require human Judge + CODEOWNERS. Bugbot +is mechanical only — never semantic approval. Canonical policy: +`~/.claude/skills/captain-workflow/REVIEW.md`. Reviewer rule, verbatim from Bun: *if a paragraph-long comment is needed to justify a workaround, the code is wrong — fix the code.* @@ -372,13 +378,15 @@ justify a workaround, the code is wrong — fix the code.* assumptions made. A submission without executed evidence is returned unread. - **Stop conditions**: max 2 fixer cycles per work item, then human - escalation with full history; per-item cost ceiling; a scope mismatch - (diff solves a different problem than the task) escalates immediately — - it is a judgment failure, not iterable. -- **Judge independence**: reviewer passes default to a different model - family than the implementer (routing has the knob); reviewers see the - diff + the oracle's ground truth (spec section, test output, golden), - never only the code. + escalation with full history; after each fixer pass the **original + finding-owner verifies** the fix (not the fixer); per-item cost ceiling; + a scope mismatch (diff solves a different problem than the task) + escalates immediately — it is a judgment failure, not iterable. +- **Judge independence**: reviewer Pass A and Pass B use distinct prompts + (and prefer different model families on trust-critical units); reviewers + see the diff + the oracle's ground truth (spec section, test output, + golden), never only the code. For semantic/trust-critical units, oracle + author ≠ implementer. - **Escalation log**: every stop-condition trigger is recorded per loop. A loop that escalates repeatedly is miscalibrated — fix the loop's prompt/standard (the existing rule), now with data instead of vibes. diff --git a/INVARIANTS.md b/INVARIANTS.md index 763e945..34c1ea5 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -4,22 +4,26 @@ Distinctions the architecture must never collapse. Each has a guard that can FAIL, wired into the gate. A guard that can't fail is prose. Statement quality matters more than guard code — a wrong invariant that passes is worse than none. +Status legend: **enforced** = failing guard in gate/tests; **partial** = some +guards live, gaps remain; **pending** = needs product surface before a real +guard can fail. + | # | Invariant | Guard | Status | |---|---|---|---| -| 1 | **proposal ≠ execution** — a model-emitted tool call never runs without passing the policy dispatcher | arch-lint: no executor import outside `core/policy/`; contract test | pending | -| 2 | **quick ≠ write** — the quick profile has no write/act tools bound, structurally | property test over profile→tool bindings | pending | -| 3 | **child grants ⊆ parent grants** — delegation never escalates privilege | property tests on grant intersection; fuzz the clamp | pending | -| 4 | **grant ≠ ambient authority** — background sessions load only their persisted grant, never the user's live session grants | contract test: scheduler-spawned session vs live grant store | pending | -| 5 | **memory candidate ≠ memory fact** — nothing durable without review status | DB CHECK constraint on `status`; test that unreviewed rows never reach prompt assembly | pending | -| 6 | **coordinates ≠ truth** — vision-derived geometry always carries its ladder rung; AX/DOM handles win when present | typed `Grounding{rung, confidence}` newtype; lint on raw coord tuples crossing module boundaries | pending | -| 7 | **one writer to the event log** — all task state changes append through the single writer | arch-lint: only `core/eventlog/` touches the tasks tables; SQLite trigger as backstop | pending | -| 8 | **archive ≠ delete** — compaction demotes/summarizes; only the user destroys | property test on consolidation job: row count(archive)+count(active) never decreases without a user-action event | pending | -| 9 | **generated ≠ hand-written** — protocol types come from the schema; drift fails the gate | parity check: regenerate and diff in check-all | pending | -| 10 | **eval floor is a ratchet** — routing/model changes cannot lower grounding/routing floors | ratchet budget file, same semantics as coverage | pending | -| 11 | **extensions ≠ kernel** — bundled extensions use only the public extension API; no private kernel imports | arch-lint: extensions/ may import only core/api/; CI builds each bundled extension against the published API types | pending | -| 12 | **extension ≠ trusted** — every extension registration is bound to declared grants; an extension's tools pass the same policy dispatcher as the model's | contract test: extension-registered tool with no grant is blocked | pending | -| 13 | **capture ≠ egress** — no screen capture (or derived crop) reaches a cloud endpoint without passing the redaction stage; redaction emits an auditable event | contract test: cloud-bound ContextBundle without a matching redaction event fails; arch-lint: model-client module imports images only from the redaction module's output type | pending | -| 14 | **turn ≠ ambient** — every Pi run is driven through `Session.runTurn`/`followUp` (budget gate in, `finishTurn` out); no loop-, queue-, or steer-originated turn exists. Past exhaustion, exactly one salvage turn is legal and it is audited (`salvage.issued` precedes it) | arch-lint: only `core/src/session/` may import the PiLoop seam, so no outside module can drive a turn around `Session`; seam contract test — `steer()` with no turn in flight throws in the double (adapter: drops, never calls `sendUserMessage` while idle); the existing abort-drains-queue test; salvage-bypass contract test lands with the adapter | pending | +| 1 | **proposal ≠ execution** — a model-emitted tool call never runs without passing the policy dispatcher | arch-lint: `executor-stays-in-policy`, `gate-executes-in-dispatcher-only`, `gate-factory-minted-in-composition-only`; `dispatch.property.test.ts`; decide §10.9 | **enforced** | +| 2 | **quick ≠ write** — the quick profile has no write/act tools bound, structurally | property test over profile→tool bindings | **pending** (needs profile binding table) | +| 3 | **child grants ⊆ parent grants** — delegation never escalates privilege | `clamp.test.ts` properties; arch `clamp-two-call-sites` | **enforced** | +| 4 | **grant ≠ ambient authority** — background sessions load only their persisted grant, never the user's live session grants | `session/background.test.ts` | **enforced** | +| 5 | **memory candidate ≠ memory fact** — nothing durable without review status | DB CHECK on `status`; unreviewed rows never reach prompt assembly | **pending** (memory schema not landed) | +| 6 | **coordinates ≠ truth** — vision-derived geometry always carries its ladder rung; AX/DOM handles win when present | typed `Grounding{rung, confidence}`; lint on raw coord tuples | **pending** (Grounding type not landed) | +| 7 | **one writer to the event log** — all task state changes append through the single writer | arch `eventlog-owns-events`; SQLite triggers in `eventlog/schema.ts`; writer §10.12 rogue INSERT tests | **enforced** | +| 8 | **archive ≠ delete** — compaction demotes/summarizes; only the user destroys | `compaction.test.ts` archive conservation; `jsonl.test.ts` | **enforced** | +| 9 | **generated ≠ hand-written** — protocol types come from the schema; drift fails the gate | `check-swift.sh` codegen parity; `surface/parity.test.ts` | **enforced** | +| 10 | **eval floor is a ratchet** — routing/model changes cannot lower grounding/routing floors | `evals/budgets.json` + `check-all` eval-floors check (floors rise only) | **partial** (file + ratchet shape enforced; harness pending) | +| 11 | **extensions ≠ kernel** — bundled extensions use only the public extension API; no private kernel imports | arch `extensions-only-public-api` | **partial** (vacuous until `extensions/` lands — fails on first bad import) | +| 12 | **extension ≠ trusted** — every extension registration is bound to declared grants; tools pass the same policy dispatcher | decide §10.11 / grants principal tests; full registration binding TBD | **partial** | +| 13 | **capture ≠ egress** — no screen capture reaches a cloud endpoint without redaction; redaction emits an auditable event | nominal `RawCapture`/`RedactedCapture` + `redaction.test.ts`; arch `raw-capture-stays-in-redaction` | **partial** (type + arch; full redaction stage + egress event later) | +| 14 | **turn ≠ ambient** — every Pi run through `Session.runTurn`/`followUp`; salvage audited | arch `turn-driving-stays-in-session`; piloop steer/abort tests; salvage-bypass contract TBD | **partial** | ## Naming triggers (add an invariant when one fires) diff --git a/evals/budgets.json b/evals/budgets.json new file mode 100644 index 0000000..173bf31 --- /dev/null +++ b/evals/budgets.json @@ -0,0 +1,7 @@ +{ + "note": "Eval floor ratchet (INVARIANTS.md row 10). Floors only rise — never edit downward to go green. Seeded before the eval harness lands; check-all enforces the ratchet shape.", + "grounding_floor": 0, + "routing_floor": 0, + "pending": true, + "pending_reason": "evals/ harness not landed yet — floors stay at 0 until first measured run, then ratchet via check-all --ratchet" +} diff --git a/review-policy.yaml b/review-policy.yaml new file mode 100644 index 0000000..8e172d2 --- /dev/null +++ b/review-policy.yaml @@ -0,0 +1,66 @@ +# review-policy.yaml — Gilly path → risk → required reviewers +# Policy: ~/.claude/skills/captain-workflow/REVIEW.md +# Aligned with INVARIANTS.md (grants, event log, protocol, redaction, policy dispatcher) + +version: 1 + +tiers: + routine: + required: + - gate + - semantic_pass + optional: + - bugbot + semantic: + required: + - gate + - bugbot + - semantic_pass + - impl_pass + trust-critical: + required: + - gate + - bugbot + - semantic_pass + - impl_pass + - human_judge + - codeowners + prefer_distinct_model_families: true + +trust_critical_paths: + - "specs/grants.md" + - "core/**/grants/**" + - "core/**/policy/**" + - "core/**/eventlog/**" + - "core/**/event-log/**" + - "core/src/session/**" + - "specs/protocol/**" + - "**/protocol/**" + - "**/redaction/**" + - "**/redaction*.ts" + - "core/src/surface/redaction.ts" + - "core/**/extension*/**" + - "specs/extension-api.md" + - "INVARIANTS.md" + +semantic_paths: + - "core/**/scheduler/**" + - "client/**" + - "specs/event-log.md" + - "specs/surface-protocol.md" + - "specs/swift-concurrency.md" + - "evals/**" + +bugbot: + on_tiers: [semantic, trust-critical] + min_diff_lines: 50 + never_counts_as: semantic_approval + +fixer: + max_cycles: 2 + require_finding_owner_verify: true + critical_findings_need_regression_test: true + +oracle: + separate_author_required_for_tiers: [semantic, trust-critical] + amend_oracle_is_separate_change: true diff --git a/scripts/arch-checks.mjs b/scripts/arch-checks.mjs index 818d855..d30b8fc 100644 --- a/scripts/arch-checks.mjs +++ b/scripts/arch-checks.mjs @@ -109,6 +109,16 @@ const RULES = [ "only core/src/session/ may import the PiLoop seam — every Pi run is driven through Session.runTurn/followUp (budget gate in, finishTurn out)", spec: "INVARIANTS.md row 14; specs/grants.md §7.3", }, + { + // INVARIANTS.md row 11: extensions may only consume the public API. + // Vacuous until extensions/ lands — fails the day a bad import appears. + name: "extensions-only-public-api", + forbidden: [{ from: "extensions/**", to: "**/core/src/**" }], + allow: [], + reason: + "bundled extensions may not import private kernel modules under core/src/ — use the public extension API only", + spec: "INVARIANTS.md row 11; specs/extension-api.md", + }, ]; // Textual (non-import) rules: {name, forbidden: [{from: path glob, @@ -184,6 +194,16 @@ const TEXT_RULES = [ "PanelRenderBody.source (and any source: on a panel body) is produced only in attribution.ts — never assign it elsewhere", spec: "specs/surface-protocol.md §9", }, + { + // INVARIANTS.md row 13: RawCapture must not appear outside the redaction + // module (+ its tests). Cloud/model egress accepts RedactedCapture only. + name: "raw-capture-stays-in-redaction", + forbidden: [{ from: "core/src/**", pattern: /\bRawCapture\b/ }], + allow: ["core/src/surface/redaction.ts", "core/src/surface/redaction.test.ts"], + reason: + "RawCapture is confined to the redaction module — egress/model clients may only name RedactedCapture", + spec: "INVARIANTS.md row 13; specs/surface-protocol.md §11", + }, ]; // Minimal glob: ** = any chars (incl. /), * = any chars except /. @@ -236,7 +256,13 @@ function checkFile(relPath, source, rules, textRules = TEXT_RULES) { const toRe = globToRegExp(to); for (const spec of specs) { if (toRe.test(spec)) { - violations.push({ rule: rule.name, file: relPath, spec, reason: rule.reason, ref: rule.spec }); + violations.push({ + rule: rule.name, + file: relPath, + spec, + reason: rule.reason, + ref: rule.spec, + }); } } } @@ -262,9 +288,12 @@ function checkFile(relPath, source, rules, textRules = TEXT_RULES) { function run() { const violations = []; - for (const file of sourceFiles(join(ROOT, "core", "src"))) { - const rel = relative(ROOT, file); - violations.push(...checkFile(rel, readFileSync(file, "utf8"), RULES)); + const roots = [join(ROOT, "core", "src"), join(ROOT, "extensions")]; + for (const root of roots) { + for (const file of sourceFiles(root)) { + const rel = relative(ROOT, file); + violations.push(...checkFile(rel, readFileSync(file, "utf8"), RULES)); + } } if (violations.length > 0) { for (const v of violations) { @@ -438,7 +467,8 @@ function selfTest() { { name: "ok: gated-executor seam usage from the composition root (session/runtime.ts)", file: "core/src/session/runtime.ts", - source: 'import { createGatedExecutor } from "../policy/index.js";\nconst run = createGatedExecutor(registry);\n', + source: + 'import { createGatedExecutor } from "../policy/index.js";\nconst run = createGatedExecutor(registry);\n', expectViolations: 0, }, { @@ -465,7 +495,8 @@ function selfTest() { { name: "ok: policy package's own tests may call createGatedExecutor", file: "core/src/policy/registry.test.ts", - source: 'import { createGatedExecutor } from "./registry.js";\nconst run = createGatedExecutor(r);\n', + source: + 'import { createGatedExecutor } from "./registry.js";\nconst run = createGatedExecutor(r);\n', expectViolations: 0, }, { @@ -526,7 +557,7 @@ function selfTest() { { name: "ok: attribution.ts stamps panel source", file: "core/src/surface/attribution.ts", - source: 'const body = { panelId, source: stampSource(r), descriptor };\n', + source: "const body = { panelId, source: stampSource(r), descriptor };\n", expectViolations: 0, }, { @@ -541,6 +572,30 @@ function selfTest() { source: 'source: "clamped",\n', expectViolations: 0, }, + { + name: "violation: RawCapture named outside redaction module (invariant 13)", + file: "core/src/session/runtime.ts", + source: 'import type { RawCapture } from "../surface/redaction.js";\n', + expectViolations: 1, + }, + { + name: "ok: redaction module may name RawCapture", + file: "core/src/surface/redaction.ts", + source: "export type RawCapture = CaptureData & { readonly [RawBrand]: true };\n", + expectViolations: 0, + }, + { + name: "ok: RedactedCapture is the egress type (not RawCapture)", + file: "core/src/session/runtime.ts", + source: 'import type { RedactedCapture } from "../surface/redaction.js";\n', + expectViolations: 0, + }, + { + name: "violation: extension imports private kernel path (invariant 11)", + file: "extensions/foo/index.ts", + source: 'import { decide } from "../../core/src/policy/index.js";\n', + expectViolations: 1, + }, ]; let failed = 0; for (const c of cases) { diff --git a/scripts/budgets/node/coverage.json b/scripts/budgets/node/coverage.json index 770d652..55e97eb 100644 --- a/scripts/budgets/node/coverage.json +++ b/scripts/budgets/node/coverage.json @@ -1,3 +1,3 @@ { - "floor": 0.0 + "floor": 93.96 } diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 9564847..858bd8e 100755 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -9,8 +9,16 @@ // node scripts/check-all.mjs --init-budgets # baseline the ratchets import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; -import { join, relative, extname } from "node:path"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { extname, join, relative } from "node:path"; const ROOT = (() => { try { @@ -36,6 +44,18 @@ function tracked() { function nodeProjects() { const found = new Set(); + const skipDir = new Set([ + "node_modules", + ".git", + ".venv", + ".claude", + ".worktrees", + "worktrees", + "coverage", + "dist", + "build", + ".build", + ]); const walk = (dir) => { let entries; try { @@ -46,7 +66,7 @@ function nodeProjects() { if (existsSync(join(dir, "package.json"))) found.add(dir); for (const e of entries) { if (!e.isDirectory()) continue; - if (["node_modules", ".git", ".venv"].includes(e.name)) continue; + if (skipDir.has(e.name)) continue; walk(join(dir, e.name)); } }; @@ -87,8 +107,10 @@ function checkLint(projects) { if (r.status) fails.push(r.stdout + r.stderr); } if (fails.length) return ["FAIL", fails.join("\n")]; - if (missing.length === projects.length) return ["SKIP", "biome not installed (npm i -D @biomejs/biome)"]; - if (missing.length) return ["WARN", `biome not found for: ${missing.join(", ")}`]; + if (missing.length === projects.length) { + return ["FAIL", "biome not installed (npm i -D @biomejs/biome) — required check cannot SKIP"]; + } + if (missing.length) return ["FAIL", `biome not found for: ${missing.join(", ")}`]; return ["OK", `${projects.length} project(s) clean`]; } @@ -109,8 +131,10 @@ function checkTypecheck(projects) { if (r.status) fails.push(r.stdout + r.stderr); } if (fails.length) return ["FAIL", fails.join("\n")]; - if (missing.length === projects.length) return ["SKIP", "no tsconfig.json / typescript not installed"]; - if (missing.length) return ["WARN", `typecheck skipped for: ${missing.join(", ")}`]; + if (missing.length === projects.length) { + return ["FAIL", "no tsconfig.json / typescript not installed — required check cannot SKIP"]; + } + if (missing.length) return ["FAIL", `typecheck skipped for: ${missing.join(", ")}`]; return ["OK", "types clean"]; } @@ -137,23 +161,41 @@ function checkTests(projects, fix, ratchet) { unlinkSync(summary); } } - if (!ran) return ["SKIP", "vitest not installed (npm i -D vitest @vitest/coverage-v8)"]; + if (!ran) { + return [ + "FAIL", + "vitest not installed (npm i -D vitest @vitest/coverage-v8) — required check cannot SKIP", + ]; + } if (fails.length) { const note = missing.length ? `\n(no vitest found for: ${missing.join(", ")})` : ""; return ["FAIL", "tests failed\n" + fails.join("\n") + note]; } - if (total === null) return ["OK", "tests pass (coverage report missing — check @vitest/coverage-v8)"]; + if (total === null) { + return [ + "FAIL", + "tests pass but coverage report missing — install @vitest/coverage-v8 and ensure json-summary reporter writes coverage/coverage-summary.json", + ]; + } if (fix) { budget.floor = Math.floor(total * 100) / 100; save("coverage.json", budget); return ["OK", `tests pass · coverage floor set to ${total.toFixed(2)}%`]; } + // A 0.0 floor means budgets were never baselined — refuse to go green. + if (floor <= 0) { + return [ + "FAIL", + `coverage floor is ${floor}% (unbaselined). Run: node scripts/check-all.mjs --init-budgets`, + ]; + } if (ratchet && total > floor) { budget.floor = Math.floor(total * 100) / 100; save("coverage.json", budget); return ["OK", `tests pass · coverage floor ratcheted ${floor}% → ${total.toFixed(2)}%`]; } - if (total + 1e-9 < floor) return ["FAIL", `tests pass but coverage ${total.toFixed(2)}% < floor ${floor}%`]; + if (total + 1e-9 < floor) + return ["FAIL", `tests pass but coverage ${total.toFixed(2)}% < floor ${floor}%`]; const nudge = total - floor > 1 ? ` (ratchet floor up toward ${total.toFixed(1)}%)` : ""; return ["OK", `tests pass · coverage ${total.toFixed(2)}% >= floor ${floor}%${nudge}`]; } @@ -167,15 +209,18 @@ function checkTests(projects, fix, ratchet) { // works with no network access. function checkDups(projects) { const jscpdConfig = join(ROOT, ".jscpd.node.json"); - if (!existsSync(jscpdConfig)) return ["SKIP", "no .jscpd.node.json"]; + if (!existsSync(jscpdConfig)) + return ["FAIL", "no .jscpd.node.json — duplicate-code check is required"]; let bin = null; for (const p of projects) { bin = toolBin(p, "jscpd"); if (bin) break; } - if (!bin) return ["SKIP", "jscpd not installed (npm i -D jscpd)"]; + if (!bin) return ["FAIL", "jscpd not installed (npm i -D jscpd) — required check cannot SKIP"]; const r = sh(bin, ["--config", jscpdConfig], { cwd: ROOT }); - return r.status === 0 ? ["OK", "no clones above threshold"] : ["FAIL", (r.stdout + r.stderr).slice(-1800)]; + return r.status === 0 + ? ["OK", "no clones above threshold"] + : ["FAIL", (r.stdout + r.stderr).slice(-1800)]; } function checkFileSizes(fix) { @@ -196,7 +241,11 @@ function checkFileSizes(fix) { save("file-sizes.json", budget); return ["OK", `baselined ${over.length} file(s) over ${mx} lines`]; } - if (over.length) return ["FAIL", "split these files:\n" + over.map(([rel, n]) => `${rel} = ${n} lines (max ${mx})`).join("\n")]; + if (over.length) + return [ + "FAIL", + "split these files:\n" + over.map(([rel, n]) => `${rel} = ${n} lines (max ${mx})`).join("\n"), + ]; return ["OK", `all source files <= ${mx} lines`]; } @@ -222,7 +271,11 @@ function checkDebt(fix, ratchet) { save("debt-allowlist.json", budget); return ["OK", `debt ceiling ratcheted ${cap} → ${hits.length}`]; } - if (hits.length > cap) return ["FAIL", `${hits.length} debt markers > ceiling ${cap}\n` + hits.slice(0, 20).join("\n")]; + if (hits.length > cap) + return [ + "FAIL", + `${hits.length} debt markers > ceiling ${cap}\n` + hits.slice(0, 20).join("\n"), + ]; return ["OK", `${hits.length} debt markers <= ceiling ${cap}`]; } @@ -267,7 +320,11 @@ function checkClaudeMd() { for (const m of text.matchAll(/`([^`]+)`/g)) { const tok = m[1].trim(); if (!tok.includes("/") || tok.includes(" ") || /[*<>]/.test(tok)) continue; - if (/^(http|npm |git |localhost)/.test(tok)) continue; + if (/^(http|npm |git |localhost|~\/)/.test(tok)) continue; + // owner/repo references (e.g. farzaa/clicky), not filesystem paths + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(tok) && !existsSync(join(ROOT, tok))) continue; + // Milestone target dirs that are allowed to be named before they land + if (/^(evals|skills)\/?$/.test(tok)) continue; if (!existsSync(join(ROOT, tok.replace(/\/$/, "")))) missing.push(`${name}: \`${tok}\``); } } @@ -277,17 +334,20 @@ function checkClaudeMd() { function checkCiParity() { const wfDir = join(ROOT, ".github", "workflows"); - if (!existsSync(wfDir)) return ["SKIP", "no .github/workflows"]; - const gates = []; - if (existsSync(join(ROOT, "scripts", "check_all.py"))) gates.push("check_all.py"); - if (existsSync(join(ROOT, "scripts", "check-all.mjs"))) gates.push("check-all.mjs"); - if (!gates.length) return ["SKIP", "no local gate to compare"]; + if (!existsSync(wfDir)) return ["FAIL", "no .github/workflows — CI backstop is required"]; + const required = []; + if (existsSync(join(ROOT, "scripts", "check_all.py"))) required.push("check_all.py"); + if (existsSync(join(ROOT, "scripts", "check-all.mjs"))) required.push("check-all.mjs"); + if (existsSync(join(ROOT, "scripts", "check-swift.sh"))) required.push("check-swift.sh"); + if (existsSync(join(ROOT, "scripts", "check-tier.mjs"))) required.push("check-tier.mjs"); + if (existsSync(join(ROOT, "scripts", "arch-checks.mjs"))) required.push("arch-checks.mjs"); + if (!required.length) return ["FAIL", "no local gate scripts to compare against CI"]; let text = ""; for (const f of readdirSync(wfDir)) { if (/\.ya?ml$/.test(f)) text += readFileSync(join(wfDir, f), "utf8"); } - if (!text) return ["SKIP", "no workflow files"]; - const absent = gates.filter((g) => !text.includes(g)); + if (!text) return ["FAIL", "no workflow files under .github/workflows"]; + const absent = required.filter((g) => !text.includes(g)); if (absent.length) { return [ "FAIL", @@ -295,34 +355,108 @@ function checkCiParity() { "add a step invoking it so local green == CI green", ]; } - return ["OK", "CI runs the same gate(s) as local"]; + // Swift gate must run on macOS — a Node-only workflow is not enough. + if (required.includes("check-swift.sh") && !/macos/i.test(text)) { + return ["FAIL", "check-swift.sh is present but no workflow job runs on macos-*"]; + } + return ["OK", "CI runs the same gate(s) as local (incl. Swift/macOS when applicable)"]; } function checkDeps(projects) { + const ci = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; let ran = false; + let offline = false; const vulns = []; for (const p of projects) { if (!existsSync(join(p, "package.json"))) continue; - if (!existsSync(join(p, "package-lock.json")) && !existsSync(join(p, "npm-shrinkwrap.json"))) continue; + if (!existsSync(join(p, "package-lock.json")) && !existsSync(join(p, "npm-shrinkwrap.json"))) + continue; const r = sh("npm", ["audit", "--audit-level=high", "--json"], { cwd: p }); - const blob = (r.stdout + r.stderr).toLowerCase(); - if (r.status && /(enotfound|etimedout|network|econnrefused|offline|getaddrinfo)/.test(blob)) { - return ["SKIP", "npm audit offline (registry unreachable) — enforced in CI"]; - } - ran = true; + let parsed = null; try { - const meta = JSON.parse(r.stdout || "{}").metadata?.vulnerabilities || {}; + parsed = JSON.parse(r.stdout || ""); + } catch { + parsed = null; + } + // A parseable vulnerability report means the audit ran: decide on its + // findings, and never reclassify it as offline (advisory titles routinely + // contain "network", which must not suppress a real CVE). + const meta = parsed?.metadata?.vulnerabilities; + if (meta) { + ran = true; const bad = (meta.high || 0) + (meta.critical || 0); if (bad) vulns.push(`${relative(ROOT, p) || "."}: ${bad} high/critical`); - } catch { - if (r.status) vulns.push(`${relative(ROOT, p) || "."}: audit failed\n${(r.stdout + r.stderr).slice(-600)}`); + continue; + } + // No report produced — only npm's error channel may signal unreachability. + const errBlob = ((parsed?.error ? JSON.stringify(parsed.error) : "") + r.stderr).toLowerCase(); + if ( + r.status && + /(enotfound|etimedout|network|econnrefused|offline|getaddrinfo)/.test(errBlob) + ) { + // Registry unreachability is transient/environmental, not a real + // vulnerability. Locally, warn and keep the commit unblocked; CI must + // still fail — a green there has to mean an audit actually ran. + if (ci) + return [ + "FAIL", + "npm audit offline (registry unreachable) — cannot claim CVE-clean without a run", + ]; + offline = true; + continue; } + if (r.status) + vulns.push(`${relative(ROOT, p) || "."}: audit failed\n${(r.stdout + r.stderr).slice(-600)}`); + } + if (vulns.length) + return ["FAIL", "vulnerable dependencies (high/critical):\n" + vulns.join("\n")]; + if (offline) { + return [ + "WARN", + "npm audit skipped: registry unreachable (offline) — not blocking locally; CI enforces", + ]; } - if (!ran) return ["SKIP", "no lockfile to audit (run npm install first)"]; - if (vulns.length) return ["FAIL", "vulnerable dependencies (high/critical):\n" + vulns.join("\n")]; + if (!ran) + return ["FAIL", "no lockfile to audit (run npm install first) — required check cannot SKIP"]; return ["OK", "no high/critical dependency CVEs"]; } +function checkEvalFloors() { + const f = join(ROOT, "evals", "budgets.json"); + if (!existsSync(f)) { + return [ + "FAIL", + "evals/budgets.json missing — INVARIANTS.md row 10 requires an eval floor ratchet file", + ]; + } + let budget; + try { + budget = JSON.parse(readFileSync(f, "utf8")); + } catch (e) { + return ["FAIL", `evals/budgets.json unreadable: ${e.message}`]; + } + for (const key of ["grounding_floor", "routing_floor"]) { + if (typeof budget[key] !== "number" || budget[key] < 0) { + return ["FAIL", `evals/budgets.json.${key} must be a non-negative number`]; + } + } + if (budget.pending === true && budget.grounding_floor === 0 && budget.routing_floor === 0) { + return ["OK", "eval floors pending (harness not landed) — ratchet file present"]; + } + return ["OK", `eval floors grounding=${budget.grounding_floor} routing=${budget.routing_floor}`]; +} + +function checkRiskTier(ciHint) { + const script = join(ROOT, "scripts", "check-tier.mjs"); + if (!existsSync(script)) return ["FAIL", "scripts/check-tier.mjs missing"]; + const args = [script]; + if (ciHint) args.push("--ci"); + const r = sh(process.execPath, args, { cwd: ROOT }); + const out = (r.stdout + r.stderr).trim(); + if (r.status) return ["FAIL", out || "check-tier failed"]; + return ["OK", out.split("\n").pop() || "tier ok"]; +} + function main() { const fix = process.argv.includes("--init-budgets"); const ratchet = process.argv.includes("--ratchet"); @@ -340,6 +474,8 @@ function main() { ["debt markers", () => checkDebt(fix, ratchet)], ["NUL-byte guard", checkNulBytes], ["dependency CVEs", () => checkDeps(projects)], + ["eval floors", checkEvalFloors], + ["risk tier", () => checkRiskTier(false)], ["CLAUDE.md alignment", checkClaudeMd], ["CI parity", checkCiParity], ]; @@ -361,7 +497,7 @@ function main() { console.log("-".repeat(44)); for (const [name, status, detail] of results) { console.log(` ${sym[status] ?? "?"} ${name.padEnd(22)} ${status}`); - if ((status === "FAIL" || status === "WARN") && detail) { + if ((status === "FAIL" || status === "WARN" || status === "SKIP") && detail) { for (const line of detail.split("\n")) console.log(` ${line}`); } } @@ -370,7 +506,8 @@ function main() { console.log("\nbudgets baselined to current state.\n"); return 0; } - const failed = results.filter(([, s]) => s === "FAIL").map(([n]) => n); + // SKIP on a required gate is a soft-lie green — treat as failure. + const failed = results.filter(([, s]) => s === "FAIL" || s === "SKIP").map(([n]) => n); if (failed.length) { console.log(`\n✗ ${failed.length} check(s) failed: ${failed.join(", ")}\n`); return 1; diff --git a/scripts/check-swift.sh b/scripts/check-swift.sh index 61b369f..827b130 100755 --- a/scripts/check-swift.sh +++ b/scripts/check-swift.sh @@ -120,11 +120,13 @@ try: except Exception: print(-1) # llvm-cov failure must read as red, never as 0%") COV=${COV:-0} - if python3 -c "exit(0 if float('$COV') >= float('$FLOOR') else 1)"; then + if python3 -c "exit(0 if float('$COV') >= 0 and float('$COV') >= float('$FLOOR') else 1)"; then green "coverage $p (${COV}% ≥ ${FLOOR}%)" else - red "coverage $p (${COV}% < floor ${FLOOR}%)" + red "coverage $p (${COV}% < floor ${FLOOR}% — or llvm-cov failed)" fi + else + red "coverage $p: tests ran but profdata/binary missing — cannot claim coverage" fi else red "test $p" @@ -133,22 +135,24 @@ except Exception: done # --- dependency CVEs ------------------------------------------------------- -RESOLVED=$(find "$CLIENT" -name Package.resolved -not -path '*/.build/*') -if [ -n "$RESOLVED" ] && command -v osv-scanner >/dev/null; then +RESOLVED=$(find "$CLIENT" -name Package.resolved -not -path '*/.build/*' 2>/dev/null || true) +if [ -z "$RESOLVED" ]; then + echo "· osv-scanner: no Package.resolved yet (not a pass — no lockfile to audit)" +elif ! command -v osv-scanner >/dev/null; then + red "osv-scanner not installed but Package.resolved exists (brew install osv-scanner)" +else for f in $RESOLVED; do if osv-scanner scan source --lockfile "$f" >/dev/null 2>&1; then green "osv-scanner $(basename "$(dirname "$f")")"; else red "osv-scanner: vulnerabilities in $f"; fi done -else - green "osv-scanner (no Package.resolved yet)" fi -# --- SAST best-effort ------------------------------------------------------ +# --- SAST (optional tooling — never counted as a green gate component) ----- if command -v semgrep >/dev/null; then if (cd "$CLIENT" && semgrep scan --quiet --error --config auto Packages GillyApp >/dev/null 2>&1); then - green "semgrep" - else red "semgrep findings"; fi + green "semgrep (advisory)" + else echo "· semgrep findings (advisory only, not part of GATE PASS/FAIL — run 'semgrep scan' to inspect)"; fi else - echo "· semgrep not installed — SAST skipped (best-effort per CLAUDE.md)" + echo "· semgrep not installed — advisory only, not part of GATE PASS/FAIL" fi [ "$FAIL" -eq 0 ] && green "GATE PASS" || red "GATE FAIL" diff --git a/scripts/check-tier.mjs b/scripts/check-tier.mjs new file mode 100644 index 0000000..408a414 --- /dev/null +++ b/scripts/check-tier.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +// Path-derived risk tier check (REVIEW.md / review-policy.yaml). +// Computes the minimum tier from changed files and fails when a declared +// tier is below that floor — or when trust-critical changes lack an ack. +// +// node scripts/check-tier.mjs # local: changed vs HEAD + index +// node scripts/check-tier.mjs --ci # PR: base...HEAD + PR body tier +// GILLY_ACK_TRUST=1 # local ack for trust-critical +// +// Declared tier sources (first match wins): +// 1. --declare=routine|semantic|trust-critical +// 2. GILLY_DECLARE_TIER env +// 3. PR body checkboxes / "Risk tier: X" (CI only) +// 4. If none and min is routine → OK; if min is higher → FAIL (must declare) + +import { execFileSync, spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = (() => { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); + } catch { + return process.cwd(); + } +})(); + +const TIERS = ["routine", "semantic", "trust-critical"]; +const RANK = Object.fromEntries(TIERS.map((t, i) => [t, i])); + +function sh(args) { + const r = spawnSync("git", args, { cwd: ROOT, encoding: "utf8" }); + return (r.stdout || "").split("\n").filter(Boolean); +} + +function loadPolicy() { + const f = join(ROOT, "review-policy.yaml"); + if (!existsSync(f)) { + console.error("check-tier FAIL: review-policy.yaml missing"); + process.exit(1); + } + const text = readFileSync(f, "utf8"); + const trust = []; + const semantic = []; + let section = null; + for (const raw of text.split("\n")) { + const line = raw.replace(/#.*/, "").trimEnd(); + if (/^trust_critical_paths:\s*$/.test(line.trim())) { + section = "trust"; + continue; + } + if (/^semantic_paths:\s*$/.test(line.trim())) { + section = "semantic"; + continue; + } + if (/^[a-zA-Z_]/.test(line.trim()) && !line.trim().startsWith("-")) { + section = null; + continue; + } + const m = line.match(/^\s*-\s*"([^"]+)"\s*$/) || line.match(/^\s*-\s*'([^']+)'\s*$/); + if (!m || !section) continue; + (section === "trust" ? trust : semantic).push(m[1]); + } + return { trust, semantic }; +} + +// Minimal glob: ** any (incl /), * any except / +function globToRegExp(glob) { + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + const pattern = escaped.replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*"); + return new RegExp(`^${pattern}$`); +} + +function matchAny(path, globs) { + return globs.some((g) => globToRegExp(g).test(path)); +} + +function changedFiles(ci) { + if (ci) { + const base = process.env.GITHUB_BASE_REF || process.env.GILLY_TIER_BASE || "origin/main"; + // Prefer merge-base range when available + const range = process.env.GILLY_TIER_RANGE; + if (range) { + const rr = spawnSync("git", ["diff", "--name-only", range], { + cwd: ROOT, + encoding: "utf8", + }); + if (rr.status !== 0) { + console.error( + `check-tier FAIL: cannot resolve GILLY_TIER_RANGE '${range}' ` + + `(git diff exited ${rr.status}). Refusing to pass on an empty diff.\n` + + ` ${(rr.stderr || "").trim()}`, + ); + process.exit(1); + } + return (rr.stdout || "").split("\n").filter(Boolean); + } + const r = spawnSync("git", ["diff", "--name-only", `${base}...HEAD`], { + cwd: ROOT, + encoding: "utf8", + }); + if (r.status === 0 && (r.stdout || "").trim()) { + return (r.stdout || "").split("\n").filter(Boolean); + } + // Fallback: all commits on branch vs main + return sh(["diff", "--name-only", "HEAD~1"]); + } + const a = sh(["diff", "--name-only", "HEAD"]); + const b = sh(["diff", "--cached", "--name-only"]); + return [...new Set([...a, ...b])]; +} + +function minTier(files, policy) { + let min = "routine"; + const hits = { trust: [], semantic: [] }; + for (const f of files) { + if (matchAny(f, policy.trust)) { + hits.trust.push(f); + min = "trust-critical"; + } else if (min !== "trust-critical" && matchAny(f, policy.semantic)) { + hits.semantic.push(f); + min = "semantic"; + } + } + return { min, hits }; +} + +function parseDeclared(argv, ci) { + const flag = argv.find((a) => a.startsWith("--declare=")); + if (flag) return flag.slice("--declare=".length); + if (process.env.GILLY_DECLARE_TIER) return process.env.GILLY_DECLARE_TIER.trim(); + if (!ci) return null; + + // PR body from GitHub Actions event payload + const eventPath = process.env.GITHUB_EVENT_PATH; + let body = process.env.GILLY_PR_BODY || ""; + if (!body && eventPath && existsSync(eventPath)) { + try { + const ev = JSON.parse(readFileSync(eventPath, "utf8")); + body = ev.pull_request?.body || ""; + } catch { + /* ignore */ + } + } + if (!body) return null; + + // Prefer checked checkboxes in PR template order (trust > semantic > routine) + const checked = []; + for (const t of TIERS) { + const re = new RegExp(`^\\s*-\\s*\\[x\\]\\s*\`?${t}\`?`, "im"); + if (re.test(body)) checked.push(t); + } + if (checked.length) { + // Highest declared among checked + return checked.sort((a, b) => RANK[b] - RANK[a])[0]; + } + const m = body.match(/risk\s*tier\s*[:-]\s*`?(routine|semantic|trust-critical)`?/i); + return m ? m[1].toLowerCase() : null; +} + +function main() { + const argv = process.argv.slice(2); + const ci = argv.includes("--ci"); + const policy = loadPolicy(); + const files = changedFiles(ci); + const { min, hits } = minTier(files, policy); + const declared = parseDeclared(argv, ci); + + console.log(`check-tier: ${files.length} changed file(s)`); + console.log(` path-derived minimum: ${min}`); + if (hits.trust.length) { + console.log(` trust-critical hits:\n - ${hits.trust.slice(0, 12).join("\n - ")}`); + } + if (hits.semantic.length) { + console.log(` semantic hits:\n - ${hits.semantic.slice(0, 12).join("\n - ")}`); + } + + if (min === "routine" && (!declared || declared === "routine")) { + console.log("check-tier OK (routine)"); + return 0; + } + + // Local mode enforces only the trust-critical floor; semantic is advisory + // (CI PR/push checks enforce declaration for semantic). + if (!ci && min === "semantic") { + console.log( + "check-tier WARN: path-derived minimum is 'semantic' — not blocking " + + "locally; declare 'semantic' on the PR (CI enforces it).", + ); + return 0; + } + + if (!declared) { + if (!ci && min === "trust-critical" && process.env.GILLY_ACK_TRUST === "1") { + // Durable trace — env ack alone used to leave no evidence. + const logDir = join(ROOT, ".captain", "review"); + const logPath = join(logDir, "loop-log.md"); + mkdirSync(logDir, { recursive: true }); + if (!existsSync(logPath)) { + appendFileSync( + logPath, + "# Review loop log\n\n| Date | Unit / PR | Tier | Reviewers used | Escaped bug? | Fixer regression? | Notes / policy change |\n|---|---|---|---|---|---|---|\n", + ); + } + const ts = new Date().toISOString().slice(0, 10); + const files = hits.trust.slice(0, 8).join(", "); + appendFileSync( + logPath, + `| ${ts} | local ack | trust-critical | GILLY_ACK_TRUST=1 | no | no | ack for: ${files} |\n`, + ); + console.log( + `check-tier OK (trust-critical locally acked via GILLY_ACK_TRUST=1; logged to ${logPath})`, + ); + return 0; + } + console.error( + `check-tier FAIL: path minimum is '${min}' but no tier was declared.\n` + + (ci + ? " Check the matching risk-tier box in the PR template (≥ path minimum)." + : min === "trust-critical" + ? " Re-run with GILLY_ACK_TRUST=1 or --declare=trust-critical (conscious ack)." + : ` Re-run with --declare=${min} (or higher).`), + ); + return 1; + } + + if (!Object.hasOwn(RANK, declared)) { + console.error(`check-tier FAIL: unknown declared tier '${declared}'`); + return 1; + } + if (RANK[declared] < RANK[min]) { + console.error( + `check-tier FAIL: declared '${declared}' is below path-derived minimum '${min}'.\n` + + " Raise the PR risk tier (or split the trust-critical files out).", + ); + return 1; + } + console.log(`check-tier OK (declared ${declared} ≥ minimum ${min})`); + return 0; +} + +process.exit(main()); diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index f38b0e5..98ebdc3 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -8,6 +8,8 @@ if [ -x "$ROOT/.venv/bin/python" ]; then PY="$ROOT/.venv/bin/python"; PATH="$ROO [ -f "$ROOT/scripts/check-all.mjs" ] && node "$ROOT/scripts/check-all.mjs" # Architecture invariants (import-graph lints) — PRIMITIVES.md [ -f "$ROOT/scripts/arch-checks.mjs" ] && node "$ROOT/scripts/arch-checks.mjs" +# Path-derived risk tier runs inside check-all (checkRiskTier); a second +# standalone invocation here would double-log trust-critical acks. # Swift gate (client/): never bypassed — CLAUDE.md "$ROOT/scripts/check-swift.sh" exit 0