Skip to content

Gate every step of the CCA pipeline - #60

Open
GiulioDER wants to merge 23 commits into
masterfrom
feat/gate-every-step
Open

GiulioDER wants to merge 23 commits into
masterfrom
feat/gate-every-step

Conversation

@GiulioDER

Copy link
Copy Markdown
Owner

What and why

In 0.10.x the commit guard enforced four layers of /audit-fix (L1, L2, L2.5, L6). The steps it did not check were the ones agents skipped, and one skip was worse than it looked: the fix layers were owed only when record L4 --fixes N was called, so an agent that edited code and never recorded L4 switched off L5, L5.5 and L5.6 at once. This PR makes every step enforceable.

  • One rules module. cca_checks/plugin/hooks/cca_gate_core.py (stdlib only) holds the fingerprint, the required layer table, the order table and the completeness check. pipeline_state, the commit guard and a new Stop hook all load it by path, so the rules exist once.
  • Fixes are detected from the working tree, via a tree id built in a copy of the index from the repository top level, with .claude/audits removed whatever .gitignore says. Every verdict from L5 onward stores the tree it judged; an edit after a verdict makes it stale.
  • Every layer is gated. L0.4 for hunts, L2.6 on STANDARD and DEEP, L3 on every fixing run, L4 onward whenever the tree changed. record refuses an out of order layer (exit 2, naming the section to re-read). L2, L2.5 and L3 take counts that must agree with each other and with the disposition ledger. L2.6 and L5.6 are recorded by their tools (--record), never by hand.
  • No silent exits. --status n/a no longer satisfies a required layer, pipeline close refuses an incomplete run, a second pipeline start is refused, and no-fix runs now close instead of blocking the next commit.
  • A Stop hook (cca_stop_guard.py) refuses to end the turn while a run is open: three times per run, then it logs STOPPED_INCOMPLETE. It allows while background subagents run, when L6 is BLOCKED, and on any internal error, so it never traps a session. cca-audit install --print-hook prints both hooks.
  • Codex has no hooks; its skill now runs pipeline status --require-complete before reporting.
  • Compatibility: a run started by 0.10.x finishes under 0.10.x rules.

Spec: docs/superpowers/specs/2026-10-10-gate-every-step-design.md. Plan: docs/superpowers/plans/2026-10-10-gate-every-step.md.

Checklist

  • pytest -q passes, apart from the 11 semgrep tests that also fail at the base d2317de on my workstation (Smart App Control blocks semgrep, WinError 4551). Here: 11 failed, 786 passed, 1 skipped of 798. Base: 11 failed, 689 passed, 1 skipped of 701. Identical failing set. CI on Ubuntu is the green run that counts.
  • ruff check cca_checks tests is clean
  • Red then green for every new behaviour test. The seven gap tests in tests/test_gate_holes.py build the run state by hand with APIs that exist at d2317de and fail there in their assertion; the rest are proved by named mutations. Each receipt is in the test docstring.
  • n/a: no new auditor or claim type

Known follow ups (deliberately not in this PR)

  • record() fingerprints between load and write, so parallel L5.5 and L5.6 records can lose an update (fails closed).
  • The stale verdict message could name the changed paths.
  • The hook snippet calls bare python; exit 127 on a host without it is non blocking.
  • Step 0.6 "start again" wording should point an aborted hunt back to Step 0.4.

🤖 Generated with Claude Code

GiulioDER and others added 22 commits October 10, 2026 18:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…found

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he tree

cca_gate_core.py holds the required-layer table, ordering, staleness and legacy
rules once, stdlib only, so the commit guard, the stop hook and pipeline_state
can all load it by path. The tree fingerprint copies the index with copy2: a
plain copy defeated git's racy-clean check and missed same-tick edits on a
loaded Windows machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… incomplete run

pipeline_state is rebuilt on cca_gate_core: record() refuses out of order
layers, tool layers recorded by hand and counts that disagree with the run,
and close() refuses an incomplete run.

Also fixes the core fingerprint: when .claude/ is gitignored and exists,
git add exits 1 if the exclude pathspec names the ignored audits path, so the
fingerprint returned None after the first record. The exclude is now passed
only when check-ignore says the path is not ignored; every other add failure
still fails closed. tests/conftest.py tree_of follows the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever .gitignore says

The previous probe asked check-ignore about run-state.json, but git's exit 1 on
an exclude pathspec depends on the directory. With a .gitignore of *.json the
state file reads as ignored, the exclude was dropped, and the ledger, the log
and the snapshots entered the tree. The fingerprint now runs git add -A and
then git rm --cached -r --ignore-unmatch on .claude/audits in the same temp
index, which is independent of every ignore pattern. tree_of in conftest
mirrors it. One parametrized test covers four .gitignore contents.

Also removes the unused _COUNT_KEYS from pipeline_state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard no longer embeds its own layer tables. It loads cca_gate_core.py from
beside itself by path, fingerprints the tree for schema 2 runs, and refuses
(exit 2) when the core is missing while a run is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erprint the whole repo

FINGERPRINT_TIMEOUT_S now bounds the whole fingerprint (each git call gets what
is left; past it the answer is None), because a hook that times out fails open.
A git found anywhere inside the audited tree, or by a relative path, is refused,
not only one at the run root. A run started in a subdirectory now fingerprints
the whole repository and excludes its own .claude/audits by relative path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stall hint is real

main() exited 1 on an exception after a run was found, which Claude Code treats
as non blocking, so the commit proceeded. It now refuses (exit 2) on any
exception, keeping the specific messages for a missing core and unreadable state.
fingerprint() returns None on ValueError (os.path.relpath across drives), the
audits exclusion is a :(literal) pathspec, and the hint names the console script
`cca-audit install`, since `python -m cca_checks.plugin.cli` has no __main__.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The commit guard only fires on git commit, so a no-fix run, or a run that
stops to summarise after L5, never met a gate. The Stop hook blocks the end
of the turn while a run is incomplete or still open, at most three times per
run, then logs STOPPED_INCOMPLETE once and lets go. It allows on background
tasks, a legacy run, an L6 BLOCKED, a missing core, and any internal failure,
the opposite direction from the commit guard on purpose.

The settings snippet now arms both hooks with a 60 s timeout, above the 30 s
fingerprint bound, and the install warning requires both guards to be named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uards register separately

The stop guard blocked every stop on a corrupt run-state.json until Claude
Code's own override. It now blocks once with the abort command and allows
when stop_hook_active is set.

The install check judged the pair by one settings file, so a commit guard in
the user file plus a stop guard in the project file warned falsely. Each guard
is now looked for across all candidate files, and the warning names whichever
is missing. The --print-hook help says it arms both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ayers

cca_scorecard.py --record writes L2.6 and cca_tautology_check.py verify --record
writes L5.6, both with source=tool, the only source the run state accepts for
those two layers. Red proofs are recorded in a follow-up commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Contract table gains the 0.4 row and the count flags for L2, L2.5 and
L3. L2.6 and L5.6 are recorded by their tools, never by hand. The ledger
run_id is the one printed by pipeline start. A no-fix run closes after
recording L2.5, and a hunt opens its run at Step 0.4. The Codex skill
checks pipeline status --require-complete before it reports. The
changelog records the gated steps and the Stop hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-fix argument now names L2.6 and the close. An empty Step 2.5 list
records L2.5 with zero counts instead of skipping it, and Step 5.5 says
the gate owes it only when the tree changed. A hunt is told to skip the
second pipeline start at Step 0.6, the Step 0.4 comment says the five
gates above, and the Output Summary lists 0.4, 2.6 and 5.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Step 2.5 heading no longer limits the layer to STANDARD and DEEP,
and the opening paragraph says FAST verifies P1 only with a single
fp-check and still records the layer. The adversarial panel and Step 2.5b
keep their own tier labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only ImportError

An uncaught exception exits 1, which Claude Code treats as non blocking, so a
core raising RuntimeError at import let a commit through while a run was open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing the open run

start() overwrote the state file, which re-baselined base_tree over the run's
edits (a run with no fixes recorded stopped owing L4 onward), dropped L0.4 when
--mode was omitted, reset stop_refusals, and left no trail. It now raises
RecordRefused while a state file exists, readable or not, naming `pipeline abort`.
The CLI prints {"refused": "start"} and exits 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, REVISE owes L5.6

Step 0.4 and the 0.4 Contract row opened a `hunt ... no-fix` run as a fixing run
because Step 0.6, where --no-fix is passed, is skipped for a hunt. The Step 0.6
note now says a second start is refused. The REVISE loop re-verifies L5, L5.5 and
L5.6, and Step 4 says the gate detects change from the working tree while --fixes
is recorded for the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is still open

With every layer recorded, outstanding was empty, so the check exited 0 and Codex
could report while the run stayed open and kept blocking later commits. It now
exits 2 with a single outstanding_to_close reason telling it to run
`pipeline close`; the Codex skill says to do that and check again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t stales L5

_run_one runs pytest on the reverted pre-fix source, which wrote .pyc files; the
restore put the fixed source back but left them, so the tree fingerprint after
verify differed from the one L5 recorded in a repository that does not ignore
__pycache__. The subprocess now runs with PYTHONDONTWRITEBYTECODE=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nonbehavioural typing fix: no runtime path changes, so the red-proof rule
does not apply. Each site gets an explicit None check that pyright can see.
The importlib spec and loader raise ImportError with the path, close()
returns None when the state cannot be loaded (as it already does when the
file is absent), the gate core keeps its isinstance test on the layers
mapping but binds the value first, and the two tests assert before use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant