diff --git a/.codearbiter/.provenance/release-targets.json b/.codearbiter/.provenance/release-targets.json new file mode 100644 index 00000000..8c369660 --- /dev/null +++ b/.codearbiter/.provenance/release-targets.json @@ -0,0 +1,78 @@ +{ + "created": "2026-07-31", + "doc": "release-targets", + "entries": [ + { + "drift_trigger": true, + "hash": "a39e18a12f1a3aefaebd5ac80b5519711d6aba84", + "path": "CHANGELOG.md" + }, + { + "drift_trigger": true, + "hash": "ae70e34bb2fea3bbef23b71e00a01c71725da90f", + "path": "package.json" + }, + { + "drift_trigger": true, + "hash": "7a8c2e5487ccea497a3014cf93c1632453cf471e", + "path": "plugins/ca-codex/.codex-plugin/plugin.json" + }, + { + "drift_trigger": true, + "hash": "9199e83dbc60ffb09dc5321ff8990d80200cab46", + "path": "plugins/ca-codex/CHANGELOG.md" + }, + { + "drift_trigger": true, + "hash": "9c1adadf3757e7f995c939b8941702504bf2ad4d", + "path": "plugins/ca-pi/CHANGELOG.md" + }, + { + "drift_trigger": true, + "hash": "b5812f8616f351ba2db9acafff5164a4e2939c87", + "path": "plugins/ca-pi/extensions/codearbiter-child.js" + }, + { + "drift_trigger": true, + "hash": "7e5890e95d06e7f1455a6ee99641c11862f25b12", + "path": "plugins/ca-pi/extensions/codearbiter.js" + }, + { + "drift_trigger": true, + "hash": "ee1837f4f453bc598eff08cc4ebb71a306e8b06d", + "path": "plugins/ca-pi/package.json" + }, + { + "drift_trigger": true, + "hash": "4778082d5ea1466d96a6bb0640eeee58bbb30f0e", + "path": "plugins/ca-sandbox/.claude-plugin/plugin.json" + }, + { + "drift_trigger": true, + "hash": "d2a8c793bcaaceb3649a06076a27533f79eb7161", + "path": "plugins/ca-sandbox/CHANGELOG.md" + }, + { + "drift_trigger": true, + "hash": "6bbd0dc0a4f0a6ab52b1b97e2467c53228861ee9", + "path": "plugins/ca-sandbox/tools/claude-inside.js" + }, + { + "drift_trigger": true, + "hash": "937c440af255c8b6aa97a4edcd4283ec7130c665", + "path": "plugins/ca-sandbox/tools/sandbox.js" + }, + { + "drift_trigger": true, + "hash": "8c98f20f9e4de1cdec10d6f99922e520347a3ba9", + "path": "plugins/ca/.claude-plugin/plugin.json" + }, + { + "drift_trigger": true, + "hash": "ab5064e747807cd68d2433b2aab234883c25d42b", + "path": "plugins/ca/tools/farm.js" + } + ], + "interview_derived": false, + "schema": 1 +} diff --git a/.codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md b/.codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md new file mode 100644 index 00000000..47cb1946 --- /dev/null +++ b/.codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md @@ -0,0 +1,72 @@ +--- +status: accepted +date: 2026-07-31 +title: Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers +decided-by: SUaDtL@users.noreply.github.com +supersedes: none +governs: core/pysrc/_protectedstatelib.py, core/pysrc/_protectedlib.py, core/pysrc/_bashguardlib.py, .codearbiter/release-targets.md +--- + +# ADR-0024 — Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers + +## Status + +Accepted — ratified 2026-07-31 by SUaDtL@users.noreply.github.com. Content pre-approved at the sprint's Phase 1 gate the same day. + +## Context + +`H-22` introduces a registry of protected project-state files, each carrying a policy — `marker-gated`, `helper-only`, or `append-only` — enforced across `pre-write.py`, `pre-edit.py`, and `_bashguardlib.py`. Its first consumer, `.codearbiter/release-targets.md`, carries per-row `pre-tag` shell commands that `/ca:release` executes before composing a tag (DECISION-0034). + +That makes `release-targets.md` **executable input**: a repository file whose contents the framework runs. ADR-0002 already established the trust model for this class in `plan.json` — operator-authored, PR-reviewed, length-capped, with the boundary declared rather than an allowlist imposed. This ADR records where the new case matches that precedent and, more importantly, where it does not. + +Three differences from `plan.json` were raised in review and are named here rather than papered over with a citation: + +1. **Authorship.** ADR-0002 rests on "operator-authored". Here the model drafts the rows — `context-creation` elicits them, and a back-fill lane proposes a detected shape. Model-drafted shell later executed by the skill is a different trust class than operator-typed shell. +2. **Review discipline.** In a consumer repo nothing guarantees `release-targets.md` edits are PR-reviewed. A third-party PR touching it plants commands the maintainer's next `/ca:release` runs. +3. **Write protection.** It is the only *executable* file under `.codearbiter/`, and the location chosen for it — a separate file rather than a `CONTEXT.md` block, on context-economy grounds — does not inherit `CONTEXT.md`'s existing guard. + +## Decision + +The registry is a declared executable-input boundary, protected by **write-gating rather than content inspection**. + +- `release-targets.md` is registered `marker-gated`. Mutations are admitted only under a fresh `release-targets-authoring` marker, minted immediately before the write and removed at lane exit. +- Each `pre-tag` entry is capped at **1024 characters**, following ADR-0002's precedent. +- `pre-tag` commands are **check-only** and may never mutate the tree (DECISION-0034). The clean-tree assertion is unconditional, so a rogue command's writes surface before tagging. +- The resolved `pre-tag` list is content-hashed; a change forces re-confirmation, so a silently edited command cannot ride an earlier approval. +- **No content predicate ever grants admission.** A guard that reads file content to decide whether to permit a write converts content into an authorization signal, which is launderable by anyone who can write the content. + +**The marker is audit friction, not authorization.** It is self-mintable via shell by design, per ADR-0010's cooperative-attestation posture. Its value is the trail and the deliberate pause, not unforgeability. `GATE_MARKER_NAMES` — the shell-flank blocklist — enumerates only markers that convert a BLOCK into an ALLOW, and authoring markers are correctly outside it. Widening that list generically over every registered marker would brick every minting lane while stopping no non-cooperative agent, since such an agent shell-mints regardless. + +**Case handling is global, not host-derived.** Both flanks treat registered paths case-insensitively and tolerate a `./` prefix, trailing slash, doubled slash, and surrounding whitespace. Matching the host filesystem was rejected: case-sensitivity varies by platform *and* by volume on the same platform, and `realpath` cannot fold case for a path that does not yet exist — exactly a Write creating a protected file for the first time. A fixed rule both flanks apply without consulting the filesystem only widens what is protected, never narrows it. + +## Accepted residuals + +Declared here and in `security-controls.md`. Undeclared is not the same as accepted. + +- **Shell indirection.** `f=…; sed -i "$f"` and novel interpreter spellings evade the lexical flank. The same residual `CONTEXT.md` already carries under ADR-0010, inherent to a cooperative guard. +- **Lexical false blocks.** The shell flank matches a registered file's bare basename with no directory requirement — forced by the need to catch a bare `tee open-tasks.md`. A description containing a write verb and the filename false-blocks. `/ca:override` is the sanctioned bypass. +- **`touch` is excluded.** Two reviewers split. A security pass traced every mtime consumer under `.codearbiter/` and found none feeding an admission decision — `marker_gated_write_admitted` stats the *marker*, not the protected file, so even back-dating admits nothing. An adversarial pass countered that `DECISIONS_WRITE_RE` *does* include `touch`, because for H-11 creation itself is the violation, and that `touch` on an absent board creates an empty board outside the sanctioned helper. The admission analysis was judged decisive; the creation case is recorded as the known cost. +- **Merge-conflict resolution.** A conflict in a `helper-only` file has no helper verb, so resolution routes through logged `/ca:override`. + +## Reopen conditions + +- If `gate-events.log` shows board-conflict overrides recurring, build a `taskwrite resolve` verb. Never punch an exception into the guard. +- If `release-targets.md` ever ingests untrusted or third-party content, the operator-authored premise is void and this decision must be revisited — inheriting ADR-0002's own reopen trigger. +- If any registry entry ever needs a non-forgeable marker, that marker joins `GATE_MARKER_NAMES` deliberately and gains a sanctioned producer, as a reviewed one-line widening. + +## Alternatives considered + +- **A content allowlist on `pre-tag` commands** — rejected for the reason ADR-0002 rejected it: it over-engineers a trusted-operator input and risks refusing legitimate commands. +- **Uniform marker-gating for all three consumers** — rejected. Marker-gating `open-tasks.md` would *admit* an agent hand-composing board markdown under a marker, while its sanctioned helper is already invisible to every flank by construction. The correct policy there is a hard block with no marker path. +- **A disk-loaded registry** — rejected. It would let a consumer repo un-protect its own board by editing a file. +- **Storing the rows in `CONTEXT.md`** to inherit its write guard — rejected on context economy: `CONTEXT.md` is read every session, release configuration only when tagging. + +## Consequences + +Easier: an explicit trust model, so a reviewer knows `release-targets.md` is executable input and reviews it as such; and one registry that a fourth protected file joins as a row rather than as a new hook branch. + +Harder: correctness depends on write-gating and PR-review discipline rather than on content validation, and the lexical flank will occasionally false-block a legitimate command. + +## Risks + +A malicious or mistaken `pre-tag` entry runs arbitrary shell on the maintainer's host at release time. Accepted because the entry is write-gated, length-capped, check-only, content-hashed against silent change, and surfaced for confirmation. The residual is a cooperative-guard residual, not a sandbox. diff --git a/.codearbiter/decisions/decision-log.md b/.codearbiter/decisions/decision-log.md index 5136a682..60072dfa 100644 --- a/.codearbiter/decisions/decision-log.md +++ b/.codearbiter/decisions/decision-log.md @@ -1114,3 +1114,105 @@ own sources so the report does not count its tests as covered source. Baseline recorded; shortfall tracked like #511. --- + +## DECISION-0033 — release-pre-tag-steps — Repo-specific pre-tag reconciliation is declared per target row, not pushed to CI + +**Date:** 2026-07-30 +**Status:** accepted +**Supersedes:** none +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** The `release` skill hardcodes this repo's pre-tag steps (README badge sync, command/skill/agent count derivation, `build-host-packages.py` regeneration) as skill prose. +- **Scaffold position:** A shippable skill cannot carry any repo's specific reconciliation commands; project state is where per-repo facts belong. +- **Status type:** open-decision-closure + +### Decision +Each release target row in `.codearbiter/release-targets.md` carries an ordered list of pre-tag shell commands. The skill runs them before tagging, asserts each exits 0, and asserts the tree is clean afterward. This repo's four existing steps move into its own rows unchanged. CI's `check_badge_consistency.py` is retained as the mechanical backstop it already is — the declared steps complement it rather than replace it. + +### SMARTS rationale +Testable, Available, Scalable, and Maintainable favored declared rows cleanly. Declared steps are data, so a fixture can assert run order and dirty-tree failure; pushing the steps to CI would require simulating CI status payloads to test, and would make tagging unavailable during any CI outage. Securable was the only non-Strong cell, and it falls inside the trust class ADR-0002 already accepted: operator-authored, PR-reviewed shell input, resolved by declaring the boundary rather than restricting the operator. The rejected local-hook-script option scored Weak on Securable for precisely the reason ADR-0002 rejected its own equivalent — an undeclared executable-input boundary. + +### Implementation implication +`.codearbiter/release-targets.md` gains a `pre-tag` list per row plus an assert-clean flag. `security-controls.md` gains a boundary-crossings entry declaring the file as executable input, with a length cap following ADR-0002's 1024-character precedent. An ADR is warranted for the new trust boundary and should be authored via `/ca:adr` during implementation. The `release` skill drops its four hardcoded reconciliation steps in favor of running the declared list. Tracked under issue #563. + +--- + +## DECISION-0034 — release-pre-tag-semantics — Declared pre-tag commands are check-only and may never mutate the tree + +**Date:** 2026-07-30 +**Status:** accepted +**Supersedes:** DECISION-0033 +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** DECISION-0033 held that pre-tag steps are declared per target row and run with exit-code and clean-tree assertions, and its implementation note described a per-row assert-clean flag. +- **Scaffold position:** This repo's actual pre-tag steps are edits, not assertions — `build-host-packages.py` regenerates the root manifest and `wc -l` count derivation implies editing README prose — so a clean-tree assertion blocks the very reconciliation the steps exist to perform. +- **Status type:** open-decision-closure + +### Decision +Pre-tag commands remain declared per target row rather than pushed to CI, restating DECISION-0033's holding in full. They are additionally constrained to be **check-only**: a command must assert and exit non-zero on drift, and must not mutate the working tree. The clean-tree assertion applies unconditionally, so any mutation is detected and blocks the release. No per-row assert-clean flag exists. Reconciliation itself — regenerating a manifest, syncing README badges — stays a separate action the operator performs and commits through `commit-gate` before re-running the release. + +### SMARTS rationale +Five of six lenses favored check-only. Maintainable and Testable: one rule with no branch, and a fixture asserts exit code plus unchanged tree without standing up commit-gate, whose own gates would otherwise leak into release tests. Reliable: the tree the suite ran against is the tree that gets tagged, with no mid-phase mutation landing after the last green run. Securable: because the clean-tree assertion always runs, a rogue declared command's writes surface before tagging, which the flagged and unconditional-mutation alternatives both lose. Available was the single Weak cell and is an accepted cost — a lagging generated manifest stops the release and names what to run. ADR-0008's ride-along precedent genuinely favored in-lane reconciliation, but it applies to narrow classified edits with named exemptions (`classify_board_diff` transitions, provenance re-baselines), never to arbitrary operator-declared commands; extending it here would let a regenerated manifest reach a tag without passing commit-gate review. + +### Implementation implication +`.codearbiter/release-targets.md` rows carry a `pre-tag` list with no assert-clean flag. The release skill runs each command, asserts exit 0, then asserts a clean tree, and BLOCKs on either failure. This repo's badge and count reconciliation must be expressed as check scripts — `check_badge_consistency.py` already has that shape; the catalog and README-table assertions need equivalent non-mutating checks written. `build-host-packages.py` is not a pre-tag command; a companion check asserts the generated root manifest matches the plugin manifest and fails when it lags. Costs this repo one extra loop per release when a generated artifact is stale. Tracked under issue #563, spec `specs/release-portable-fixture.md`. + +--- + +## DECISION-0035 — adr-0024-ratification — Protected-state registry ratified as a declared executable-input boundary + +**Date:** 2026-07-31 +**Status:** accepted +**Supersedes:** none +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** security-architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** `.codearbiter/release-targets.md` carries per-row `pre-tag` shell commands that `/ca:release` executes, making it executable input, with no recorded trust model. +- **Scaffold position:** ADR-0002 already governs this class for `plan.json` — operator-authored, PR-reviewed, length-capped, boundary declared rather than allowlisted — but three material differences separate the new case from it. +- **Status type:** open-decision-closure + +### Decision +ADR-0024 is accepted. The protected-state registry is a declared executable-input boundary protected by write-gating rather than content inspection: marker-gated writes, a 1024-character cap per `pre-tag` entry, check-only commands under an unconditional clean-tree assertion, and a content hash forcing re-confirmation when a command changes. No content predicate ever grants admission. The authoring marker is explicitly audit friction rather than authorization, self-mintable by design under ADR-0010, and `GATE_MARKER_NAMES` is not widened to cover it. Case handling is global rather than host-derived. + +### SMARTS rationale +Securable drove it, and the decisive reasoning is that the alternatives fail in the same shape: a content predicate admitting a write based on what the file contains converts content into an authorization signal, launderable by anyone who can write the content — the same defect that sank the file-absent exemption and the conflict-marker carve-out considered earlier in this campaign. Maintainable and Scalable favored one registry over per-file hook branches, and the maintainer's standing steer to weight `Scalable` heavily for deterministic enforcement over prose reinforced it. Reliable favored global case handling once it was established that `realpath` cannot fold case for a file that does not yet exist, which is precisely a Write creating a protected file for the first time — a host-derived rule would have been silently wrong exactly at creation. + +### Implementation implication +`core/pysrc/_protectedstatelib.py`, `_protectedlib.py` and `_bashguardlib.py` carry the enforcement, already landed at 56387ee. The ADR's `governs:` field enrolls those three plus `.codearbiter/release-targets.md`, so the post-write hook surfaces the decision at edit time rather than at a checkpoint sweep. Four residuals are declared in both the ADR and `security-controls.md`, and three reopen conditions are recorded — most concretely, that recurring board-conflict overrides in `gate-events.log` mean building a `taskwrite resolve` verb rather than punching an exception into the guard. Closes T-16 of the sprint plan. + +--- + +## DECISION-0036 — bare-release-requires-explicit-target — A multi-target project must name its release target; no implicit default + +**Date:** 2026-07-31 +**Status:** accepted +**Supersedes:** none +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** The release skill defaulted a bare `/ca:release` to target `ca`, a hardcoded fact about this repository. +- **Scaffold position:** A-6.0 requires that no hardcoded row survive the portability rewrite, and steer 5 requires no behavior change to this repo's four-plugin release. Both cannot hold for the bare invocation. +- **Status type:** same-level-conflict-resolution + +### Decision +A declared file with exactly one target resolves that target implicitly. A declared file with more than one target requires an explicit `$TARGET`; a bare invocation STOPs. This repository declares four targets, so `/ca:release` alone now stops and `/ca:release ca` is required. This is an accepted, deliberate exception to steer 5, taken with the steer in view rather than around it. + +### SMARTS rationale +Securable and Reliable drove it. A bare invocation that resolves a target nobody named is a default-allow on a lane that ends in a `contents: write` publisher, and the alternative places that default inside `release-targets.md`, which has no write protection until its H-22 enrolment at T-33 and would therefore be an unguarded redirect. Reliable agrees: a stale default flag publishes the wrong series, where an explicit argument cannot. Scalable was the one clean win for the declared-default alternative, since bare invocation silently changes meaning the day a consumer adds a second row, but that discontinuity is one-time per consumer and surfaces as a STOP rather than as a wrong publish. Precedent aligns: DECISION-0034 chose check-only over silent fixers, and this campaign already rejected positional target selection as a reorder-to-mispublish hazard. + +Recorded against the author's original justification, which does not survive scrutiny: the claim was that a declared default would bake in "the primary target is named ca", but `latest-eligible: true` already singles out `ca` in the same row set and shipped in the same change under the same criterion. The objection proves too much. The decision stands on the security and reliability grounds above, not on that reasoning. + +### Implementation implication +`core/surface/skills/release/SKILL.md` keeps the STOP. `core/surface/commands/release.md` must stop documenting a `ca`-only default when T-71 reconciles it. A test must pin this: an adversarial mutant restoring the hardcoded `ca` default currently survives both suites, so the behavior is asserted by nothing. That test is a follow-up obligation of this decision, not optional. + +--- diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log index cdabd2e0..3526fd62 100644 --- a/.codearbiter/gate-events.log +++ b/.codearbiter/gate-events.log @@ -1157,3 +1157,516 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages [2026-07-30T19:07:55Z] BLOCK [H-01] host=codex hook=pre-bash.py | Direct commit to main is prohibited (ORCHESTRATOR §3). Create a feature branch. [2026-07-30T20:15:22Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). [2026-07-30T20:23:22Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-30T21:00:35Z] BLOCK [H-03] host=codex hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('site/public/diagrams/*.svg') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-30T21:00:53Z] BLOCK [H-09b] host=codex hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path. +[2026-07-30T21:01:31Z] BLOCK [H-09b] host=codex hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path. +[2026-07-30T22:04:25Z] BLOCK [H-03] host=codex hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('site/public/diagrams/*.svg') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-30T22:04:40Z] BLOCK [H-01] host=codex hook=pre-bash.py | Direct commit to main is prohibited (ORCHESTRATOR §3). Create a feature branch. +[2026-07-31T01:19:05Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T01:21:12Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T01:45:54Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T01:46:28Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T02:03:40Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T02:34:43Z] BLOCK [H-05] host=codex hook=pre-write.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) are append-only (ORCHESTRATOR §7). Append with Edit or '>>', never Write. +[2026-07-31T02:36:33Z] BLOCK [H-11] host=codex hook=pre-write.py | ADR files are authored only via /adr (ORCHESTRATOR §3) — user attribution required. Subagent-authored ADRs are prohibited. +[2026-07-31T02:37:52Z] BLOCK [H-11] host=codex hook=pre-write.py | ADR files are authored only via /adr (ORCHESTRATOR §3) — user attribution required. Subagent-authored ADRs are prohibited. +[2026-07-31T02:40:27Z] BLOCK [H-11] host=codex hook=pre-write.py | ADR files are authored only via /adr (ORCHESTRATOR §3) — user attribution required. Subagent-authored ADRs are prohibited. +[2026-07-31T02:43:22Z] BLOCK [H-19] host=codex hook=pre-write.py | The .codearbiter/.markers/ gate tokens are not writable via the Write tool (#160) — a hand-written marker forges a security/migration/ADR gate pass. Markers are recorded only by the sanctioned gate producers. +[2026-07-31T02:46:33Z] BLOCK [H-05] host=codex hook=pre-write.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) are append-only (ORCHESTRATOR §7). A patch cannot express a verifiable pure append; append with '>>' instead. +[2026-07-31T02:58:22Z] BLOCK [H-18] host=codex hook=pre-write.py | This patch operation edits or deletes .codearbiter/CONTEXT.md (#159) — the activation switch every enforcement hook reads — and its resulting frontmatter cannot be verified from patch hunks. Failing closed; use the sanctioned init path (or $ca-override). +[2026-07-31T03:42:39Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$tmp' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T03:43:44Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$scratch' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T03:44:03Z] BLOCK [H-01] host=codex hook=pre-bash.py | branch state could not be determined (git unavailable or timed out) — failing closed (ORCHESTRATOR §2). Retry, or verify you are not on main/master before committing/pushing. Underlying git error: `git branch --show-current` -> fatal: not a git repository (or any of the parent directories): .git +[2026-07-31T04:45:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:45:54Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:46:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:46:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:46:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:46:55Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:47:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:47:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:47:30Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:47:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:47:54Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:47:59Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:48:17Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:48:31Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:48:43Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:53:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T04:53:54Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T05:10:56Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$tmp' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T05:11:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:11:23Z] BLOCK [H-03] host=codex hook=pre-bash.py | Wildcard staging is prohibited — a directory ('.codearbiter') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T05:11:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:11:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:11:44Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:11:55Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:12:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:15:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:15:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:15:56Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T05:16:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:18:45Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T05:37:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:37:31Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:37:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:37:53Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:02Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:43Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:38:48Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:39:00Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:39:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:39:26Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:39:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:39:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:40:00Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:40:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:45:03Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:47:02Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:47:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:47:28Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:48:39Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_pre_write.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:49:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_pre_write.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:49:17Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_pre_edit.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:49:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_pre_edit.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:53:16Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:53:29Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:53:55Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:54:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:54:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:54:29Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-write.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:54:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:54:58Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:55:11Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:55:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/pre-edit.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:55:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:55:59Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:56:13Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:56:29Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:56:42Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:56:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:57:02Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:57:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:57:35Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:57:46Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:58:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:58:30Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:58:43Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:58:53Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T05:59:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:16:03Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('core/surface/skills/commit-gate/*.md') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T06:16:12Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('core/surface/skills/commit-gate/') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T06:16:14Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('\') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T06:45:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_pathnorm.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:45:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:45:36Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:45:53Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:45:58Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:46:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:46:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:47:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:47:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:48:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:48:28Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:50:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:50:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:50:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:51:26Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:52:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:52:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:52:26Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:54:58Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_pre_write.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:55:06Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_pre_write.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:55:55Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:56:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:56:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:56:46Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:56:57Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:57:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:57:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:57:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:57:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:58:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:58:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:58:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:58:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:58:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:58:53Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:03Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T06:59:59Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:00:03Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:00:13Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:00:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:00:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:00:53Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:01:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:01:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:01:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:08:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/security-controls.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:11:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:11:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:11:30Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:11:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:11:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:26:34Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('plugins/ca/hooks') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T07:26:51Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('\') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T07:30:21Z] BLOCK [H-11] host=claude hook=pre-write.py | ADR files are authored only via /adr (ORCHESTRATOR §3) — user attribution required. Subagent-authored ADRs are prohibited. +[2026-07-31T07:37:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:44:04Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:44:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:44:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T07:51:26Z] REMIND [H-12] host=claude hook=post-write-edit.py | README.md is governed by ADR-0006-broad-adoption-oss-posture (Broad-adoption OSS posture, optimizing for adoption over a commercial vertical). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:09:26Z] BLOCK [H-19] host=codex hook=pre-write.py | The .codearbiter/.markers/ gate tokens are not writable via the Write tool (#160) — a hand-written marker forges a security/migration/ADR gate pass. Markers are recorded only by the sanctioned gate producers. +[2026-07-31T08:13:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:13:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:13:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:14:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:14:22Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:14:35Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:16:26Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T08:19:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:19:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:19:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:19:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:19:39Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:19:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:20:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:20:54Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T08:21:16Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:21:23Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:28:36Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log, decisions/decision-log.md) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. +[2026-07-31T08:35:39Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T08:38:23Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$base' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T08:41:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T08:45:31Z] REMIND [H-07] host=claude hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T08:45:34Z] REMIND [H-07] host=claude hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T08:51:10Z] BLOCK [H-11] host=codex hook=pre-write.py | ADR files are authored only via /adr (ORCHESTRATOR §3) — user attribution required. Subagent-authored ADRs are prohibited. +[2026-07-31T09:03:45Z] BLOCK [H-18] host=codex hook=pre-bash.py | .codearbiter/CONTEXT.md is the activation switch every enforcement hook reads (#159) — shell rewrites, edits, or deletions that could flip `arbiter: enabled` off or corrupt its frontmatter are prohibited. Edit it through the sanctioned init path. +[2026-07-31T09:35:00Z] REMIND [H-07] host=claude hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T09:35:03Z] REMIND [H-07] host=claude hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T09:37:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T09:37:10Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T09:37:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T09:37:18Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T09:37:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/tech-stack.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T09:38:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T09:38:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T09:49:46Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T10:11:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T10:11:33Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T10:13:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T10:13:20Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T10:49:31Z] REMIND [H-10] host=claude hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T10:51:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T10:51:51Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T10:57:06Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T11:08:11Z] BLOCK [H-19] host=claude hook=pre-bash.py | The .codearbiter/.markers/ security-gate-passed / migration-gate-passed tokens are recorded only by the sanctioned gate producers (#160) — a shell redirect, write verb, or interpreter invocation (python/node/perl/ruby/sh) naming a gate marker forges a security/migration gate pass and is prohibited. +[2026-07-31T11:34:06Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T11:34:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T12:06:55Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T12:07:42Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T12:16:34Z] BLOCK [H-05] host=codex hook=pre-write.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) are append-only (ORCHESTRATOR §7). A patch cannot express a verifiable pure append; append with '>>' instead. +[2026-07-31T12:20:50Z] BLOCK [H-05] host=codex hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. +[2026-07-31T12:57:39Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T12:57:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T12:58:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T12:59:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:00:11Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:00:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:00:39Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:00:57Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:01:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:01:48Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:02:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:02:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:05:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:07:13Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:07:17Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:19:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:54:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:54:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:54:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:54:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:54:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:54:58Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:55:16Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:55:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:55:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:55:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:55:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T13:55:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:04:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:04:36Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:04:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:06:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:14:45Z] BLOCK [H-09b] host=claude hook=pre-bash.py | the diff for the crypto/secret security scan could not be read (git unavailable or timed out) — failing closed (ORCHESTRATOR §2). Retry, or run the crypto-compliance / secret-handling gate, then commit. Underlying git error: `git -c diff.mnemonicPrefix=false -c diff.noprefix=false diff --no-ext-diff --src-prefix=a/ --dst-prefix=b/ HEAD -- git log echo === the ratchet test should now go green against the committed skill === export NO_COLOR= python .github/scripts/test_consumer_smoke.py /dev/null` -> fatal: Invalid path '/dev': No such file or directory +[2026-07-31T14:18:45Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log, decisions/decision-log.md) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. +[2026-07-31T14:26:42Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:26:46Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:26:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:27:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:27:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:28:13Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:28:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:28:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:29:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/decompose/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:29:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/context-creation/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:29:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/context-creation/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:29:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/context-creation/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:30:00Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/context-creation/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:37:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:37:23Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:37:28Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:37:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T14:40:59Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:02:52Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T15:06:39Z] BLOCK [H-18] host=claude hook=pre-bash.py | .codearbiter/CONTEXT.md is the activation switch every enforcement hook reads (#159) — shell rewrites, edits, or deletions that could flip `arbiter: enabled` off or corrupt its frontmatter are prohibited. Edit it through the sanctioned init path. +[2026-07-31T15:06:44Z] BLOCK [H-18] host=claude hook=pre-write.py | This Write would remove or alter the `arbiter: enabled` frontmatter in .codearbiter/CONTEXT.md (#159) — the activation switch every enforcement hook reads. Disabling it from inside the repo would make every gate dormant. Keep `arbiter: enabled` in a well-formed frontmatter block. +[2026-07-31T15:07:10Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('=== clean now? ===') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T15:08:59Z] BLOCK [H-20] host=claude hook=pre-bash.py | '--no-verify' / '-n' on git commit skips the .git/hooks git-enforce backstop entirely (appsec-002) — every commit-time gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this commit. Remove the flag; use /override for a sanctioned bypass. +[2026-07-31T15:11:48Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log, decisions/decision-log.md) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. +[2026-07-31T15:23:58Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:24:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:24:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:24:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:24:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:25:03Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:26:57Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:27:04Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:27:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:27:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:28:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:28:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:31:43Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:32:36Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:32:46Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:32:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:33:04Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:33:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:33:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:33:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:33:54Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:34:21Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:34:30Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T15:41:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:04:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:04:51Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T16:05:06Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:05:06Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-07-31T16:22:10Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T16:40:30Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:40:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:40:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:40:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:41:06Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:41:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:41:22Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:43:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:44:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:45:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:45:22Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:45:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:46:02Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:46:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:46:25Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:46:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:47:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T16:57:23Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T17:01:50Z] BLOCK [H-02] host=claude hook=pre-bash.py | Force-push is prohibited (ORCHESTRATOR §3). +[2026-07-31T17:02:18Z] BLOCK [H-18] host=claude hook=pre-bash.py | .codearbiter/CONTEXT.md is the activation switch every enforcement hook reads (#159) — shell rewrites, edits, or deletions that could flip `arbiter: enabled` off or corrupt its frontmatter are prohibited. Edit it through the sanctioned init path. +[2026-07-31T17:08:47Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T17:13:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:16:22Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:18:48Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:18:58Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:19:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:19:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:24:23Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:24:50Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T17:24:56Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('.github/scripts') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T17:28:10Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T17:28:32Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('module.exports = { render: (o) => o ? "widget" : "widget" };\n') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T17:37:41Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T17:42:35Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:42:44Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:42:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:43:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:43:17Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:43:31Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T17:51:23Z] BLOCK [H-20] host=claude hook=pre-bash.py | '--no-verify' / '-n' on git commit skips the .git/hooks git-enforce backstop entirely (appsec-002) — every commit-time gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this commit. Remove the flag; use /override for a sanctioned bypass. +[2026-07-31T17:54:30Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T17:56:47Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T18:06:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:06:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:06:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:06:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:13:19Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T18:13:29Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('(tag list exit=$?)') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T18:15:04Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('commit exit=$?') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T18:20:19Z] BLOCK [H-20] host=claude hook=pre-bash.py | '--no-verify' / '-n' on git commit skips the .git/hooks git-enforce backstop entirely (appsec-002) — every commit-time gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this commit. Remove the flag; use /override for a sanctioned bypass. +[2026-07-31T18:27:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:28:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:28:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:28:39Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:28:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:29:13Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:29:31Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:33:07Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:37:34Z] BLOCK [H-01] host=claude hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$FX' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T18:37:35Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T18:37:56Z] BLOCK [H-01] host=claude hook=pre-bash.py | 'git -C C:/c/Users/brenn/AppData/Local/Temp/claude/C--Users-brenn-projects-codeArbiter/04889afe-e4db-4c0a-9c68-896597704fd9/scratchpad/t78g/fx1' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T18:38:06Z] BLOCK [H-01] host=claude hook=pre-bash.py | branch state could not be determined (git unavailable or timed out) — failing closed (ORCHESTRATOR §2). Retry, or verify you are not on main/master before committing/pushing. Underlying git error: `git branch --show-current` -> fatal: not a git repository (or any of the parent directories): .git +[2026-07-31T18:38:34Z] BLOCK [H-01] host=claude hook=pre-bash.py | Direct commit to main is prohibited (ORCHESTRATOR §3). Create a feature branch. +[2026-07-31T18:38:54Z] BLOCK [H-01] host=claude hook=pre-bash.py | Direct commit to main is prohibited (ORCHESTRATOR §3). Create a feature branch. +[2026-07-31T18:39:11Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-07-31T18:39:18Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('C:/Users/brenn/AppData/Local/Temp/claude/C--Users-brenn-projects-codeArbiter/04889afe-e4db-4c0a-9c68-896597704fd9/scratchpad/t78g/fx1') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T18:39:26Z] BLOCK [H-01] host=claude hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$fx' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-07-31T18:39:32Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('C:/Users/brenn/AppData/Local/Temp/claude/C--Users-brenn-projects-codeArbiter/04889afe-e4db-4c0a-9c68-896597704fd9/scratchpad/t78g/fx1') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T18:43:50Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T18:43:50Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T18:52:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:52:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T18:53:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:08:00Z] REMIND [H-10] host=codex hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-31T19:12:04Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/release-targets.md is governed by ADR-0024-protected-state-declared-executable-input-boundary (Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:18:38Z] BLOCK [H-11] host=claude hook=pre-bash.py | ADR files under .codearbiter/decisions/ are authored only via /adr and are immutable history (ORCHESTRATOR §6) — shell writes, edits, and deletions there are prohibited. +[2026-07-31T19:18:55Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log, decisions/decision-log.md) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. +[2026-07-31T19:20:00Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:20:06Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:20:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:20:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:20:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:21:09Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:21:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:23:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:24:04Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/security-controls.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:44:04Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('# Changelog\n\n## [1.1.0] - 2026-07-31\n') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T19:44:35Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:44:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:45:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:50:02Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T19:55:52Z] BLOCK [H-09b] host=claude hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path. +[2026-07-31T19:55:54Z] BLOCK [H-09b] host=claude hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path. +[2026-07-31T19:57:23Z] BLOCK [H-19] host=claude hook=pre-bash.py | The .codearbiter/.markers/ security-gate-passed / migration-gate-passed tokens are recorded only by the sanctioned gate producers (#160) — a shell redirect, write verb, or interpreter invocation (python/node/perl/ruby/sh) naming a gate marker forges a security/migration gate pass and is prohibited. +[2026-07-31T20:08:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:08:39Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:09:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:09:23Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:14:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:14:28Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:14:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:18:21Z] BLOCK [H-20] host=claude hook=pre-bash.py | '--no-verify' / '-n' on git commit skips the .git/hooks git-enforce backstop entirely (appsec-002) — every commit-time gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this commit. Remove the flag; use /override for a sanctioned bypass. +[2026-07-31T20:18:26Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('commit exit=$?') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T20:18:31Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern ('[end porcelain]') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T20:21:14Z] BLOCK [H-19] host=codex hook=pre-write.py | The .codearbiter/.markers/ gate tokens are not writable via the Write tool (#160) — a hand-written marker forges a security/migration/ADR gate pass. Markers are recorded only by the sanctioned gate producers. +[2026-07-31T20:23:46Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a glob pattern (' +import io,sys +io.open(sys.argv[2],'w',encoding='utf-8',newline='\n').write(open(sys.argv[1],encoding='utf-8').read()+'\nReleased-at: 2026-07-31\n') +') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-31T20:28:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:28:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:28:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:29:21Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:34:43Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:34:54Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:35:16Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:51:14Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:51:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:51:48Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:52:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:52:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T20:56:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-31T21:04:55Z] BLOCK [H-20] host=codex hook=pre-bash.py | '--no-verify' / '-n' on git commit skips the .git/hooks git-enforce backstop entirely (appsec-002) — every commit-time gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this commit. Remove the flag; use /override for a sanctioned bypass. +[2026-07-31T21:13:54Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T21:23:54Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T21:24:15Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-31T22:05:57Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T22:08:42Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T22:11:04Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:14:48Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:19:01Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:20:49Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:38:45Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:39:36Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:40:03Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-07-31T23:53:56Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T00:31:06Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T00:35:34Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T00:41:14Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T00:57:24Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T02:06:21Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T02:39:08Z] BLOCK [H-20] host=claude hook=pre-bash.py | '--no-verify' / '-n' on git commit skips the .git/hooks git-enforce backstop entirely (appsec-002) — every commit-time gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this commit. Remove the flag; use /override for a sanctioned bypass. +[2026-08-01T02:44:33Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$tmp' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-08-01T02:52:29Z] BLOCK [H-05] host=codex hook=pre-write.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) are append-only (ORCHESTRATOR §7). A patch cannot express a verifiable pure append; append with '>>' instead. +[2026-08-01T03:04:43Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T03:27:36Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:32:54Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/releasehash.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:37:43Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:42:26Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:43:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:44:52Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/release.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:44:52Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-08-01T03:45:17Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/release.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T03:45:17Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block. +[2026-08-01T04:01:59Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass. +[2026-08-01T04:02:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:02:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:02:47Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:02:53Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:03:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:03:29Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/debug/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:13:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/skills/context-creation/SKILL.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:16:16Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:17:48Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:18:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:18:12Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:18:34Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:22:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/init-codearbiter.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:23:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:26:18Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/commands/standup.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:33:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/surface/commands/release.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T04:35:50Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$learner' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-08-01T04:38:45Z] BLOCK [H-03] host=claude hook=pre-bash.py | Wildcard staging is prohibited — a directory ('plugins/ca') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-08-01T05:00:00Z] BLOCK [H-01] host=codex hook=pre-bash.py | 'git -C C:/Users/brenn/projects/codeArbiter\$d' does not resolve to an existing directory — failing closed (ORCHESTRATOR §2). Verify the -C target exists before committing/pushing. +[2026-08-01T05:10:05Z] BLOCK [H-03] host=codex hook=pre-bash.py | Wildcard staging is prohibited — a directory ('docs') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-08-01T05:17:40Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_taskboardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:18:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:18:28Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:18:36Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:22:55Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:23:01Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:23:16Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:24:04Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:24:11Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:24:19Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:24:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:24:50Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_protectedstatelib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:25:11Z] BLOCK [H-09b] host=claude hook=pre-bash.py | the diff for the crypto/secret security scan could not be read (git unavailable or timed out) — failing closed (ORCHESTRATOR §2). Retry, or run the crypto-compliance / secret-handling gate, then commit. Underlying git error: `git -c diff.mnemonicPrefix=false -c diff.noprefix=false diff --no-ext-diff --src-prefix=a/ --dst-prefix=b/ HEAD -- % body for rel,pol,red,wr,gr,it in B._STATE_WRITE_RES: hits=[n for n,r in (("redirect",red),("write",wr),("git",gr),("interp",it)) if r.search(cmd)] if hits: print(f"BLOCK on {rel}: {hits}") m = None for rel,pol,red,wr,gr,it in B._STATE_WRITE_RES: m = wr.search(cmd) if m: print("\nmatched span:", repr(m.group(0))[:220])` -> fatal: m:: 'm:' is outside repository at 'C:/Users/brenn/projects/codeArbiter' +[2026-08-01T05:25:44Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:26:06Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:26:15Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:26:36Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:26:52Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/security-controls.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:27:03Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/security-controls.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:30:23Z] BLOCK [H-03] host=claude hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill). +[2026-08-01T05:36:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/hooks/tests/test_protectedstatelib.py is governed by ADR-0004-database-free-stdlib-only-architecture (Database-free architecture; Python hooks are stdlib-only). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:36:52Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:50:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:50:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:52:21Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:56:20Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-codex/CHANGELOG.md is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:56:30Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/CHANGELOG.md is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:58:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/_releaselib.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T05:58:23Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/CHANGELOG.md is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T06:01:28Z] REMIND [H-12] host=claude hook=post-write-edit.py | README.md is governed by ADR-0006-broad-adoption-oss-posture (Broad-adoption OSS posture, optimizing for adoption over a commercial vertical). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-08-01T06:43:10Z] REMIND [H-12] host=claude hook=post-write-edit.py | core/pysrc/taskwrite.py is governed by ADR-0011-multi-host-codex-plugin-shared-core (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. diff --git a/.codearbiter/open-questions.md b/.codearbiter/open-questions.md index a0fb4163..f41136dd 100644 --- a/.codearbiter/open-questions.md +++ b/.codearbiter/open-questions.md @@ -30,6 +30,10 @@ strategy (a), a lightweight staleness *warn* paired with the #186 gate-events si - **D-2 — Archival sweep owner + cutoff.** Which command runs the deliberate, confirmed, append-only sweep of long-settled `[x]` items from `open-tasks.md` into `done-tasks.md`, and what "long-settled" means (e.g. done >14 days). Archival is post-MVP; done items stay in-place under `## Done` until then. - **D-3 — Stale-in-progress threshold.** The age at which a `[~]` task triggers the SessionStart nudge. Default 3 days ships as a named constant; this only tunes the number (the mechanism is tested with an injected date, so the value is non-load-bearing). +### `release-portable-fixture` feature deferrals (spec: `specs/release-portable-fixture.md`, 2026-07-30) + +- **D-6 — `published-tags.json` location.** The tag-provenance manifest currently lives at `.github/published-tags.json`, a path the portable skill cannot assume exists in a consumer repo. _Narrowed 2026-07-30 after adversarial review:_ the spec's rev-1 form of this deferral was incoherent, because it waved the path off as "declared per target" while the row schema had no field to declare it in — leaving the skill's tag-provenance hard rule requiring a `.github/` path that the portability guard forbids. The schema now carries an optional `provenance-manifest` field (spec AC-3.5), and a row omitting it skips the recording step with an explicit note in the report. What remains deferred is only whether **this repo's** copy physically moves from `.github/` to `.codearbiter/` for consistency with the project-state store. Relocating requires repointing `check_tag_immutability.py` and its `[CHECK] | [REPO] | Published tag immutability` CI check in the same PR, since that check compares live refs against recorded entries and a path change with a stale checker reads as a deletion. Non-blocking. + ### `task-writer-harvest` feature deferrals (spec: `specs/task-writer-harvest.md`, 2026-06-21) - **D-4 — `/ca:standup` harvest backstop.** Whether the daily standup should offer to promote any workflow residue NOT caught at a terminal step (a sweep that re-scans recent checkpoint docs / sprint-log / plan files for un-promoted `[NEEDS-TRIAGE]`/DEFERRABLE/low-confidence items). v1 harvests only at the terminal steps; this is the safety net if a terminal step is skipped or interrupted. diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md new file mode 100644 index 00000000..15d1a6bb --- /dev/null +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -0,0 +1,412 @@ +# Plan — portable release + protected-state machinery + +**Spec:** `.codearbiter/specs/portable-release-and-protected-state.md` +**Companion spec:** `.codearbiter/specs/release-portable-fixture.md` (rev 4) +**Date:** 2026-07-31 +**Status column is the resume ledger** — `subagent-driven-development` flips a task to `ACCEPTED` on +acceptance; an interrupted run re-enters at the first non-`ACCEPTED` task. + +> **Verification note.** Every `unittest discover` verification MUST be run with `NO_COLOR` unset. +> This harness exports `NO_COLOR=1`, which makes `statusline.py` strip SGR and fails 7 palette tests +> against a healthy tree. Prefix with `Remove-Item Env:\NO_COLOR -ErrorAction SilentlyContinue;` in +> the same shell call. + +## AC ledger + +### Workstream A — release portability + +The companion spec is **authoritative**; criteria are cited by ID with a short label rather than +duplicated, so the two documents cannot drift. + +| AC | label | +|---|---| +| A-1.1 | `core/pysrc/_releaselib.py` exists; `sync-core --check` passes | +| A-1.2 | mechanism carries no repo-namespace literal (denylist) | +| A-1.3 | repo defaults become required params (`classify_merge_readiness`, `last_tag_select`, `select_release_target`) | +| A-1.4 | `load_targets(path)` returns the full row schema | +| A-1.5 | absent block → declared error | +| A-1.6 | each parser-contract violation → its own declared error (8 cases) | +| A-1.7 | empty block → declared error | +| A-1.8 | series isolation against loaded data | +| A-1.9 | *(transitional)* shim re-exports mechanism, still exposes `RELEASE_TAG_PREFIXES` | +| A-1.10 | this repo's four rows load; target+prefix equal pre-change constants | +| A-1.11 | resolution trace reproduces a recorded pre-change run for `ca` and `ca-pi` | +| A-1.12 | the trace asserts the **intended** `last_tag_select` divergence on a marker-bearing prefix, so it says "exactly this changed, on purpose" rather than "nothing changed" | +| A-2.1 | pre-tag commands execute in declared order | +| A-2.2 | non-zero exit blocks | +| A-2.3 | dirty tree blocks; assertion precedes `rebuild` | +| A-2.4 | >1024-char `pre-tag` entry rejected | +| A-2.5 | `security-controls.md` boundary-crossings entry | +| A-2.6 | protected class admits `release-targets.md` writes only under marker | +| A-2.7 | four-case flank test | +| A-2.8 | `check_command_catalog.py` exists and is non-mutating | +| A-2.9 | this repo's declared rows run green on a reconciled tree | +| A-2.10 | `pre-tag` content-hash change forces re-confirmation | +| A-3.1 | declared manifest → assert equality, BLOCK on mismatch | +| A-3.2 | no manifest → tag is version source | +| A-3.3 | `rebuild` runs; artifacts asserted clean; nondeterministic bundler named | +| A-3.4 | `payload-exclude` honored (ca-pi `tools/`) | +| A-3.5 | `provenance-manifest` optional; absent → skip + report | +| A-3.6 | interpreter fallback where `python3` absent | +| A-4.1 | `payload_version_gate.py` derives prefixes from declared file | +| A-4.2 | target selection name-keyed | +| A-4.3 | workflow-contract test on name agreement | +| A-4.4 | constants removed from shim; six sites + gate still pass; A-1.9 test retired | +| A-5.1 | `decompose` elicits intent only | +| A-5.2 | `context-creation` writes a file `load_targets` accepts | +| A-5.3 | back-fill presents and requires confirmation | +| A-5.4 | back-fill persists; second run reads | +| A-5.5 | first-release changelog baseline instead of per-commit BLOCK | +| A-5.6 | provenance triggers are the rows' referenced paths | +| A-6.0 | the release skill itself resolves targets from the declared file (table becomes loader, helpers repoint, provenance step reads the row field, hosted-lane prose conditionalized) | +| A-6.1 | reference-form guard over `core/surface/skills/**` | +| A-6.2 | `subagent-driven-development` farm.js reference resolves | +| A-6.3 | `decision-lifecycle` reworded to conditional CI reference | +| A-6.4 | `commands/release.md` matches the skill | +| A-6.5 | docs-site guide distinguishes general lane from this repo | +| A-6.6 | portability proven in a clean consumer repo built by `git archive HEAD` — reference resolution, prose-extracted lane driver, and a narrow agent-judgment layer; assertions on derived outputs, never exit codes | +| A-6.7 | this repo still releases — pinned old lane and new lane derive the same version, window and composed tag **message file** from live HEAD, with **zero refs created** | +| A-6.8 | the agent-layer proof cannot rot: a `pre-tag` check asserts the recorded skill-content hash still matches what ships, so editing the skill without re-running the proof blocks the next release | + +### Workstream B — protected-state machinery + +| AC | criterion | +|---|---| +| B-01 | The registry carries a per-entry policy enum `{marker-gated, helper-only, append-only}`, present from slice 1 | +| B-02 | `marker-gated`: a Write is admitted only under a fresh authoring marker | +| B-03 | `marker-gated`: the Edit flank blocks via `classify_protected` per-class dispatch | +| B-04 | `marker-gated`: the shell flank blocks via a redirect + write-verb regex pair | +| B-05 | `helper-only`: Write, Edit, and shell naming the file are hard-blocked with **no** marker path | +| B-06 | `append-only`: mutation is admitted only via the helper's append verb | +| B-07 | `git add open-tasks.md` passes the shell flank | +| B-08 | `taskwrite add -- "fix open-tasks.md schema"` passes (filename as argv data) | +| B-09 | `tee open-tasks.md` and `>> open-tasks.md` block | +| B-10 | A stale marker (older than the freshness window) does not admit a write | +| B-11 | The class carries a stable `H-NN` ID cited in code comments and its test | +| B-12 | With enrolment live, a `taskwrite.py` invocation still succeeds (circularity proof) | +| B-13 | `release-targets.md` is registered `marker-gated` | +| B-14 | `open-tasks.md` is registered `helper-only` | +| B-15 | `done-tasks.md` is registered `append-only` | +| B-16 | `debug/SKILL.md:80` writes via `{{CMD:task}} add` rather than a direct append | +| B-17 | `taskwrite add` supports the rationale sub-bullet `debug` requires | +| B-18 | `context-creation`'s board population routes through the helper or a declared scaffold-time exemption | +| B-19 | A done-flip still classifies RETAINED through `classify_board_diff` after enrolment | +| B-20 | `taskwrite archive ` appends to `done-tasks.md` then removes from `open-tasks.md`, per item | +| B-21 | `archive` is rerun-safe: dedup by dotted ID, exact text for ID-less entries | +| B-22 | Interruption mid-sweep leaves item-level consistency — no duplicate, no loss | +| B-23 | `done-tasks.md` is created with the expected header shape | +| B-24 | `/ca:standup` offers the sweep under per-item confirmation | +| B-25 | Cutoff is a named constant, default done > 14 days, tested with an injected date | +| B-26 | An undated `[x]` archives only under explicit per-item confirmation | +| B-27 | An ADR records the executable-input boundary and names the ADR-0010 shell-indirection residual | + +## Tasks + +Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, shared kernel in +`core/pysrc/`, hook tests in `plugins/ca/hooks/tests/`, standalone gates in `.github/scripts/`. +`SUITE` = `Remove-Item Env:\NO_COLOR -EA SilentlyContinue; python -m unittest discover -s plugins/ca/hooks/tests -p "test_*.py"`. + +### Step 1 — B1: the class and registry (MVP slice begins) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-01 | `core/pysrc/_protectedstatelib.py` | `python -m py_compile` passes; module imports with zero side effects | registry module exists | B-01 | — | ACCEPTED | +| T-02 | `core/pysrc/_protectedstatelib.py`, `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_policy_enum` — all three policies present, unknown policy raises | policy enum | B-01 | T-01 | ACCEPTED | +| T-03 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_registry_lookup` — registered path returns its policy, unregistered returns None | registry lookup | B-01 | T-02 | ACCEPTED | +| T-04 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_gated_write` — fresh marker admits, absent marker blocks | marker-gated Write | B-02 | T-03 | ACCEPTED | +| T-05 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_stale` — marker older than the window blocks | marker freshness | B-10 | T-04 | ACCEPTED | +**Flank wiring design — proxy-ruled 2026-07-31, do not re-derive.** Full reasoning in `sprint-log.md`. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-05a | `core/pysrc/_protectedlib.py` | `python .github/scripts/test_hooklib.py -k ClassifyProtectedStateTest` — `classify_protected` returns `"state"` for a registered path; return contract stays a set of strings so its four consumers see no change | classifier extension | B-01 | T-05 | ACCEPTED | +| T-05b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_no_legacy_overlap` — **no** registered path classifies into any legacy class; overlap fails loudly as a configuration error rather than resolving by precedence | overlap guard | B-01 | T-05a | ACCEPTED | +| T-06 | `core/pysrc/pre-write.py` | `SUITE -p "test_pre_write.py" -k TestH22ProtectedState` — one generic `"state"` branch resolves the entry's policy from the registry and applies it; no second lookup | pre-write flank | B-02, B-05 | T-05b | ACCEPTED | +| T-07 | `core/pysrc/pre-edit.py` | `SUITE -p "test_pre_edit.py" -k TestH22ProtectedState` — same generic branch; `helper-only` blocks **unconditionally**, no marker path | pre-edit flank | B-03, B-05 | T-06 | ACCEPTED | +| T-08 | `core/pysrc/_bashguardlib.py` | `SUITE -k TestStateShell` — `_state_write_res(basename) -> (redirect_re, write_re)` compiled once at import from the **code-constant** registry, per entry rather than one alternation. `TestStateShellWiring` must drive `run_guards()` itself: every other shell test calls `_check_h22_state` directly and would pass with the wiring deleted | shell flank | B-04, B-05 | T-07 | ACCEPTED | +| T-08a | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_marker_touch_allowed` — `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; `GATE_MARKER_NAMES` gains a comment stating it enumerates **block-to-allow** markers while friction markers stay touchable by design | minting fence | B-04 | T-08 | ACCEPTED | +| T-08b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_verb_in_description_residual` — documents that `taskwrite add -- "remember to tee open-tasks.md"` false-blocks; pins the **passing** B-08 form and records the residual rather than chasing it with smarter parsing | lexical residual | B-08 | T-08a | ACCEPTED | + +**Rulings encoded above, with the reasoning that produced them:** + +- **The registry is code constants, never disk-loaded.** A disk registry would let a consumer repo + un-protect `open-tasks.md` by editing a file. Zero-side-effects-at-import prohibits file I/O and + git, not regex compilation from module constants (`_scopelib.py:109-117` precedent). +- **Dispatch extends `classify_protected` rather than sitting beside it.** `_protectedlib.py:13-19` + records #528/#529, where independent class checks deadlocked because set membership was + uncoordinated; and #162 symlink laundering is closed *inside* the classifier + (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms). A parallel + lookup ships without symlink resolution, and a symlink alias writes through the guard on day one. +- **`helper-only` is unconditional, with merge conflicts as a named residual.** A conflict in + `open-tasks.md` itself has no `taskwrite` verb, so resolution routes through logged + `/ca:override`. The ADR carries a reopen condition: if `gate-events.log` shows board-conflict + overrides recurring, build a `taskwrite resolve` verb — never a guard exception. A + conflict-marker content predicate was rejected as converting file content into an authorization + signal. +- **`GATE_MARKER_NAMES` is not widened.** It enumerates markers that convert a BLOCK into an ALLOW; + an authoring marker fakes nothing and is self-mintable by design (ADR-0010). The risk runs + opposite to intuition — a future generic "hardening" over every registered marker would brick + every minting lane while stopping no non-cooperative agent. T-08a is the fence against that. +| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_git_add_passes` — `git add open-tasks.md` passes. Load-bearing: `commit-gate` Phase 7 runs exactly that on every retained board flip, so a git verb in the list makes commit-gate block itself | git-verb non-regression | B-07 | T-08 | ACCEPTED | +| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_filename_as_helper_argv_data_passes` — helper call with the filename in its description passes | argv-data non-regression | B-08 | T-08 | ACCEPTED | +| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_tee_blocks_unconditionally` and `-k test_append_redirect_blocks_unconditionally` | shell-write blocking | B-09 | T-08 | ACCEPTED | +| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_taskwrite_invocation_passes_with_enrolment_live` — the real `core/surface/commands/task.md` invocation shape. **Not mutation-killable by construction** — the command names no registered basename, which is the property being proved; assert it against the generated invocation rather than a hand-copy so it notices drift | circularity proof | B-12 | T-08 | ACCEPTED | +| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k TestStateShellAppendOnly` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | ACCEPTED | +| T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | ACCEPTED | +| T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | ACCEPTED | +| T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | ACCEPTED | + +**HARD GATE at T-16** — `/ca:adr` requires user attribution. Halts and surfaces. + +### Step 2 — A slices 1–4 (MVP slice continues through T-33) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-17 | `core/pysrc/_releaselib.py` | `python tools/sync-core.py --check` passes | mechanism ships | A-1.1 | T-15 | ACCEPTED | +| T-18 | `core/pysrc/_releaselib.py`, `.github/scripts/test_release_lib.py` | `python .github/scripts/test_release_lib.py -k denylist` — no repo literal | data-free mechanism | A-1.2 | T-17 | ACCEPTED | +| T-19 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k required_params` — three functions reject missing args | required params | A-1.3 | T-18 | ACCEPTED | +| T-20 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k load_targets` — full row schema from a fixture | loader | A-1.4 | T-19 | ACCEPTED | +| T-21 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k absent_block` raises the declared error | absent-block error | A-1.5 | T-20 | ACCEPTED | +| T-22 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k parser_contract` — 8 violations, 8 distinguishable errors | parser contract | A-1.6 | T-21 | ACCEPTED | +| T-23 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k empty_block` raises the declared error | empty-block error | A-1.7 | T-22 | ACCEPTED | +| T-24 | `.github/scripts/test_release_lib.py` | `-k series_isolation` — `v` and `ca-pi-v` resolve independently | series isolation | A-1.8 | T-23 | ACCEPTED | +| T-25 | `.github/scripts/_releaselib.py` | `python .github/scripts/payload_version_gate.py --plugin plugins/ca --base origin/main` exits 0 (bare invocation exits 2 — args are required) | transitional shim | A-1.9 | T-24 | ACCEPTED | +| T-26 | `.codearbiter/release-targets.md` | `python .github/scripts/test_release_lib.py -k this_repo_rows` — 4 rows load, prefixes match constants, **and every one of the four declares `provenance-manifest`** | repo rows declared | A-1.10 | T-25 | ACCEPTED | +| T-27a | `.github/scripts/fixtures/release-trace/` | `python .github/scripts/test_release_trace.py -k fixture_shape` — frozen tag list, manifests, commit graph, 4 rows | trace fixture | A-1.11 | T-26 | ACCEPTED | +| T-27b | `.github/scripts/test_release_trace.py` | `-k old_lane_loads` — helpers pinned via `git show :.github/scripts/_releaselib.py` | pinned old lane | A-1.11 | T-27a | ACCEPTED | +| T-27c | `.github/scripts/test_release_trace.py` | `-k old_lane_live` — the transcribed old lane resolves `ca`'s real last tag against the live repo; **divergence is a STOP, not a fixup** | old-lane validation | A-1.11 | T-27b | ACCEPTED | +| T-27d | `.github/scripts/test_release_trace.py` | `-k trace_matches` — new lane reproduces the recorded variable dict for `ca` **and** `ca-pi` | trace assertion | A-1.11 | T-27c | ACCEPTED | +| T-28 | `core/surface/skills/release/SKILL.md` | `test_release_lib.py -k pre_tag_order` — declared order preserved | pre-tag order | A-2.1 | T-27 | ACCEPTED | +| T-29 | `core/pysrc/_releaselib.py` | `-k pre_tag_exit` — non-zero exit blocks | pre-tag exit | A-2.2 | T-28 | ACCEPTED | +| T-30 | `core/pysrc/_releaselib.py` | `-k pre_tag_dirty` — dirty tree blocks, assertion precedes rebuild | clean-tree gate | A-2.3 | T-29 | ACCEPTED | +| T-31 | `core/pysrc/_releaselib.py` | `-k pre_tag_cap` — >1024 chars rejected | length cap | A-2.4 | T-30 | ACCEPTED | +| T-32 | `.codearbiter/security-controls.md` | boundary-crossings entry present; `test_release_lib.py -k boundary_entry` | boundary declared | A-2.5 | T-31 | ACCEPTED | +| T-33 | `core/pysrc/_protectedstatelib.py`, `.codearbiter/release-targets.md` | `SUITE -k test_release_targets_registered` — marker-gated, 4-case flank test passes | consumer 1 enrolled | A-2.6, A-2.7, B-13 | T-32 | ACCEPTED | + +**HARD GATE at T-32** — `security-controls.md` is a trust-boundary change. + +**— END MVP SLICE —** At T-33 the registry exists with a live consumer, the mechanism ships, this +repo's rows load, and the release lane is proven behavior-identical. Shippable on its own. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-34 | `.github/scripts/check_command_catalog.py` | script exits 0 on a reconciled tree, 1 on drift, mutates nothing | catalog check | A-2.8 | T-33 | ACCEPTED | +| T-35 | `.codearbiter/release-targets.md` | all declared `pre-tag` commands exit 0 on a clean tree | rows run green | A-2.9 | T-34 | ACCEPTED | +| T-36 | `core/pysrc/releasehash.py`, `tools/sync-core.py` generated set | `python tools/sync-core.py --check` passes with it enrolled; `SUITE -k test_pre_tag_hash` — changed hash forces re-confirmation | hash re-confirm | A-2.10 | T-35 | ACCEPTED | +| T-37 | `core/pysrc/_releaselib.py` | `-k manifest_declared` — equality asserted, mismatch BLOCKs | manifest assert | A-3.1 | T-36 | ACCEPTED | +| T-38 | `core/pysrc/_releaselib.py` | `-k manifest_absent` — tag is version source, no assertion | optional manifest | A-3.2 | T-37 | ACCEPTED | +| T-39 | `core/pysrc/_releaselib.py` | `-k rebuild_artifacts` — stale bundle blocks, cause named | rebuild gate | A-3.3 | T-38 | ACCEPTED | +| T-40 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k payload_exclude` — ca-pi `tools/` excluded | payload exclusions | A-3.4 | T-39 | ACCEPTED | +| T-41 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k provenance_optional` — absent → skipped and reported | optional provenance | A-3.5 | T-40 | ACCEPTED | + +**The skill rewrite — the campaign's central deliverable.** Absent from the first plan draft; a review +found the bijection passed because A-6.1 mapped to the guard *script* rather than the cleanup it +enforces. `SKILL.md` here means `core/surface/skills/release/SKILL.md` (the source; three payloads +generate from it). + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-41a | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k skill_uses_loader` — Targets table replaced by `load_targets`; no hardcoded row survives | table → loader | A-6.0 | T-41 | ACCEPTED | +| T-41b | `core/surface/skills/release/SKILL.md` | `python .github/scripts/check_skill_portability.py` — no `.github/scripts/` invocation remains; helpers resolve under `${CLAUDE_PLUGIN_ROOT}` | helper repointing | A-6.0 | T-41a | ACCEPTED | +| T-41c | `core/surface/skills/release/SKILL.md` | `-k skill_provenance_field` — Phase 3 step 5 reads the row field; absent → documented skip | provenance step | A-6.0, A-3.5 | T-41b | ACCEPTED | +| T-41d | `core/surface/skills/release/SKILL.md` | `-k skill_conditional_prose` — hosted-lane and immutability sections conditional on repo capability | prose conditionals | A-6.0 | T-41c | ACCEPTED | +| T-41e | — (review only) | adversarial Opus agent reviews the rewritten skill; BLOCK-level findings fixed and re-reviewed before proceeding | mid-sprint review | A-6.0 | T-41d | ACCEPTED | +| T-41f | `core/pysrc/_releaselib.py` | `python "/hooks/_releaselib.py" tag-prefix ca` exits 0 from a consumer-shaped environment — the mechanism gains a `__main__` CLI entry point | shipped CLI exists | A-6.0 | T-41b | ACCEPTED | + +> **T-41f exists because the plan had a hole.** T-41b repoints the skill's helper +> invocations to `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py`, but that file has **zero** +> `__main__` — only the `.github/scripts/` shim carries a CLI. Repointing alone would aim the +> prose at a file that cannot be invoked, so `tag-prefix`, `last-tag` and `classify` would still +> fail in a consumer *after* the rewrite "succeeded". Found by the T-74 lane driver, which runs +> the prose's invocation strings rather than importing the library. This is the third ledger hole +> in this campaign — the first two were the missing skill rewrite (A-6.0) and the missing +> portability proof (A-6.6/6.7) — and all three shared a shape: a criterion set that was +> internally consistent and silent about a step nobody had named. +| T-42 | `core/surface/skills/release/SKILL.md` | `-k interpreter_fallback` — uses the shipped `python3 "

" … \|\| python "

" …` pattern (`taskwrite.py:11`) | interpreter fallback | A-3.6 | T-41e | ACCEPTED | +| T-43 | `.github/scripts/payload_version_gate.py` | `python .github/scripts/test_payload_version_gate.py -k no_prefix_literal` | CI reads declared source | A-4.1 | T-42 | ACCEPTED | +| T-44a | `.github/scripts/_releaselib.py` | `python .github/scripts/test_release_lib.py -k select_target_name_keyed` — `name=value` argv pairs; unknown name fails closed | shim CLI shape | A-4.2 | T-43 | ACCEPTED | +| T-44b | `.github/workflows/release.yml` | `python .github/scripts/test_release_workflow.py -k name_keyed` — inputs plumbed by name, order-independent | workflow plumbing | A-4.2 | T-44a | ACCEPTED | +| T-45 | `.github/scripts/test_release_workflow.py` | `-k name_agreement` fails when declared set and workflow inputs disagree | contract test | A-4.3 | T-44b | ACCEPTED | +| T-46 | `.github/scripts/_releaselib.py` | `python .github/scripts/test_payload_version_gate.py`; `python .github/scripts/test_release_workflow.py`; `python .github/scripts/test_release_lib.py` all green; A-1.9's `test_releaselib_shim_exports_constants` deleted in this commit | shim data removed | A-4.4 | T-45 | ACCEPTED | + +### Step 3 — A slice 5: onboarding and back-fill + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-47 | `core/surface/skills/decompose/SKILL.md` | `python .github/scripts/test_board_sync.py -k decompose_intent_only` | intent-only elicitation | A-5.1 | T-46 | ACCEPTED | +| T-48 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_release_lib.py -k context_creation_writes_loadable` — the written file parses via `load_targets` | full elicitation | A-5.2 | T-47 | ACCEPTED | +| T-49 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_requires_confirmation` — no write without confirm | back-fill gate | A-5.3 | T-48 | ACCEPTED | +| T-50 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_persists` — second run reads, does not re-detect | back-fill persist | A-5.4 | T-49 | ACCEPTED | +| T-51 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k first_release_baseline` — baseline derived from `git log --diff-filter=A -- .codearbiter/CONTEXT.md`, with a user override offered in the prompt | adoption baseline | A-5.5 | T-50 | ACCEPTED | +> **T-51 completed (2026-07-31).** Mechanism and CLI landed in `a31d398`; the lane prose that USES them landed with run 11's remediation, batched with T-42's so one exercise covers both. The row is now ACCEPTED because the lane genuinely offers the baseline, not merely because the helper exists. + +| T-52 | `.codearbiter/.provenance/release-targets.json` | `python .github/scripts/test_provenancelib.py -k release_targets_triggers` | drift triggers | A-5.6 | T-51 | ACCEPTED | + +### Step 4 — B3: the two conversions + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-53 | `core/pysrc/taskwrite.py`, `core/pysrc/_taskboardlib.py` | `python .github/scripts/test_taskwriter.py -k add_rationale` — sub-bullet round-trips; `python tools/sync-core.py --check` passes | helper extension | B-17 | T-52 | ACCEPTED | +| T-54 | `core/surface/skills/debug/SKILL.md` | `python .github/scripts/test_board_sync.py -k debug_uses_helper` — no direct append remains | debug converted | B-16 | T-53 | ACCEPTED | +| T-55 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_board_sync.py -k context_creation_board_route` — seeds via a repeated `taskwrite add` loop; **no file-absent exemption predicate exists** | scaffold route | B-18 | T-54 | ACCEPTED | +| T-56 | `.github/scripts/test_board_sync.py` | `-k done_flip_retained` — flip classifies RETAINED with enrolment **simulated in a fixture**; live post-enrolment coverage is T-67 | ADR-0008 composition | B-19 | T-55 | ACCEPTED | + +### Step 5 — B4: archive verb, done-tasks, sweep + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-57 | `core/pysrc/_taskboardlib.py` | `test_taskwriter.py -k archive_transform` — pure text→text move | archive transform | B-20 | T-56 | ACCEPTED | +| T-58 | `core/pysrc/taskwrite.py` | `python .github/scripts/test_taskwriter.py -k archive_verb` — appends then removes, per item; **creates `done-tasks.md` with the canonical header when absent** | archive verb | B-20, B-23 | T-57 | ACCEPTED | +| T-59 | `.github/scripts/test_taskwriter.py` | `-k archive_rerun` — dotted-ID and exact-text dedup, no duplicate | rerun safety | B-21 | T-58 | ACCEPTED | +| T-60 | `.github/scripts/test_taskwriter.py` | `-k archive_interrupted` — kill between phases leaves no dup and no loss | interruption safety | B-22 | T-59 | ACCEPTED | +| T-61 | `core/pysrc/init-codearbiter.py` | `python .github/scripts/test_taskboardlib.py -k done_tasks_shape` — greenfield scaffold path; `python tools/sync-core.py --check` passes | done-tasks scaffolded | B-23 | T-60 | ACCEPTED | +| T-62 | `core/pysrc/_taskboardlib.py` | `-k archive_cutoff` — named constant, injected date | cutoff constant | B-25 | T-61 | ACCEPTED | +| T-63 | `core/pysrc/_taskboardlib.py` | `-k archive_undated` — undated `[x]` items appear in their own section, **excluded from cutoff math**, archivable only per-item | undated rule | B-26 | T-62 | ACCEPTED | +| T-64 | `core/surface/commands/standup.md` | `python .github/scripts/test_ux_conversion.py -k standup_sweep` — per-item confirmation | standup owns sweep | B-24 | T-63 | ACCEPTED | +| T-65 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_done_tasks_registered` — append-only, archive verb admitted | consumer 3 enrolled | B-15 | T-64 | ACCEPTED | + +### Step 6 — B2: open-tasks enrolment (lands last, per sequencing) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-66 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_open_tasks_registered` — helper-only, no marker path | consumer 2 enrolled | B-14 | T-65 | ACCEPTED | +| T-67 | full suite | `SUITE` green; `python .github/scripts/test_taskwriter.py`; `test_board_sync.py` | enrolment regression | B-05, B-12, B-19 | T-66 | ACCEPTED | + +### Step 7 — A slice 6: surfaces + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-68a | `.github/scripts/check_skill_portability.py` | `python .github/scripts/test_skill_portability.py -k matching_rule` — reference-form rule stated in the docstring; flags an executed `.github/scripts/` path, does **not** flag a scan-target list entry | matching rule | A-6.1 | T-67 | ACCEPTED | +| T-68b | `.github/scripts/check_skill_portability.py` | guard exits 1 against `core/surface/skills/**` at `469c2fb`, exits 0 after T-41a–d, T-69 and T-70 | guard wiring | A-6.1 | T-68a | ACCEPTED | +| T-69 | `core/surface/skills/subagent-driven-development/SKILL.md` | guard passes; reference reads `${CLAUDE_PLUGIN_ROOT}/tools/farm.js` | farm.js reference | A-6.2 | T-68b | ACCEPTED | +| T-70 | `core/surface/skills/decision-lifecycle/SKILL.md` | guard passes; line is a conditional CI reference | ADR-identity reference | A-6.3 | T-69 | ACCEPTED | +| T-71 | `core/surface/commands/release.md` | `python .github/scripts/check-plugin-refs.py`; documents `[target]` only — `--auto`, `--dry-run` and `` deleted (a real dry-run is tracked as #565); phase numbers match the skill | command surface | A-6.4 | T-70 | ACCEPTED | +| T-72 | `site/src/content/docs/guides/releasing-a-version.md` | `npm --prefix site test` green | docs guide | A-6.5 | T-71 | ACCEPTED | + +### Step 8 — Completion proof (the sprint is not done without this) + +Per the maintainer's completion bar: proven to work **and** to port. Verifying against this repo's +hand-built `.codearbiter/` state is the documented way consumer-facing bugs stay hidden, so the +consumer proof runs in a scratch repo with no file from this repository present. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +**Pulled forward as a ratchet.** T-73a/T-73b run right after T-27d, not at the end. The loader and +library already landed at `0664506`, so the fixture is feasible now — and authoring it at the end +means transcribing its expected values from the implementation it is supposed to check. + +The ratchet is what makes early landing safe. A long-red test cannot be a required check while red, +so it enforces nothing for weeks, and a test red for its whole life gets edited into passing on the +day it finally matters. Instead T-73b compares the unresolved-reference set against a **committed +known-failures list** — green and required from day one, failing whenever that set changes in +**either** direction without the list moving in the same diff. It catches both a shrink nobody +recorded and a *new* contaminating reference sneaking in mid-campaign, which a plain red test would +silently absorb. T-41a–d, T-69 and T-70 each shrink the list in their own commit; T-79 asserts it is +empty and deletes the ratchet. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-73a | `.github/scripts/test_consumer_smoke.py` | scratch consumer repo built; plugin materialized by `git archive HEAD -- plugins/ca` into a scratch cache — **not** an in-repo `CLAUDE_PLUGIN_ROOT` pointer, **not** a recursive copy (both carry uncommitted and gitignored dev-tree state) | consumer fixture | A-6.6 | T-27d | ACCEPTED | +| T-73b | `.github/scripts/test_consumer_smoke.py`, `.github/scripts/known-unresolved-refs.txt` | `-k reference_resolution_ratchet` — unresolved refs in the **installed** SKILL.md equal the committed list exactly; fails on any change in either direction | reference ratchet | A-6.6 | T-73a | ACCEPTED | +| T-74 | `.github/scripts/test_consumer_smoke.py` | `-k lane_driver` — the mechanical sequence runs via **invocation strings extracted from the skill text**, never direct imports, so prose/CLI drift fails here | lane driver | A-6.6 | T-73b | ACCEPTED | +| T-75 | `.github/scripts/test_consumer_smoke.py` | `-k consumer_end_to_end` — asserts on derived **outputs** (resolved row, `LAST_TAG`, computed bump, rolled changelog text), never exit codes alone | portability proof | A-6.6 | T-74 | ACCEPTED | +| T-76 | `.github/scripts/test_consumer_smoke.py` | `-k backfill_detects` — no declared file, so the detected shape is presented and does not proceed unconfirmed | consumer back-fill | A-6.6 | T-75 | ACCEPTED | +| T-77 | `.github/scripts/test_release_trace.py` | `-k this_repo_still_releases` — pinned pre-change lane and new lane both derive next version, window and composed tag **message file** from live HEAD; equality asserted; **zero refs created** | this repo still releases | A-6.7 | T-76 | ACCEPTED | +| T-78 | `.codearbiter/reports/agent-lane-proof.json` | **SUBSTITUTED, see note:** eight independent BLIND agent exercises (not the scripted happy-path / missing-footer / back-fill scenarios this row originally specified), each against the skill as the previous run left it, with the **content hash of the shipped skill** recorded for the run the gate enforces | agent judgment layer | A-6.6 | T-77 | ACCEPTED | + +> **T-78 scope substitution (2026-07-31).** This row originally specified a SCRIPTED scenario harness. What shipped is eight human-directed blind exercises plus a hash-freshness gate wired as a declared `pre-tag` command. The substitution is recorded rather than silently absorbed: a scripted harness asserts only what its author already thought to script, and every HIGH this campaign found after the first run was in a seam nobody had thought to script. HIGHs by run: 4, 2, 0, 2, 1, 1, 1, 0. Three were introduced by the campaign's own remediation of an earlier HIGH. A scripted harness remains worth building and is NOT claimed by this row. +| T-79 | `.codearbiter/release-targets.md`, `.github/scripts/check_skill_proof_fresh.py` | a `pre-tag` row asserts the recorded skill hash still matches the shipped skill, so editing the skill without re-running the proof **blocks the next release**; and the known-failures list is asserted empty, retiring the ratchet | proof freshness + ratchet retirement | A-6.8, A-6.1 | T-78 | ACCEPTED | + +## Pre-run dispositions (maintainer-answered 2026-07-31) + +Encoded here so a subagent does not re-surface them. Full SMARTS in `sprint-log.md`. + +- **T-06 – T-08 — delegate with a tripwire.** Proceed when `security-reviewer` PASSes and B-07…B-12 + are green; halt only on a finding. This was a risk-appetite call, not an analysis result: Reliable + and Securable favored an unconditional halt. +- **T-16 / T-32 — content pre-approved.** ADR-**0024** (verified next-free) records the + executable-input boundary, the ≤1024 cap, that the marker is audit friction rather than + authorization, and names the ADR-0010 shell-indirection residual per flank. The + `security-controls.md` row mirrors ADR-0002's. T-32 lands without a stop; **T-16 still halts, for + attribution only**. +- **T-41e — adversarial Opus review**, not a maintainer stop. Maintainer reviews the skill text at PR + stage. +- **Identifiers** — hook ID **H-22** (H-21 is taken); ADR **0024**; marker `release-targets-authoring`, + with `-authoring` as the pattern for future consumers. +- **T-13 / T-65 — `append-only` blocks all tool writes.** Flank-identical to `helper-only`; the + distinction lives in the helper's verb constraint. No tail-anchored-Edit admission. +- **T-55 — helper loop, no exemption predicate.** A file-absent exemption would let delete-then-Write + launder arbitrary content through "the file is absent". + +**LOW — a known dead window.** Between T-33 (rows enrolled `marker-gated`) and T-49/T-50 (the minter +prose lands), no lane can legally edit `release-targets.md`. A correction in that window needs a +hand-armed marker or `/ca:override`. Expected, not a defect — do not treat the block as a failure. + +## Coverage proof + +**Every AC → at least one task.** A-1.1→T-17, A-1.2→T-18, A-1.3→T-19, A-1.4→T-20, A-1.5→T-21, +A-1.6→T-22, A-1.7→T-23, A-1.8→T-24, A-1.9→T-25/T-46, A-1.10→T-26, A-1.11→T-27a/b/c/d, A-2.1→T-28, +A-2.2→T-29, A-2.3→T-30, A-2.4→T-31, A-2.5→T-32, A-2.6→T-33, A-2.7→T-33, A-2.8→T-34, A-2.9→T-35, +A-2.10→T-36, A-3.1→T-37, A-3.2→T-38, A-3.3→T-39, A-3.4→T-40, A-3.5→T-41/T-41c, A-3.6→T-42, +A-4.1→T-43, A-4.2→T-44a/T-44b, A-4.3→T-45, A-4.4→T-46, A-5.1→T-47, A-5.2→T-48, A-5.3→T-49, +A-5.4→T-50, A-5.5→T-51, A-5.6→T-52, **A-6.0→T-41a/T-41b/T-41c/T-41d/T-41e**, A-6.1→T-68a/T-68b, +A-6.2→T-69, A-6.3→T-70, A-6.4→T-71, A-6.5→T-72, **A-6.6→T-73/T-74/T-75, A-6.7→T-76**. +B-01→T-01/02/03/15/T-05a/T-05b, B-02→T-04/T-06, B-03→T-07, B-04→T-08/T-08a, B-05→T-06/07/08/T-67, +B-06→T-13, B-07→T-09, B-08→T-10/T-08b, B-09→T-11, B-10→T-05, B-11→T-14, B-12→T-12/T-67, B-13→T-33, B-14→T-66, +B-15→T-65, B-16→T-54, B-17→T-53, B-18→T-55, B-19→T-56/T-67, B-20→T-57/T-58, B-21→T-59, B-22→T-60, +B-23→T-58/T-61, B-24→T-64, B-25→T-62, B-26→T-63, B-27→T-16. + +**Every task → at least one AC.** Verified across all 90 tasks; no task covers nothing. + +Bijective coverage proven: **72 criteria, 90 tasks**, no uncovered criterion and no orphan task. +(90 rather than 86 after the proxy rulings added T-05a, T-05b, T-08a, T-08b — the classifier +extension, the legacy-overlap guard, the marker-minting fence, and the lexical-residual pin.) + +*Rev 2/3 note — two holes were in the ledger, not the task set.* The first draft claimed bijection +over 69 criteria and 72 tasks. The claim was formally true and hollow both times: A-6.1 mapped to the +portability guard *script* while nothing rewrote the skill it polices (closed by A-6.0 + T-41x), and +#563's consumer-portability acceptance existed only as a prose checkbox, so no criterion and +therefore no task covered the thing the campaign is *for* (closed by A-6.6/A-6.7 + T-73–T-76). A +coverage proof over a criteria set with a hole in it proves the hole is consistent, nothing more. + +## Dependency order + +Strictly linear as written, with no cycle: +B1 (T-01–16) → A 1–4 (T-17–T-46, including the T-41x skill rewrite) → A 5 (T-47–52) → +B3 (T-53–56) → B4 (T-57–65) → B2 enrolment (T-66–67) → A 6 (T-68a–72). + +Ordering constraints that are not merely sequential: + +- **T-46 must not land before T-43–T-45**, or `payload_version_gate.py` breaks on every PR. T-25's + transitional test is deleted in T-46's own commit. +- **T-41a–d must land before T-68b**, since the guard cannot go green while the release skill still + carries its non-payload references. T-68b's verification names them explicitly. +- **T-58 must land before T-65.** The archive verb creates `done-tasks.md` when absent; once the file + is enrolled `append-only`, no tool write can create it. Every already-initialized repo — including + this one — never re-runs `init-codearbiter.py`, so T-61's scaffold path alone would leave the file + missing and the first archive failing. + +## Hard gates on the critical path + +After the pre-run dispositions, **two** stops remain rather than four: + +- **T-16** — ADR-0024 attribution. Content pre-approved; the halt is the signing act only. +- **Landing** — merge to the default branch; `/ca:sprint` auto-selects open-PR and never merges. + +Downgraded, with the reason recorded: + +- **T-06 – T-08** — now conditional. Halts only if `security-reviewer` reports a finding or any of + B-07…B-12 is red. +- **T-32** — no longer a stop; the boundary row text is pre-approved. + +Conditional stops that are not scheduled but may fire on genuinely new evidence: + +- **T-27c** — if the transcribed old-lane script disagrees with the live repo, that is new evidence + about the pre-change lane, not a fixup. STOP and investigate. +- **T-35** — a stale badge is not a stop (DECISION-0034 pre-decides the reconcile-and-rerun loop), but + drift revealing the catalog itself is wrong is new information. + +## Out of scope + +- `[NEEDS-TRIAGE]` D-6: whether `.github/published-tags.json` relocates to `.codearbiter/`. Tracked + in `open-questions.md`; not planned here. diff --git a/.codearbiter/release-targets.md b/.codearbiter/release-targets.md new file mode 100644 index 00000000..7c323446 --- /dev/null +++ b/.codearbiter/release-targets.md @@ -0,0 +1,96 @@ +# Release targets — codeArbiter (this repository) + +Declared-target-file for the portable `/ca:release` lane (issue #563, +`.codearbiter/specs/release-portable-fixture.md`). Parsed by +`load_targets()` / `parse_release_targets()` (`core/pysrc/_releaselib.py`, +vendored byte-identically into every governance plugin's `hooks/` directory) +per the spec's "File grammar" and "Parser contract" sections, which are +authoritative over this file's shape. + +Four rows, one per shipped plugin (the four rows the pre-rewrite +`plugins/ca/skills/release/SKILL.md` used to hardcode as a Targets table, +before T-41a replaced that table with this declared file and a loader). +Every row declares +`provenance-manifest: .github/published-tags.json` (maintainer decision, +2026-07-31): an absent field silently skips the tag-provenance recording +step, and all four of this repository's release lanes record provenance. + +Only `ca` is `latest-eligible` — one repo-wide "Latest" badge across four +release series; a sibling claiming it would hide `ca`'s current release from +every visitor. + +`ca-pi` has **two** manifests — the plugin's own `plugins/ca-pi/package.json` +plus the GENERATED repo-root `package.json` Pi actually installs — and its +payload **excludes** `tools/`, which ships neither generated policy nor a +built runtime artifact under `extensions/`. The root `package.json` is never +hand-edited: it is declared in `generated-manifest`, and its paired +`generate` command (`tools/build-host-packages.py`) is what the release +skill runs to regenerate it before asserting it equals the derived version — +the same trap the pre-change skill only stated in prose (HIGH-3, adversarial +review 2026-07-31), now declarable rather than folklore a consumer had no +way to express. The `pre-tag: ... --check` entry is the belt to that +suspenders: a non-mutating verify that also catches drift introduced any +other way. + +This file's `pre-tag` entries — and, as of HIGH-3, `ca-pi`'s `generate` +entry — are operator-authored executable input that a `contents: write` +release lane later runs, which is why it is a *declared* candidate for the +H-22 protected-state registry +(`.codearbiter/security-controls.md`, "Protected-state registry"). **It is +now protected** — T-33 enrolled it as `marker-gated`, so a Write, an Edit, a +shell redirect, and a `sed -i`-class verb all block unless a fresh +`release-targets-authoring` marker is present. `git add` stays allowed, or +`commit-gate` could not commit a sanctioned row edit. The three sanctioned +authors (`context-creation`, the release skill's back-fill lane, and its +row-edit path) each mint that marker. + + +[ca] +prefix: v +display-name: codeArbiter +manifest: plugins/ca/.claude-plugin/plugin.json +changelog: CHANGELOG.md +payload: plugins/ca/ +rebuild: cd plugins/ca/tools && npm run build +artifacts: plugins/ca/tools/farm.js +provenance-manifest: .github/published-tags.json +latest-eligible: true +pre-tag: python3 .github/scripts/check_badge_consistency.py +pre-tag: python3 .github/scripts/check_command_catalog.py +pre-tag: python3 .github/scripts/check_skill_proof_fresh.py + +[ca-codex] +prefix: ca-codex-v +manifest: plugins/ca-codex/.codex-plugin/plugin.json +changelog: plugins/ca-codex/CHANGELOG.md +payload: plugins/ca-codex/ +provenance-manifest: .github/published-tags.json +latest-eligible: false + +[ca-sandbox] +prefix: ca-sandbox-v +manifest: plugins/ca-sandbox/.claude-plugin/plugin.json +changelog: plugins/ca-sandbox/CHANGELOG.md +payload: plugins/ca-sandbox/ +rebuild: cd plugins/ca-sandbox/tools && npm run build +artifacts: plugins/ca-sandbox/tools/sandbox.js +artifacts: plugins/ca-sandbox/tools/claude-inside.js +provenance-manifest: .github/published-tags.json +latest-eligible: false + +[ca-pi] +prefix: ca-pi-v +manifest: plugins/ca-pi/package.json +manifest: package.json +generated-manifest: package.json +generate: python3 tools/build-host-packages.py +changelog: plugins/ca-pi/CHANGELOG.md +payload: plugins/ca-pi/ +payload-exclude: plugins/ca-pi/tools/ +rebuild: cd plugins/ca-pi/tools && node build.mjs +artifacts: plugins/ca-pi/extensions/codearbiter.js +artifacts: plugins/ca-pi/extensions/codearbiter-child.js +provenance-manifest: .github/published-tags.json +latest-eligible: false +pre-tag: python3 tools/build-host-packages.py --check + diff --git a/.codearbiter/reports/agent-lane-proof.json b/.codearbiter/reports/agent-lane-proof.json new file mode 100644 index 00000000..177bc184 --- /dev/null +++ b/.codearbiter/reports/agent-lane-proof.json @@ -0,0 +1,277 @@ +{ + "$schema_note": "Agent-judgment exercise record for the release skill (issue #563, spec .codearbiter/specs/release-portable-fixture.md AC-6.6 'agent judgment' layer). EIGHT independent blind exercises were run, each against the skill as the previous one left it. This file records the campaign; `exercise` below is the one run whose hash the freshness gate enforces. It is a record, not a test -- no number in it is re-derived by any test in this suite.", + "proof_current": false, + "proof_current_note": "FALSE, deliberately, as of run 18 (2026-08-01). Run 18 exercised the shipped skill at 33e82a6abdb952058b609c6e4fa5ad8c2d5bab503b5521bb8a5c3a901a5b5010 and returned two HIGHs. One was a prose parenthetical instructing an interactive rebase over ALREADY-PUBLISHED commits, which the same file forbids twice. That could not ship in order to obtain a green gate, so it was fixed AFTER the exercise -- see post_exercise_delta. The recorded hash is therefore one commit behind the shipped skill, and this gate WILL fail until run 19 runs against the final bytes. That is the gate working, not a defect in it. Never hand-edit this field to clear a block.", + "stopping_rule": { + "what_was_applied": "Agreed BEFORE run 16 and amended once, in writing, in .codearbiter/sprint-log.md. At most two runs per remediation round; the final run files findings at every severity and changes nothing, so its hash is the proof by construction. Amended after run 17, because the final exercise had been staged concurrently with a CI cycle that could still demand skill edits -- and did. The amendment added the terminator the original lacked: if a forcing function moves the skill after the exercise, record against the exercised hash and state the delta, rather than running again.", + "what_this_record_does_NOT_claim": "Not convergence, and not a clean run. Run 18 returned two HIGHs. The claim is narrower and checkable: the shipped skill was exercised end-to-end by an agent given no expected outcomes; the four properties runs 16 and 17 fixed were attacked directly and held; and everything found is either fixed or filed with a number. The HIGH sequence across this session is 2, 2, 2 -- each pair in a region the previous pair's fix had not covered, which is coverage expanding rather than the skill degrading, but it is emphatically not settlement." + }, + "exercise": { + "run": 18, + "date": "2026-08-01", + "role": "THE RECORDED PROOF. Staged only after CI went green with zero pending skill edits, so the archived bytes, the worktree bytes and the shipped payload were verified byte-identical before the agent read them. Directed to attack the four specific properties runs 16 and 17 had just fixed, rather than to roam.", + "method": "An agent was handed only the installed release skill and the consumer repository, and followed the ca lane from its first line. 58 commands, all read-only against the consumer; every write-bearing behaviour was tested in throwaway scratch repositories. Every helper was additionally run through a plugin root copied to a path containing a space. No expected outcomes were given and no prior run's findings were shared.", + "exercised_skill_sha256": "33e82a6abdb952058b609c6e4fa5ad8c2d5bab503b5521bb8a5c3a901a5b5010", + "exercised_skill_path": "plugins/ca/skills/release/SKILL.md", + "exercised_skill_path_note": "72,531 bytes. `git archive HEAD` and the working tree hashed identically at stage time, so the exercised bytes are the COMMITTED bytes of b74ace58.", + "high_findings": 2, + "agents_own_verdict": "\"All four attack targets held.\" Both HIGHs sit at the Back-fill seam -- a region no earlier run had probed -- and both are the same shape: two individually correct paragraphs never read against each other.", + "fixes_confirmed_working": { + "space-in-path quoting (run 16 HIGH-1)": "Every helper ran with no quoting failure from a root spelled 'Plugin Root With Space'. No unquoted straggler anywhere in the file.", + "clean-tree exemption and root anchoring (runs 16/17)": "The `:/` form with two `,top` exclusions returns the same correct answer from the repo root and from a subdirectory, filters both remedy-written paths, and still reports real dirt. The bare-`.` counterfactual reproduces the documented silent pass exactly.", + "the 6c remedy no longer trips step 7 (run 17 HIGH-2)": "`releasehash.py record` genuinely writes into `.codearbiter/.markers/`, the path the exemption now names.", + "rebuild cannot move the lane's cwd (run 17 HIGH-1)": "The subshell contains the `cd`, verified inside a SINGLE bash invocation so the harness's per-call cwd reset could not fake the result.", + "manifest-ahead STOP (run 14)": "Fired correctly: LAST_TAG v2.8.13, manifest 2.11.0, newest changelog section [2.11.0]. Local and remote tag sets identical, so the STOP is genuine rather than an unfetched-tags artifact." + } + }, + "campaign": { + "high_findings_by_run": [ + 4, + 2, + 0, + 2, + 1, + 1, + 1, + 0, + 2, + 2, + 1, + 1, + 0 + ], + "runs": [ + { + "run": 1, + "highs": 4, + "note": "Found the back-fill overwrite path -- a data-loss defect -- plus three others. Detail preserved in `findings` below." + }, + { + "run": 2, + "highs": 2, + "note": "Wiring seams: peel-tag absent from the shipped CLI (so the mandated `git tag -a` made a HEALTHY tag classify as abort_mismatch), and backfill-detect omitting latest-eligible. Shipped in 73bd884. Its summary named the pattern the whole campaign kept hitting: 'The mechanisms are individually well tested; what is unspecified is how a caller wires them together.'" + }, + { + "run": 3, + "highs": 0, + "note": "Recorded at the time as convergence. It was not -- see `stopping_rule`." + }, + { + "run": 4, + "highs": 2, + "note": "git tag's default cleanup silently deleting every Markdown heading from the tag message (issue #569, ALREADY FILED and shipped unfixed; it has permanently corrupted this repo's published v2.8.13), and a stale manifest read returning a terminal abort_mismatch on a healthy release. The second was introduced by this campaign's own fix for run 3's findings." + }, + { + "run": 5, + "highs": 1, + "note": "The `` sentinel substituted into `git log ..HEAD`, exit 128 -- on the first release of every back-filled project. Run 4 had found this and rated it MEDIUM; filing rather than fixing it was an error, and run 5's HIGH rating was correct." + }, + { + "run": 6, + "highs": 1, + "note": "A project that had shipped 1.4.2 without ever tagging derived 0.1.0 and wrote it over its own manifest, walking the version backward with all three existing guards passing." + }, + { + "run": 7, + "highs": 1, + "note": "Caused by run 6's fix: the base was stated only in the no-tag branch, so a TAGGED project with a leading manifest derived off the tag and then hard-blocked against its own manifest floor, with no remedy stated. Fixed by collapsing two floors into one $BASE_VERSION." + }, + { + "run": 8, + "highs": 0, + "note": "The recorded proof. Same region as run 7's fix; no HIGH." + }, + { + "run": 9, + "highs": 2, + "note": "The pre-tag runner shipped one commit earlier asserted a PRISTINE tree, which blocks every release -- Phase 1 bumps the manifest before that step runs, and must. Also ran commands in the inherited cwd, so a declared check could pass having inspected a different repository." + }, + { + "run": 10, + "highs": 2, + "note": "Step 7 was conditional, so a literal traversal tagged an uncommitted tree; and the mutation assertion compared porcelain LINES, so a command mutating an already-modified $CHANGELOG or $MANIFEST exited 0 undetected." + }, + { + "run": 11, + "highs": 1, + "note": "The exit-6 remedy written one commit earlier could not terminate (the mutation recurs on every invocation), destroyed the composed section, and was inexecutable on a first release." + }, + { + "run": 12, + "highs": 1, + "note": "The skill CLAIMED classify catches a partial manifest bump. It does not: classify_publish_state short-circuits on `if not tag_exists` before comparing versions, so the catch fires only on the resume path. No all-paths guard existed; check-manifests is it." + }, + { + "run": 13, + "highs": 0, + "note": "THE RECORDED PROOF. Exercised every fix from runs 9-12 together and found no HIGH." + } + ], + "what_the_campaign_demonstrated": "Every HIGH after run 1 was in a SEAM -- how a caller wires the mechanisms together -- not in a mechanism. The mechanical suites were green throughout: 272 unit tests, 69 consumer-portability tests, mutation-verified governance rules. None of them could see a prose instruction that names a command nobody can run, sources an argument from the wrong read, or hands an operator a sentinel where a revision is required. Three of the HIGHs were introduced by this campaign's own remediation of an earlier HIGH, which is the strongest available argument for keeping the exercise in the loop rather than trusting a green suite.", + "self_inflicted_pattern": "Every HIGH from run 9 through run 12 was introduced by the campaign's own preceding fix, or asserted a property the code did not have. Run 6's fix caused run 7's HIGH; the run-9 runner caused run 10's; the run-10 exit-6 remedy caused run 11's; the run-7 coupling paragraph made the claim run 12 disproved. That is the strongest evidence in this record for keeping a real agent in the loop -- and equally the reason the loop needs a human decision about when to stop, since a green mechanical suite never once saw any of them." + }, + "post_remediation_skill_sha256": { + "plugins/ca/skills/release/SKILL.md": "bb4c79287a4e60db530ea64e1a6829ae714d8e47dac579af2c1bbd3bfacba309", + "core/surface/skills/release/SKILL.md": "b9cbf3a0e7bb01abcb28aa77c6a18ba55fc21e56a662f2d41c804f3cfdf26945", + "plugins/ca-pi/routines/release/SKILL.md": "9c1b916988bcaac72f5e3be1fc7ed1d9bd14d25d8407aa63a1f17220a74e93b4", + "plugins/ca-codex/routines/release/SKILL.md": "5b02ee87807188a6045c929d118d92e3b8c675e32b82463b1f0bf666526635ae", + "note": "Computed after this remediation's edits, `tools/build-surface.py`, and `tools/sync-core.py`. `build-surface --check` and `sync-core --check` both report OK against these files as of this record. NOT a proof hash -- see `proof_current`." + }, + "findings": [ + { + "id": "HIGH-1", + "title": "Back-fill's own trigger could not distinguish a genuinely absent declared file from an existing-but-blockless one, so back-fill could silently overwrite an operator's existing file", + "status": "remediated", + "mechanism_fix": { + "file": "core/pysrc/_releaselib.py", + "summary": "Added FileExistsNoBlockError, a SIBLING of AbsentBlockError (not a subclass) under ReleaseTargetsError. load_targets() now raises it when open() succeeds but parse_release_targets() finds no block, so an `except AbsentBlockError:` clause -- the Back-fill lane's trigger -- never catches the existing-file case. parse_release_targets() itself is unchanged: it has no way to know whether a file existed, so a blockless STRING still raises AbsentBlockError exactly as before.", + "cli_fix": "core/pysrc/_releaselib.py main(): added _targets_error_exit_code(exc) -- exit 3 for genuinely-absent (AbsentBlockError), exit 4 for every other declared-file error (FileExistsNoBlockError, malformed, empty, ...). Exit 2 is reserved for bad invocation / unrecognised target and is never reused for either declared-file state, so a caller can discriminate by exit code alone, without parsing English. Applied to both `tag-prefix` and the new `list-targets` subcommand." + }, + "prose_fix": { + "file": "core/surface/skills/release/SKILL.md", + "summary": "\"Targets\" and \"Back-fill\" sections now name FileExistsNoBlockError explicitly, state the CLI exit-code discrimination, and reiterate that the Back-fill lane triggers ONLY on AbsentBlockError. Back-fill step 3 additionally adds a belt-and-braces prose rule on top of the mechanism fix: immediately before minting the marker or writing anything, re-confirm no file exists yet at the target path; if one does (a race, or the lane reached by any route other than the documented AbsentBlockError trigger), STOP without writing and surface it. This closes the task's second, independently-stated requirement ('have it refuse to write over any existing file regardless') -- the mechanism fix makes the TRIGGER correct, this makes the WRITE itself refuse to clobber regardless of how the trigger was reached." + }, + "tests_added": [ + ".github/scripts/test_release_lib.py: FileExistsNoBlockTest (5 tests)", + ".github/scripts/test_release_lib.py: CoreCLITest.test_tag_prefix_absent_declared_file_exits_3_not_a_traceback (renamed/updated from _exits_2_)", + ".github/scripts/test_release_lib.py: CoreCLITest.test_tag_prefix_existing_no_block_file_exits_4_distinct_from_absent", + ".github/scripts/test_release_lib.py: CoreCLITest.test_list_targets_* (shares the exit-code discriminator)", + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES['HIGH-1: back-fill distinguishes exists-vs-absent'], _GOVERNANCE_RULES['HIGH-1: back-fill re-checks existence at write time, belt-and-braces']" + ], + "mutation_evidence": "Mechanism (3 real code mutants, run/reverted): (1) load_targets reverted to bare `return parse_release_targets(text)` -> FileExistsNoBlockTest failed 2/5 for the right reason (AbsentBlockError propagated instead of FileExistsNoBlockError; the never-caught-by-except-AbsentBlockError test also failed). (2) _targets_error_exit_code reverted to `return 2` unconditionally -> 3 CoreCLITest tests failed (3 != 2, 3 != 2, 4 != 2). (3) FileExistsNoBlockError's base class changed to AbsentBlockError (subclass instead of sibling) -> both the sibling-not-subclass test AND the never-caught-by-except test failed, proving the sibling relationship is load-bearing. Prose (2 real text mutants, run/reverted against the live SKILL.md, GovernanceSurvivalTest re-run each time): deleting 'mechanically distinct' -> failed naming exactly 'HIGH-1: back-fill distinguishes exists-vs-absent'; deleting 'STOP without writing' (the belt-and-braces sentence) -> failed naming exactly 'HIGH-1: back-fill re-checks existence at write time, belt-and-braces'. All five reverted; full suite (256/256) and build-surface --check re-confirmed green afterward. Token uniqueness was checked (grep-count == 1 per rule's tokens against the real file) before trusting the mutant, after an earlier draft of this file's tokens ('never runs against a file that already exists', 'in any state') were found to occur twice in the real text and would have survived deletion of either single occurrence -- replaced with tokens unique to one sentence." + }, + { + "id": "HIGH-2", + "title": "Pre-flight's mandatory CONTEXT.md read STOPs a fresh consumer whose only .codearbiter/ file is the one Back-fill itself just wrote, dead-ending the lane Back-fill exists to offer", + "status": "remediated", + "investigation": "Read core/pysrc/_activationlib.py and every arbiter_active() call site (pre-write.py, pre-edit.py, pre-bash.py, pre-read.py, post-write-edit.py, git-enforce.py, prune-transcript.py). Confirmed: CONTEXT.md's `arbiter: enabled` frontmatter gates HOOK ENFORCEMENT only, not skill invocability. A skill's SKILL.md prose loads and is followed by the agent regardless of CONTEXT.md's presence. Pre-flight's CONTEXT.md STOP is therefore a self-imposed prose rule, not an activation hard-block -- so making it conditional (rather than 'the consumer literally cannot reach this skill') is the correct fix.", + "prose_fix": { + "file": "core/surface/skills/release/SKILL.md", + "summary": "The Pre-flight CONTEXT.md bullet now reads 'when it exists'. Its absence, when the consumer reached this skill only via the Back-fill lane, is no longer a STOP: the default branch is resolved directly via `git symbolic-ref refs/remotes/origin/HEAD` (or asked of the user), narrowly for the one fact this skill uses CONTEXT.md for. A project with no .codearbiter/ state at all and no interest in the release-only footprint is still routed to context-creation, unchanged." + }, + "tests_added": [ + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES['HIGH-2: CONTEXT.md is conditional on the Back-fill path']" + ], + "mutation_evidence": "Real mutant run against the live SKILL.md: deleted 'not itself a STOP' from the Pre-flight CONTEXT.md bullet, re-ran GovernanceSurvivalTest -> failed naming exactly 'HIGH-2: CONTEXT.md is conditional on the Back-fill path' and no other rule. Restored; full suite re-confirmed green." + }, + { + "id": "HIGH-3", + "title": "Back-fill's own write to release-targets.md dirties the tree inside the payload window it just declared, and the prose never said what to do about it", + "status": "remediated", + "prose_fix": { + "file": "core/surface/skills/release/SKILL.md", + "summary": "Back-fill step 3 now: (a) mints the release-targets-authoring marker before writing (release-targets.md is a marker-gated protected-state file per the sibling protected-state-machinery sprint, even though enrolment -- T-33 -- is still PENDING on this branch; the prose is now forward-consistent with that landing rather than needing a second edit then), (b) removes the marker after writing, (c) explicitly instructs committing the new file through commit-gate as `chore: declare release targets` BEFORE re-entering Pre-flight (which STOPs on a dirty tree), and (d) states this is expected and inert: a `chore` commit contributes no bump and no changelog section, so the extra commit changes neither the derived version nor what ships." + }, + "tests_added": [ + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES[\"HIGH-3: back-fill's own write is committed before Pre-flight\"]" + ], + "mutation_evidence": "Real mutant run against the live SKILL.md: deleted 'This write itself dirties the tree', re-ran GovernanceSurvivalTest -> failed naming exactly \"HIGH-3: back-fill's own write is committed before Pre-flight\" and no other rule. Restored; full suite re-confirmed green." + }, + { + "id": "HIGH-4", + "title": "resume_publish points at Phase 3 (publish) with no restated authorization requirement, immediately after Phase 2 composes a local, unauthorized tag", + "status": "remediated", + "assessment": "Considered widening classify_publish_state (core/pysrc/_releaselib.py) to distinguish a pushed tag from a local one. Rejected: doing so needs either a `git ls-remote` round-trip threaded into a currently-pure classifier (breaking its 'no I/O' contract, shared with every other mechanism function in this module) or a 7th CLI positional argument threaded through the CLI, the CI shim, test_release_workflow.py's action-step invocations, and test_release_trace.py -- real, non-trivial blast radius for a case the prose restatement already closes without it: the Phase-3 gate is unconditional on HOW resume_publish was reached.", + "prose_fix": { + "file": "core/surface/skills/release/SKILL.md", + "summary": "Phase 2 step 1's resume_publish branch now explicitly restates that Phase 3 publication still requires explicit user authorization exactly as a fresh publish does, and that a locally-composed tag from a prior Phase 2 invocation is not itself that authorization. The rejected classifier-widening option and why is stated inline." + }, + "tests_added": [ + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES['HIGH-4: resume_publish still requires authorization']" + ], + "mutation_evidence": "Real mutant run against the live SKILL.md: deleted the entire authorization-restatement clause from Phase 2 step 1, re-ran GovernanceSurvivalTest -> failed exactly one subtest (payload='core/surface (source)') naming exactly 'HIGH-4: resume_publish still requires authorization'. Restored; full suite (256/256) re-confirmed green with build-surface --check and sync-core --check both OK afterward." + }, + { + "id": "MEDIUM-changelog-1", + "title": "perf is harvested for CHANGELOG: footers but the BLOCK sentence named only feat/fix, so a footerless perf silently bumped and vanished from the changelog", + "status": "remediated", + "prose_fix": "Phase 1 step 3 (new, moved ahead of the manifest bump) states the BLOCK covers the full harvested set: feat/fix/perf/refactor.", + "tests_added": [ + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES['MEDIUM: footer BLOCK covers the full harvested set']" + ], + "mutation_evidence": "Real mutant run against the live SKILL.md: deleted 'MUST carry a `CHANGELOG:` footer', re-ran GovernanceSurvivalTest -> failed naming exactly 'MEDIUM: footer BLOCK covers the full harvested set'. Restored; full suite re-confirmed green.", + "operational_impact_on_this_repo": "Checked the symmetric direction of the docs/chore history check above: `git log v2.8.13..HEAD --pretty='%h %s' -- plugins/ca/` (v2.8.13 is `ca`'s real last tag; no v2.9.x/2.10.x/2.11.0 tag exists yet even though CHANGELOG.md and plugin.json already carry composed-but-unpublished sections up to 2.11.0) contains FIVE commits this widened BLOCK now covers and none of the old prose did: `b98c321`/`4e2dae9`/`45f5046`/`5429862` (refactor, the #321 _hooklib partition slices) and `03995ab` (perf, the statusline ledger write). None of the five carries a `CHANGELOG:` footer in its commit body (verified: `git log -1 --pretty=%B | grep -c '^CHANGELOG:'` returns 0 for all five), even though their content is ALREADY manually present in CHANGELOG.md under already-composed `### Changed` sections for 2.10.7/2.10.8. This means the NEXT real `/release ca` invocation in this repository will BLOCK at the new Phase 1 step 3 on these five commits, requiring operator reconciliation before it can proceed -- this is a genuine, immediate consequence of the fix this finding mandated, not a hypothetical. It is disclosed here rather than silently discovered at tag time; resolving it (retro-fitting footers is not practical on already-merged commits, so the operator's actual path is likely a documented one-time override or backfilling the missing footers via amended trailers on a rebase before the next release) is a maintainer decision this remediation does not make unilaterally." + }, + { + "id": "MEDIUM-changelog-2", + "title": "refactor bumps patch but was neither harvested nor grouped, so a refactor-only window bumped, composed an empty changelog section, and tripped no BLOCK", + "status": "remediated", + "decision": "refactor is now harvested into the changelog under a 'Changed' group, matching this repository's OWN existing changelog convention (verified: CHANGELOG.md already uses '### Changed' for refactor-shaped entries, e.g. the #321 _hooklib partition slices) rather than inventing a new convention. refactor is now subject to the same missing-footer BLOCK as feat/fix/perf.", + "prose_fix": "Phase 1 step 5 groups Added(feat)/Fixed(fix)/Performance(perf)/Changed(refactor)." + }, + { + "id": "MEDIUM-changelog-3", + "title": "A CHANGELOG: footer on a non-bumping commit (docs/chore/test/ci) was silently discarded, against the same paragraph's own 'never silently drop a user-visible change' rule", + "status": "remediated", + "investigation": "Ran `git log --grep='CHANGELOG:' --pretty='%s'` filtered to docs/chore/test/ci-typed subjects against this repository's own history before choosing a resolution: found 8 real, pre-existing examples (docs(site), chore(ca), ci:, chore: relicense, etc.) -- a recurring, evidently intentional pattern in THIS repo's own commit history, not a mistake. A BLOCK on this pattern would therefore block this repository's own next several releases on its own historical norm. Chose harvest-not-drop over BLOCK for this reason.", + "decision": "A CHANGELOG: footer on ANY test/docs/chore/ci commit is now harvested into the same section's Changed group when a release is otherwise happening (i.e. some other commit in the window bumps). This does NOT by itself force a release: if the whole window is non-bumping, the existing STOP (nothing to release) still applies even when a non-bumping commit in it carries a footer -- widening what a bare footer alone can trigger was treated as a separate, out-of-scope design question and stated as such in the prose rather than silently decided.", + "prose_fix": "Phase 1 step 5's harvesting rule, second sentence.", + "tests_added": [ + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES['MEDIUM: a footer on a non-bumping commit is harvested, not dropped']" + ], + "mutation_evidence": "Real mutant run against the live SKILL.md: deleted 'Also harvest a `CHANGELOG:` footer from any', re-ran GovernanceSurvivalTest -> failed naming exactly 'MEDIUM: a footer on a non-bumping commit is harvested, not dropped'. Restored; full suite re-confirmed green." + }, + { + "id": "MEDIUM-changelog-4", + "title": "Step 3 (old numbering) bumped the manifest before step 4's footer-completeness BLOCK fired, so a BLOCKed release could leave a bumped manifest behind with nothing shipped", + "status": "remediated", + "prose_fix": "Reordered Phase 1: the footer-completeness check is now step 3, BEFORE the manifest bump (now step 4) and the changelog composition (now step 5). A BLOCK at step 3 now happens before any file is touched." + }, + { + "id": "MEDIUM-list-targets", + "title": "The single-target rule requires knowing a target's name, but tag-prefix takes the name as INPUT and there was no subcommand to enumerate declared targets, forcing a hand-parse of the grammar the skill says to resolve only through the tested helper", + "status": "remediated", + "mechanism_fix": { + "file": "core/pysrc/_releaselib.py", + "summary": "Added the `list-targets` subcommand: prints every declared target's name, one per line, in declaration order. Shares default_targets_path()/--targets-file resolution and the same exit-3/exit-4 discrimination as tag-prefix." + }, + "prose_fix": { + "file": "core/surface/skills/release/SKILL.md", + "summary": "The 'one command, any number of targets' paragraph now points to `list-targets` for enumeration." + }, + "tests_added": [ + ".github/scripts/test_release_lib.py: CoreCLITest.test_list_targets_prints_every_declared_name", + ".github/scripts/test_release_lib.py: CoreCLITest.test_list_targets_absent_file_exits_3", + ".github/scripts/test_release_lib.py: CoreCLITest.test_list_targets_bad_invocation_exits_2", + ".github/scripts/test_release_lib.py: _GOVERNANCE_RULES['MEDIUM: list-targets exists']" + ], + "mutation_evidence": "Mechanism: `list-targets`'s row-printing loop replaced with a bare `return 0`; test_list_targets_prints_every_declared_name failed for the right reason (empty output where ['one', 'two'] was expected). Prose: deleted 'sanctioned enumeration' from the live SKILL.md, re-ran GovernanceSurvivalTest -> failed naming exactly 'MEDIUM: list-targets exists' (the token 'list-targets' alone is NOT used as the sole anchor, since it occurs twice in the file -- verified by grep count before choosing the token pair). Both reverted; full suite re-confirmed green." + } + ], + "mutation_testing_note": "Every mechanism-level assertion (HIGH-1's exception hierarchy and CLI exit codes, MEDIUM's list-targets) was verified by an actual revert-run-restore cycle: PYTHONDONTWRITEBYTECODE=1, python -B, targeted -k selection, confirmed red for the stated reason, reverted, confirmed green. Every prose-only fix (HIGH-1's belt-and-braces rule, HIGH-2, HIGH-3, HIGH-4, and all three of the MEDIUM changelog-harvest _GOVERNANCE_RULES entries) was ALSO verified by a real, live mutant against core/surface/skills/release/SKILL.md itself: the load-bearing phrase was deleted, GovernanceSurvivalTest was re-run, the failure was confirmed to name exactly that one rule and no other, and the file was restored (git diff / build-surface --check confirmed byte-identical to the pre-mutant state afterward). Token uniqueness (grep-count == 1 across the real file for each rule's anchor phrase) was checked before trusting any of these mutants -- an earlier draft used tokens that occurred in two separate sentences and would have survived deleting either one alone; those were replaced with phrases unique to the single sentence each rule protects. This closes the gap a purely synthetic-fixture proof (GovernanceRuleCheckerTest.test_deleting_one_rules_tokens_is_detected_and_only_that_one, which proves the CHECKER discriminates in the abstract, not that a given rule's tokens are unique in the REAL file) would have left open. What remains genuinely irreducible, and is NOT closed by any test in this campaign, is whether an agent reading the restored prose actually behaves as it prescribes -- that is the AC-6.6 'agent judgment' layer, and re-running the real agent-judgment exercise against the post-remediation skill is what actually re-validates it.", + "verification": { + "as_of": "run 18 / commit b74ace58 (2026-08-01)", + "note": "Counts and hash are from the same commit. The two-sentence safety fix described in post_exercise_delta landed after these counts were taken and changed no test.", + "suites_green": [ + ".github/scripts/test_release_lib.py (346 tests)", + ".github/scripts/test_consumer_smoke.py (69 tests, 1 skipped)", + ".github/scripts/test_release_trace.py (28 tests)", + ".github/scripts/test_release_workflow.py (68 tests)", + ".github/scripts/test_ci_impact.py (54 tests)", + ".github/scripts/test_check_skill_proof_fresh.py (23 tests)" + ], + "build_surface_check": "OK (claude, codex, pi in sync)", + "ci": "green on b74ace58; all 51 PR checks pass, Pages deploy correctly skipping on a PR" + }, + "not_remediated_here": { + "run_18_HIGH_1": "Back-fill's own first release cannot clear Phase 1 step 3. The adoption floor is derived from .codearbiter/CONTEXT.md, which Pre-flight explicitly says a back-filled consumer does not have. An empty floor means the whole history is footer-checked -- the exact unbounded NEEDS-TRIAGE block A-5.5 exists to prevent, on the only path that lane serves. Reproduced in a scratch repo: 4 commits, 4 NEEDS-TRIAGE lines. This is a design gap, not a sentence: the floor needs a source that does not presuppose onboarding.", + "run_18_mediums_and_lows": "Four MEDIUMs and three LOWs from run 18.", + "earlier_runs": "Run 16's and run 17's non-HIGH findings.", + "tracking": "Filed as GitHub issues; the numbers are recorded in PR #576's description." + }, + "wiring": { + "status": "WIRED as of this record.", + "row": "`pre-tag: python3 .github/scripts/check_skill_proof_fresh.py` on the [ca] row of .codearbiter/release-targets.md", + "decision": "DECISION-0034 (pre-tag commands are check-only and non-mutating). This one reads two files and prints; it never writes.", + "consequence": "Every future edit to plugins/ca/skills/release/SKILL.md now fails `ca`'s pre-tag gate until the exercise is re-run and this artifact refreshed. Intended. The cost is one blind run per skill revision; the purpose is that a prose change to the release lane cannot ship on the strength of a green mechanical suite alone -- which all eight runs demonstrated it should not." + }, + "post_exercise_delta": { + "why_this_key_exists": "The stopping rule agreed before run 16 said the final exercise files everything and changes nothing, so its hash would be the proof by construction. Run 18 returned a finding that could not be filed and shipped: a destructive instruction. The rule's own escape clause applies -- record against the exercised hash and state the delta here, rather than spawning run 19 and restarting the loop.", + "commit": "the commit immediately following b74ace58 on sprint/portable-release-and-protected-state", + "changed_after_the_exercise": [ + "Phase 1 step 3's amend/rebase parenthetical. It read 'both are fine at this point, since nothing in this window has been tagged or pushed'. Measured on this repository: 38 of the 57 footer-less commits in the window are already ancestors of origin/main. Following the sentence literally means an interactive rebase over published history and a force-push, which the same file forbids. It now requires a `git merge-base --is-ancestor` check first, and routes an already-published commit to classification-from-message -- which is what the manifest-ahead STOP 30 lines earlier already says about the identical commits.", + "Step 7's cross-reference said 'the same single exclusion'; the command it points at has carried two since the .markers/ exemption landed. Wording only." + ], + "what_run_19_must_do": "Re-exercise against the post-fix hash and set proof_current back to true. Until then this gate correctly blocks ca's pre-tag." + } +} diff --git a/.codearbiter/security-controls.md b/.codearbiter/security-controls.md index 86495b37..10736675 100644 --- a/.codearbiter/security-controls.md +++ b/.codearbiter/security-controls.md @@ -1,57 +1,57 @@ -# Security controls — codeArbiter - -This document is the single source of truth for the project's security posture. -The `auth-crypto-reviewer`, `security-reviewer`, and `dependency-reviewer` agents -read this file before every review. The crypto-compliance and secret-handling -skills gate on this file being present. - ---- - -## Cryptographic primitives - -**Approved:** SHA-256 and the broader SHA-2 family (SHA-384, SHA-512). - -**Forbidden:** MD5, SHA-1, DES, 3DES, RC4, RC2, Blowfish. These are never -acceptable regardless of context; the `CRYPTO_RE` commit gate (H-09b) flags any -added line that uses one, with no new-code-versus-old distinction. - -All production crypto in this repo uses `hashlib.sha256` (Python) or -`createHash("sha256")` (Node.js). The two occurrences of `createHash("md5")` -in `.github/scripts/` are intentional adversarial test payloads injected to -verify that the H-09 gate fires on banned algorithms — they are not operational -uses and must never be treated as approved exceptions. - ---- - -## Secret store and access method - +# Security controls — codeArbiter + +This document is the single source of truth for the project's security posture. +The `auth-crypto-reviewer`, `security-reviewer`, and `dependency-reviewer` agents +read this file before every review. The crypto-compliance and secret-handling +skills gate on this file being present. + +--- + +## Cryptographic primitives + +**Approved:** SHA-256 and the broader SHA-2 family (SHA-384, SHA-512). + +**Forbidden:** MD5, SHA-1, DES, 3DES, RC4, RC2, Blowfish. These are never +acceptable regardless of context; the `CRYPTO_RE` commit gate (H-09b) flags any +added line that uses one, with no new-code-versus-old distinction. + +All production crypto in this repo uses `hashlib.sha256` (Python) or +`createHash("sha256")` (Node.js). The two occurrences of `createHash("md5")` +in `.github/scripts/` are intentional adversarial test payloads injected to +verify that the H-09 gate fires on banned algorithms — they are not operational +uses and must never be treated as approved exceptions. + +--- + +## Secret store and access method + This project has no secrets vault. The only secret codeArbiter manages for its main governance/farm runtime is `FARM_API_KEY`, the API key for the cost-arbitrage farm dispatcher. - -**Approved access method:** `process.env.FARM_API_KEY` in Node.js. This key is -injected by the CI environment (GitHub Actions secret) or by the developer's -shell environment for local runs. It is never stored in a config file, never -committed to the repository, and never written to a log. - -`process.env` is the sanctioned access method for `FARM_API_KEY` in this -project. This is an explicit exception to a general "no process.env for secrets" -rule: the project has no vault, the key is short-lived per-session, and the -deployment model is a single-developer CLI tool. - -A second secret exists in the `ca-sandbox` plugin (ADR-0007): the -`CLAUDE_CODE_OAUTH_TOKEN` used by `--with-claude` to authenticate Claude Code -*inside* a sandbox box. - -**Approved access method:** env-injection only. The token is passed to the -container as `-e CLAUDE_CODE_OAUTH_TOKEN=...` (auth-precedence #5; resolved via -ADR-0007, Spike B). It is never baked into an image layer, never written to a committed -file, never logged (the failure path emits docker's own stderr/stdout, never the -argv), and tests use a clearly-labelled DUMMY value only. Because a token in a box -running untrusted code is stealable, `--with-claude` is hard-defaulted to -offline/Anthropic-only egress and its credential volume is never co-mounted with -an untrusted source volume (`TokenCoMountRejectedError`). - + +**Approved access method:** `process.env.FARM_API_KEY` in Node.js. This key is +injected by the CI environment (GitHub Actions secret) or by the developer's +shell environment for local runs. It is never stored in a config file, never +committed to the repository, and never written to a log. + +`process.env` is the sanctioned access method for `FARM_API_KEY` in this +project. This is an explicit exception to a general "no process.env for secrets" +rule: the project has no vault, the key is short-lived per-session, and the +deployment model is a single-developer CLI tool. + +A second secret exists in the `ca-sandbox` plugin (ADR-0007): the +`CLAUDE_CODE_OAUTH_TOKEN` used by `--with-claude` to authenticate Claude Code +*inside* a sandbox box. + +**Approved access method:** env-injection only. The token is passed to the +container as `-e CLAUDE_CODE_OAUTH_TOKEN=...` (auth-precedence #5; resolved via +ADR-0007, Spike B). It is never baked into an image layer, never written to a committed +file, never logged (the failure path emits docker's own stderr/stdout, never the +argv), and tests use a clearly-labelled DUMMY value only. Because a token in a box +running untrusted code is stealable, `--with-claude` is hard-defaulted to +offline/Anthropic-only egress and its credential volume is never co-mounted with +an untrusted source volume (`TokenCoMountRejectedError`). + Pi host authentication is an external trusted-runtime boundary. **No provider credential enters an isolated child in any form** (ADR-0019, superseding the credential-projection clause of ADR-0016). ADR-0016 permitted projecting the @@ -167,46 +167,46 @@ All other codeArbiter-defined env vars (`FARM_MODEL`, `FARM_BASE_BRANCH`, etc.) are non-sensitive configuration and may freely use `process.env`. Provider environment variables remain secret-bearing external Pi inputs and are governed by ADR-0016's child allowlist and redaction contract, not by that general rule. - ---- - -## Container isolation (ca-sandbox) - -`ca-sandbox` (ADR-0007) runs **untrusted** repositories. Its entire value is -isolation, so the following structural controls are load-bearing and enforced by -construction in `plugins/ca-sandbox/tools/`. A regression in any of them is a -security defect, not a style nit. - -- **No host filesystem access.** Every mount is built through the single - chokepoint `buildMountArgs` (`mounts.ts`), which rejects all bind specs (string - `-v` shorthand, object form, explicit `type=bind`, unknown types) — only - `type=volume` and `type=tmpfs` are emitted. There is no other path to a `docker` - mount argv. -- **Reduced privilege.** Sandbox runs (`run.ts`) and the `--with-claude` box - (`claude-inside.ts`) both emit `--user 1000:1000`, `--cap-drop ALL`, - `--read-only`, `--security-opt no-new-privileges`, and resource caps. Never - `--privileged`; the docker socket is never mounted. -- **Egress default-deny.** The default network policy is `offline` - (`--network none`). The `clone-then-cut` and experimental allowlist policies are - opt-in; an unknown policy is a hard error, never a silent pass-through. -- **Clone-input trust model.** The repo url is untrusted and validated by - `validateRepoUrl` (`create.ts`) before it reaches git: only `https://`, `ssh://`, - and `user@host:path` remotes are allowed; leading-`-` values (git argument - injection) and transport-helper syntax (`ext::`, `fd::`, `file://`) are rejected, - and the clone argv emits an end-of-options `--` before the url. -- **No shell interpolation of untrusted input.** Every docker invocation uses an - argv array (`spawn`/`spawnSync`, no `shell: true`); untrusted urls, ids, and - paths reach docker as discrete argv elements, never a parsed command line. - ---- - -## TLS - -Default Node.js TLS is required on all outbound HTTPS calls. -`rejectUnauthorized: false` is never permitted. No HTTP (non-TLS) endpoint may -be used for API calls, except loopback (`127.0.0.1`/`localhost`) for test mocks -— see the boundary-crossings table. - + +--- + +## Container isolation (ca-sandbox) + +`ca-sandbox` (ADR-0007) runs **untrusted** repositories. Its entire value is +isolation, so the following structural controls are load-bearing and enforced by +construction in `plugins/ca-sandbox/tools/`. A regression in any of them is a +security defect, not a style nit. + +- **No host filesystem access.** Every mount is built through the single + chokepoint `buildMountArgs` (`mounts.ts`), which rejects all bind specs (string + `-v` shorthand, object form, explicit `type=bind`, unknown types) — only + `type=volume` and `type=tmpfs` are emitted. There is no other path to a `docker` + mount argv. +- **Reduced privilege.** Sandbox runs (`run.ts`) and the `--with-claude` box + (`claude-inside.ts`) both emit `--user 1000:1000`, `--cap-drop ALL`, + `--read-only`, `--security-opt no-new-privileges`, and resource caps. Never + `--privileged`; the docker socket is never mounted. +- **Egress default-deny.** The default network policy is `offline` + (`--network none`). The `clone-then-cut` and experimental allowlist policies are + opt-in; an unknown policy is a hard error, never a silent pass-through. +- **Clone-input trust model.** The repo url is untrusted and validated by + `validateRepoUrl` (`create.ts`) before it reaches git: only `https://`, `ssh://`, + and `user@host:path` remotes are allowed; leading-`-` values (git argument + injection) and transport-helper syntax (`ext::`, `fd::`, `file://`) are rejected, + and the clone argv emits an end-of-options `--` before the url. +- **No shell interpolation of untrusted input.** Every docker invocation uses an + argv array (`spawn`/`spawnSync`, no `shell: true`); untrusted urls, ids, and + paths reach docker as discrete argv elements, never a parsed command line. + +--- + +## TLS + +Default Node.js TLS is required on all outbound HTTPS calls. +`rejectUnauthorized: false` is never permitted. No HTTP (non-TLS) endpoint may +be used for API calls, except loopback (`127.0.0.1`/`localhost`) for test mocks +— see the boundary-crossings table. + The **resolved** `apiBaseUrl` — after the `FARM_API_BASE_URL` env override, `plan.meta.apiBaseUrl`, `FARM_DEFAULT_API_BASE_URL`, and the built-in default are applied in that precedence — is validated by `assertSecureBaseUrl` (`farm.ts`) @@ -219,45 +219,45 @@ a validated endpoint from forwarding request bodies to an unvalidated transport. Provider-controlled response bodies are consumed but never copied into stderr, retry prompts, or reports; endpoint diagnostics emit only the parsed origin. This supersedes the prior parse-time check that covered only `plan.meta.apiBaseUrl`. - -**Outbound surface — the update-available notifier.** The update-notifier -(`plugins/ca/hooks/_updatelib.py`, run detached by `update-refresh.py`) makes one -outbound call: an **unauthenticated HTTPS GET** to -`https://api.github.com/repos/arbiterForge/codeArbiter/releases/latest`, at most -once per day (cached in the user-global `~/.codearbiter/update-state.json`). It is -the plugin's only routine outbound call outside the farm dispatcher. Posture per -ADR-0003: `https://` is asserted on the initial URL *and* re-asserted on any 3xx -via a custom redirect handler that refuses an `https://`→`http://` downgrade; -stdlib `urllib` only (ADR-0004), default verifying TLS, no `rejectUnauthorized` -equivalent. It **sends no repo content, no PII, and no secret** (User-Agent + -Accept headers only) and is **fail-silent** — any network/parse/cache error -degrades to "no notice" and never raises into the SessionStart or statusline hook. -It adds **no synchronous network call** to the SessionStart hot path (those hooks -only read the cache; the fetch runs in the detached refresh child). - ---- - -## Approved npm registries - -`https://registry.npmjs.org` is the only approved registry. No alternative -registries, `git+` URLs, `file:` references, or `http:` (non-TLS) sources are -permitted in `package-lock.json` or any manifest. - ---- - -## Approved licenses (dependencies) - -This is a private package (`"private": true`). The following SPDX identifiers -are approved across all manifests (the shipped plugin payload carries no runtime -npm dependencies; the docs site under `site/` is not part of that payload): - -- MIT -- ISC -- Apache-2.0 -- BSD-2-Clause -- BSD-3-Clause -- BlueOak-1.0.0 — permissive, OSI-approved "better-MIT"; imposes no obligations -- CC0-1.0 — public-domain dedication; imposes no obligations + +**Outbound surface — the update-available notifier.** The update-notifier +(`plugins/ca/hooks/_updatelib.py`, run detached by `update-refresh.py`) makes one +outbound call: an **unauthenticated HTTPS GET** to +`https://api.github.com/repos/arbiterForge/codeArbiter/releases/latest`, at most +once per day (cached in the user-global `~/.codearbiter/update-state.json`). It is +the plugin's only routine outbound call outside the farm dispatcher. Posture per +ADR-0003: `https://` is asserted on the initial URL *and* re-asserted on any 3xx +via a custom redirect handler that refuses an `https://`→`http://` downgrade; +stdlib `urllib` only (ADR-0004), default verifying TLS, no `rejectUnauthorized` +equivalent. It **sends no repo content, no PII, and no secret** (User-Agent + +Accept headers only) and is **fail-silent** — any network/parse/cache error +degrades to "no notice" and never raises into the SessionStart or statusline hook. +It adds **no synchronous network call** to the SessionStart hot path (those hooks +only read the cache; the fetch runs in the detached refresh child). + +--- + +## Approved npm registries + +`https://registry.npmjs.org` is the only approved registry. No alternative +registries, `git+` URLs, `file:` references, or `http:` (non-TLS) sources are +permitted in `package-lock.json` or any manifest. + +--- + +## Approved licenses (dependencies) + +This is a private package (`"private": true`). The following SPDX identifiers +are approved across all manifests (the shipped plugin payload carries no runtime +npm dependencies; the docs site under `site/` is not part of that payload): + +- MIT +- ISC +- Apache-2.0 +- BSD-2-Clause +- BSD-3-Clause +- BlueOak-1.0.0 — permissive, OSI-approved "better-MIT"; imposes no obligations +- CC0-1.0 — public-domain dedication; imposes no obligations - MPL-2.0 (development/build-time only): weak, file-level copyleft. The obligation attaches only to the MPL-licensed source files themselves and edits made to them; it never reaches files that merely consume the library. Approved for @@ -266,37 +266,37 @@ npm dependencies; the docs site under `site/` is not part of that payload): approved as a runtime plugin dependency or distributed artifact. `node_modules`, native bindings, WASM, Vite, Rolldown, Lightning CSS, and their source files must not enter a shipped plugin payload; built outputs must be checked for their absence. -- LGPL-3.0-or-later (build-time, `site/` ONLY): weak, library-level copyleft - discharged by keeping the component replaceable. Approved solely for the 18 - `@img/sharp-libvips-*` prebuilt binaries pulled by `sharp` as a build-time - docs-site image optimizer under `site/`. The obligation is low-stakes for a - replaceable build tool, and its output (optimized images) carries no LGPL - obligation. NOT approved for the plugin payload (`plugins/**`) or any - distributed artifact. +- LGPL-3.0-or-later (build-time, `site/` ONLY): weak, library-level copyleft + discharged by keeping the component replaceable. Approved solely for the 18 + `@img/sharp-libvips-*` prebuilt binaries pulled by `sharp` as a build-time + docs-site image optimizer under `site/`. The obligation is low-stakes for a + replaceable build tool, and its output (optimized images) carries no LGPL + obligation. NOT approved for the plugin payload (`plugins/**`) or any + distributed artifact. - 0BSD (development/build-time only): a public-domain-equivalent BSD variant with no attribution requirement — more permissive than MIT. Approved for `tslib` pulled transitively under `site/` and development-only `plugins/*/tools` locks. It is not approved as a runtime plugin dependency or distributed artifact. - -`BlueOak-1.0.0` and `CC0-1.0` were approved 2026-06-22 (user decision via SMARTS -arbitration, checkpoint 2026-06-22) to cover transitive `site/` dependencies -(`common-ancestor-path`, `lru-cache`, `sax`; `mdn-data`). `argparse@2.0.1` -declares a `Python-2.0` SPDX field that is a packaging mislabel — upstream is -MIT — and is accepted on that basis. - -`MPL-2.0` was approved 2026-06-27 (user decision, BY SUaDtL@users.noreply.github.com), -scoped to build-time `site/` dependencies only, to cover `lightningcss@1.32.0` -introduced by the Astro 7 / Vite 8 upgrade; the scoped entry above states the -boundary. `satteri@0.9.3` (and its `@bruits/satteri-*` platform variants), Astro -7's markdown processor, omits the SPDX license field in its npm metadata; upstream -(`github.com/bruits/satteri`, published by an Astro core maintainer via OIDC) -ships an MIT license, so it is accepted as MIT on the same packaging-mislabel -basis as `argparse`, build-time `site/` only. - -`LGPL-3.0-or-later` and `0BSD` were approved 2026-07-02 (user decision, -BY SUaDtL@users.noreply.github.com, via SMARTS arbitration; resolves -`[CONFIRM-08]`), scoped to build-time `site/` dependencies only, to cover the 18 -`@img/sharp-libvips-*` binaries (`sharp` docs-site image optimizer) and `tslib`. + +`BlueOak-1.0.0` and `CC0-1.0` were approved 2026-06-22 (user decision via SMARTS +arbitration, checkpoint 2026-06-22) to cover transitive `site/` dependencies +(`common-ancestor-path`, `lru-cache`, `sax`; `mdn-data`). `argparse@2.0.1` +declares a `Python-2.0` SPDX field that is a packaging mislabel — upstream is +MIT — and is accepted on that basis. + +`MPL-2.0` was approved 2026-06-27 (user decision, BY SUaDtL@users.noreply.github.com), +scoped to build-time `site/` dependencies only, to cover `lightningcss@1.32.0` +introduced by the Astro 7 / Vite 8 upgrade; the scoped entry above states the +boundary. `satteri@0.9.3` (and its `@bruits/satteri-*` platform variants), Astro +7's markdown processor, omits the SPDX license field in its npm metadata; upstream +(`github.com/bruits/satteri`, published by an Astro core maintainer via OIDC) +ships an MIT license, so it is accepted as MIT on the same packaging-mislabel +basis as `argparse`, build-time `site/` only. + +`LGPL-3.0-or-later` and `0BSD` were approved 2026-07-02 (user decision, +BY SUaDtL@users.noreply.github.com, via SMARTS arbitration; resolves +`[CONFIRM-08]`), scoped to build-time `site/` dependencies only, to cover the 18 +`@img/sharp-libvips-*` binaries (`sharp` docs-site image optimizer) and `tslib`. Neither reaches the shipped plugin payload. `MPL-2.0` and `0BSD` were extended 2026-07-14 (user decision, @@ -306,68 +306,68 @@ Vitest 4.1.9 lock gate and the pre-existing `ca-sandbox` lock-policy mismatch. The extension does not authorize runtime dependencies or distribution of `node_modules`, native binaries, WASM, or dependency source; release checks must prove those artifacts are absent from shipped plugin payloads. - -Any new dependency with a license outside this list requires an explicit -review and an entry in `overrides.log` before merging. - ---- - -## Hook security (Python) - -All hook files under `plugins/ca/hooks/` must use the Python standard library -only — no third-party dependencies, ever. Hooks run on stock Python installs -with nothing additional installed. - -Hook input parsing fails open (not closed) on malformed stdin — see -`_hooklib.py:read_input()` for the documented rationale. - -**Repo resolution — the guards judge the repo the git op fires in (#190).** The -`.git/hooks` enforcement backstop `git-enforce.py` resolves its target via -`git rev-parse --show-toplevel` inheriting the hook's own cwd (which git sets to -the target repo's work-tree top for `pre-commit`/`pre-push`), **not** -`CLAUDE_PROJECT_DIR` — so a `git -C commit` under a Claude session is -gated against ``, not the session's repo. The PreToolUse `pre-bash.py` -`git_cwd` composes a **repeated** `-C` run the way git itself does (fold-left: -absolute replaces the accumulator, relative joins onto it, seeded with -`project_root`), closing the multi-`-C` fail-open where a crafted -`git -C /abs/main -C . commit` would otherwise be judged against the wrong repo; -a `-C` target that is not a real directory now fails **closed** (H-01 block). -`session-start.py` and `taskwrite.py` resolve via the shared -`_hooklib.project_root` (CLAUDE_PROJECT_DIR-first) rather than divergent local -copies, so audit lines / the task board / installed hooks land in the -harness-authoritative project dir. - -The hook payload's `cwd` field is a **trusted-harness input** to repo -resolution, on the same footing as the host's project-dir env var: both are -written by the host harness itself, never by the model. Its precedence is -per-host (`hostapi.Host.project_root`, ADR-0011): under **Claude Code** the -env var `CLAUDE_PROJECT_DIR` is consulted first and the payload-`cwd` leg is -inert (the harness always sets the env var); under **Codex** there is **no -env leg at all** — `CLAUDE_PROJECT_DIR` is deliberately never consulted, so a -value leaked from an adjacent Claude session cannot redirect the guards. The -Codex Host method defines payload `cwd` as its first leg ahead of -`git rev-parse --show-toplevel` and the process cwd, but the entry scripts do -not currently feed the payload into `project_root()` — in the wired path, -Codex resolution is `git rev-parse` from the session cwd, which is equivalent -because the Codex harness runs every hook in the session cwd it also stamps -into the payload. If an entry ever passes the payload, the documented -precedence above is the contract it inherits. - + +Any new dependency with a license outside this list requires an explicit +review and an entry in `overrides.log` before merging. + +--- + +## Hook security (Python) + +All hook files under `plugins/ca/hooks/` must use the Python standard library +only — no third-party dependencies, ever. Hooks run on stock Python installs +with nothing additional installed. + +Hook input parsing fails open (not closed) on malformed stdin — see +`_hooklib.py:read_input()` for the documented rationale. + +**Repo resolution — the guards judge the repo the git op fires in (#190).** The +`.git/hooks` enforcement backstop `git-enforce.py` resolves its target via +`git rev-parse --show-toplevel` inheriting the hook's own cwd (which git sets to +the target repo's work-tree top for `pre-commit`/`pre-push`), **not** +`CLAUDE_PROJECT_DIR` — so a `git -C commit` under a Claude session is +gated against ``, not the session's repo. The PreToolUse `pre-bash.py` +`git_cwd` composes a **repeated** `-C` run the way git itself does (fold-left: +absolute replaces the accumulator, relative joins onto it, seeded with +`project_root`), closing the multi-`-C` fail-open where a crafted +`git -C /abs/main -C . commit` would otherwise be judged against the wrong repo; +a `-C` target that is not a real directory now fails **closed** (H-01 block). +`session-start.py` and `taskwrite.py` resolve via the shared +`_hooklib.project_root` (CLAUDE_PROJECT_DIR-first) rather than divergent local +copies, so audit lines / the task board / installed hooks land in the +harness-authoritative project dir. + +The hook payload's `cwd` field is a **trusted-harness input** to repo +resolution, on the same footing as the host's project-dir env var: both are +written by the host harness itself, never by the model. Its precedence is +per-host (`hostapi.Host.project_root`, ADR-0011): under **Claude Code** the +env var `CLAUDE_PROJECT_DIR` is consulted first and the payload-`cwd` leg is +inert (the harness always sets the env var); under **Codex** there is **no +env leg at all** — `CLAUDE_PROJECT_DIR` is deliberately never consulted, so a +value leaked from an adjacent Claude session cannot redirect the guards. The +Codex Host method defines payload `cwd` as its first leg ahead of +`git rev-parse --show-toplevel` and the process cwd, but the entry scripts do +not currently feed the payload into `project_root()` — in the wired path, +Codex resolution is `git rev-parse` from the session cwd, which is equivalent +because the Codex harness runs every hook in the session cwd it also stamps +into the payload. If an entry ever passes the payload, the documented +precedence above is the contract it inherits. + **Hooks-install re-probe and shared-enforcer identity are fail-safe (#194, ADR-0015).** To cut SessionStart -latency, `_githooks.install()` may skip the git-spawn hooks-dir probe when a -cheap on-disk cache proves the shims are already current. The skip fires ONLY -when it can positively, spawn-free confirm no hooks redirect: the cached dir is -exactly `/.git/hooks` AND `_confirmed_no_local_hooks_path` finds no -`core.hooksPath` (a **grammar-free** case-insensitive substring scan of -`.git/config`/`.git/config.worktree` for `hookspath` — cannot under-detect any +latency, `_githooks.install()` may skip the git-spawn hooks-dir probe when a +cheap on-disk cache proves the shims are already current. The skip fires ONLY +when it can positively, spawn-free confirm no hooks redirect: the cached dir is +exactly `/.git/hooks` AND `_confirmed_no_local_hooks_path` finds no +`core.hooksPath` (a **grammar-free** case-insensitive substring scan of +`.git/config`/`.git/config.worktree` for `hookspath` — cannot under-detect any git-config spelling) AND no `[include]` directive AND the host-neutral shims still match the shared drop-in contract. Any read failure, any `hookspath` occurrence, an -`[include]`, a cached custom hooksPath, or a global-config change (a -`~/.gitconfig` + XDG-config mtime token invalidates the cache) → fall through to -the full probe. The fail direction is **install-when-unsure, never -skip-when-unsure** — the fast path can never leave the #161 git-enforce backstop -unwired. Accepted residual: a `$GIT_CONFIG_GLOBAL`/`$GIT_CONFIG_SYSTEM` -env-repointed config or `/etc/gitconfig` `core.hooksPath` set AFTER a +`[include]`, a cached custom hooksPath, or a global-config change (a +`~/.gitconfig` + XDG-config mtime token invalidates the cache) → fall through to +the full probe. The fail direction is **install-when-unsure, never +skip-when-unsure** — the fast path can never leave the #161 git-enforce backstop +unwired. Accepted residual: a `$GIT_CONFIG_GLOBAL`/`$GIT_CONFIG_SYSTEM` +env-repointed config or `/etc/gitconfig` `core.hooksPath` set AFTER a default-location install (the cold/first install always resolves those via the full probe). @@ -388,9 +388,9 @@ requires a regular bundle containing exactly three records, non-empty owner, and existing executable files. Any malformed or stale state blocks before enforcer dispatch; it never downgrades to ambient `PATH`. Uninstall removes the bundle only when the uninstalling plugin owns it. - ---- - + +--- + ## Pi adapter and child-process security `ca-pi` is an enforcement adapter inside Pi's cooperative trusted-extension @@ -434,102 +434,102 @@ ADR-0010's cooperative-agent residual-risk boundary. --- ## Audit trail - -`overrides.log`, `triage.log`, and `sprint-log.md` are append-only artifacts. -They may never be truncated, rewritten, or deleted. The `pre-bash.py` H-05 guard -and the `pre-write.py` / `pre-edit.py` H-05 guards enforce this at every -tool-call boundary. - -**Enforcement scope (accepted residual risk).** These guards are *integrity* -controls, not *completeness* controls — they protect a log once written, they do -not compel a write. The completeness half is resolved by `[CONFIRM-09]` -(2026-07-02, BY SUaDtL@users.noreply.github.com): strategy (a) — a lightweight -staleness check (UserPromptSubmit) warns when an active `/sprint` or `/dev` -flow has not appended its expected log line (`sprint-log.md` / `overrides.log`) -within a bounded window — paired with the durable gate-events sink from -`observability-001` (issue #186). It is a *warn*, not a hard gate: a missed write -is surfaced, not blocked, keeping the integrity guards the sole true STOP. -**Shipped in ca 2.8.11 (#186):** `_hooklib` `block()`/`remind()`/`warn()` -best-effort append a structured line to `.codearbiter/gate-events.log` (fail-open -— a locked/missing/unwritable log never changes a hook's exit code nor suppresses -a BLOCK; the write is wrapped so no exception escapes into any of the 16 entry -hooks), and `_hooklib.staleness_warning` surfaces stale active flows only through -`warn()` (non-blocking by construction). `gate-events.log` is append-only — -added to `AUDIT_LOG_BASENAMES`, the single source that `AUDIT_LOG_NAMES` and all -three H-05 flanks (shell pre-filter + regex, Write, Edit) derive from, so the set -cannot drift. `/override` is deliberately **not** staleness-tracked: it is a -single synchronous announce-then-log action with no in-progress marker to key -off (per CONFIRM-09's "don't invent new state" constraint). -The `pre-bash.py` shell guard is lexical and anchored on the literal log name, so -the following truncation/indirection spellings are out of scope and accepted as -residual risk (the sanctioned bypass for legitimate log management is -`/ca:override`): - -- file-descriptor redirects where no filename token is adjacent to the verb — - `exec 3>.codearbiter/overrides.log`; -- triple-chevron `>>>` (treated as append by some shells); -- process-substitution spellings; -- verb-with-variable targets where the literal name never appears beside the - verb — `f=.codearbiter/overrides.log; rm "$f"` (bash) or `$f='overrides.log'; - rm $f` (PowerShell). - -The `pre-write.py` / `pre-edit.py` guards close the Write/Edit flank (including an -empty-`old_string` Edit, which is not a verifiable append). The append-only path -set is centralized in `_hooklib` (`is_audit_log`, `AUDIT_LOG_NAMES`) so the three -guards never drift on which files are covered. - -**H-05 tail-anchor + H-20 `--no-verify` (2026-07-02, #172 / #175).** The H-05 -append check (`pre-edit.py`, via `_hooklib.is_tail_append`) now **tail-anchors** — -an audit-log Edit is admitted only as a strict append (`new` = current content + -appended tail, with `old` occurring exactly once), and a `replace_all` Edit on an -audit-log path is **rejected outright** (reliability-003/#172), closing the prior -`new.startswith(old)` hole that let a mid-file insertion or a multi-site suffix -rewrite pass as an "append". The new **H-20** guard (`pre-bash.py`) blocks a -literal `--no-verify`/`-n` on `git commit` — including bundled and attached-value -short-flag spellings (`-nm`, `-nm=x`, `-vnm=y`; the char-walk mirrors git's own -cluster parsing) — and a literal `--no-verify` on `git push` (appsec-002/#175), -because that flag skips the `.git/hooks` git-enforce backstop (voiding -H-01/H-02/H-09b/H-10b/H-14 for that operation). The residual is the same accepted -**shell-indirection** class listed above (`g=git; $g commit --no-verify` defeats -the lexical `COMMIT_RE`/`PUSH_RE` matcher itself) — out of scope per ADR-0010's -cooperative-agent trust model. - -**Automated writer of record.** One write to `overrides.log` is performed by the -framework, not a user action: on session start, if a prior session entered -`/ca:dev` and ended without `/ca:arbiter`, `session-start.py` appends a -`BY: session-cleanup | DEV: exit` close line before clearing the live dev marker -(observability-001), so the dev-mode enter/exit trail is never left half-open. It -is append-only and best-effort. This is the only writer of `overrides.log` other -than the three sanctioned mutators (`/override`, `/sprint` auto-decisions, -`/dev` entry/exit). - -**Gate-marker trust boundary (ADR-0010).** codeArbiter's gate markers (e.g. -`.codearbiter/.markers/security-gate-passed`) are *cooperative-agent -attestations*, not tamper-proof proofs. `security-pass.py` mints the -security-gate marker by re-deriving the sensitive-line digests from the current -worktree; direct invocation of the sanctioned producer is the *intended* -attestation mechanism. A Bash-capable non-cooperating agent can self-mint a pass -(as it can defeat the `--no-verify` and shell-indirection controls, appsec-002 / -#175) — this is an accepted trust boundary, out of scope for the product's -cooperative-orchestrator threat model, not a defect. The marker's value is the -friction and audit trail it adds on the cooperative path. Reopens (→ non-fabricable -reviewer-signed binding) only if the threat model expands to untrusted agents. See -ADR-0010 (resolves appsec-003 / #196). - -**MCP file-write tools out of scope (both hosts).** The write-path guards -(`pre-write.py` / `post-write-edit.py`) are wired to each host's *native* write -tools — Claude's `Write`/`Edit`/`MultiEdit`/`NotebookEdit`, and Codex's -`apply_patch` (plus its `Write`/`Edit` matcher aliases). A file write performed -through an **MCP server tool** (`mcp____`) is not covered: on Claude -such tools escape the `Write`/`Edit` matchers, and on Codex `mcp__*` normalizes to -the `OTHER` category (no `TOOL_MAP` entry) and matches neither the -`apply_patch|Write|Edit` write hooks nor the `Bash` exec hook. An agent that adds -an MCP filesystem/write server can therefore write `.codearbiter/CONTEXT.md`, a -`.markers/` token, or an audit log without a guard firing, on either host. This is -**accepted residual risk** under the same cooperative-agent trust model as the -`--no-verify`, shell-indirection, and self-minted-marker gaps above (ADR-0010) — a -cooperating orchestrator does not route protected writes through an out-of-band MCP -tool, and a non-cooperating Bash-capable agent already has stronger bypasses. + +`overrides.log`, `triage.log`, and `sprint-log.md` are append-only artifacts. +They may never be truncated, rewritten, or deleted. The `pre-bash.py` H-05 guard +and the `pre-write.py` / `pre-edit.py` H-05 guards enforce this at every +tool-call boundary. + +**Enforcement scope (accepted residual risk).** These guards are *integrity* +controls, not *completeness* controls — they protect a log once written, they do +not compel a write. The completeness half is resolved by `[CONFIRM-09]` +(2026-07-02, BY SUaDtL@users.noreply.github.com): strategy (a) — a lightweight +staleness check (UserPromptSubmit) warns when an active `/sprint` or `/dev` +flow has not appended its expected log line (`sprint-log.md` / `overrides.log`) +within a bounded window — paired with the durable gate-events sink from +`observability-001` (issue #186). It is a *warn*, not a hard gate: a missed write +is surfaced, not blocked, keeping the integrity guards the sole true STOP. +**Shipped in ca 2.8.11 (#186):** `_hooklib` `block()`/`remind()`/`warn()` +best-effort append a structured line to `.codearbiter/gate-events.log` (fail-open +— a locked/missing/unwritable log never changes a hook's exit code nor suppresses +a BLOCK; the write is wrapped so no exception escapes into any of the 16 entry +hooks), and `_hooklib.staleness_warning` surfaces stale active flows only through +`warn()` (non-blocking by construction). `gate-events.log` is append-only — +added to `AUDIT_LOG_BASENAMES`, the single source that `AUDIT_LOG_NAMES` and all +three H-05 flanks (shell pre-filter + regex, Write, Edit) derive from, so the set +cannot drift. `/override` is deliberately **not** staleness-tracked: it is a +single synchronous announce-then-log action with no in-progress marker to key +off (per CONFIRM-09's "don't invent new state" constraint). +The `pre-bash.py` shell guard is lexical and anchored on the literal log name, so +the following truncation/indirection spellings are out of scope and accepted as +residual risk (the sanctioned bypass for legitimate log management is +`/ca:override`): + +- file-descriptor redirects where no filename token is adjacent to the verb — + `exec 3>.codearbiter/overrides.log`; +- triple-chevron `>>>` (treated as append by some shells); +- process-substitution spellings; +- verb-with-variable targets where the literal name never appears beside the + verb — `f=.codearbiter/overrides.log; rm "$f"` (bash) or `$f='overrides.log'; + rm $f` (PowerShell). + +The `pre-write.py` / `pre-edit.py` guards close the Write/Edit flank (including an +empty-`old_string` Edit, which is not a verifiable append). The append-only path +set is centralized in `_hooklib` (`is_audit_log`, `AUDIT_LOG_NAMES`) so the three +guards never drift on which files are covered. + +**H-05 tail-anchor + H-20 `--no-verify` (2026-07-02, #172 / #175).** The H-05 +append check (`pre-edit.py`, via `_hooklib.is_tail_append`) now **tail-anchors** — +an audit-log Edit is admitted only as a strict append (`new` = current content + +appended tail, with `old` occurring exactly once), and a `replace_all` Edit on an +audit-log path is **rejected outright** (reliability-003/#172), closing the prior +`new.startswith(old)` hole that let a mid-file insertion or a multi-site suffix +rewrite pass as an "append". The new **H-20** guard (`pre-bash.py`) blocks a +literal `--no-verify`/`-n` on `git commit` — including bundled and attached-value +short-flag spellings (`-nm`, `-nm=x`, `-vnm=y`; the char-walk mirrors git's own +cluster parsing) — and a literal `--no-verify` on `git push` (appsec-002/#175), +because that flag skips the `.git/hooks` git-enforce backstop (voiding +H-01/H-02/H-09b/H-10b/H-14 for that operation). The residual is the same accepted +**shell-indirection** class listed above (`g=git; $g commit --no-verify` defeats +the lexical `COMMIT_RE`/`PUSH_RE` matcher itself) — out of scope per ADR-0010's +cooperative-agent trust model. + +**Automated writer of record.** One write to `overrides.log` is performed by the +framework, not a user action: on session start, if a prior session entered +`/ca:dev` and ended without `/ca:arbiter`, `session-start.py` appends a +`BY: session-cleanup | DEV: exit` close line before clearing the live dev marker +(observability-001), so the dev-mode enter/exit trail is never left half-open. It +is append-only and best-effort. This is the only writer of `overrides.log` other +than the three sanctioned mutators (`/override`, `/sprint` auto-decisions, +`/dev` entry/exit). + +**Gate-marker trust boundary (ADR-0010).** codeArbiter's gate markers (e.g. +`.codearbiter/.markers/security-gate-passed`) are *cooperative-agent +attestations*, not tamper-proof proofs. `security-pass.py` mints the +security-gate marker by re-deriving the sensitive-line digests from the current +worktree; direct invocation of the sanctioned producer is the *intended* +attestation mechanism. A Bash-capable non-cooperating agent can self-mint a pass +(as it can defeat the `--no-verify` and shell-indirection controls, appsec-002 / +#175) — this is an accepted trust boundary, out of scope for the product's +cooperative-orchestrator threat model, not a defect. The marker's value is the +friction and audit trail it adds on the cooperative path. Reopens (→ non-fabricable +reviewer-signed binding) only if the threat model expands to untrusted agents. See +ADR-0010 (resolves appsec-003 / #196). + +**MCP file-write tools out of scope (both hosts).** The write-path guards +(`pre-write.py` / `post-write-edit.py`) are wired to each host's *native* write +tools — Claude's `Write`/`Edit`/`MultiEdit`/`NotebookEdit`, and Codex's +`apply_patch` (plus its `Write`/`Edit` matcher aliases). A file write performed +through an **MCP server tool** (`mcp____`) is not covered: on Claude +such tools escape the `Write`/`Edit` matchers, and on Codex `mcp__*` normalizes to +the `OTHER` category (no `TOOL_MAP` entry) and matches neither the +`apply_patch|Write|Edit` write hooks nor the `Bash` exec hook. An agent that adds +an MCP filesystem/write server can therefore write `.codearbiter/CONTEXT.md`, a +`.markers/` token, or an audit log without a guard firing, on either host. This is +**accepted residual risk** under the same cooperative-agent trust model as the +`--no-verify`, shell-indirection, and self-minted-marker gaps above (ADR-0010) — a +cooperating orchestrator does not route protected writes through an out-of-band MCP +tool, and a non-cooperating Bash-capable agent already has stronger bypasses. **Ratified 2026-07-25 (#270 / tribunal appsec-002).** The acceptance stands: no default-deny on `mcp__*` ships, and the write matchers are NOT extended. It is conditioned instead on the gap being VISIBLE to whoever carries it. codeArbiter is @@ -557,9 +557,130 @@ vague "if the threat model expands to untrusted agents". Any ONE reopens it: 4. `doctor.check_mcp` stops reporting the gap on a supported host — the seam returns no sources, or the host's configuration location moves. The acceptance is conditioned on visibility, so losing visibility voids it. - ---- - + +--- + +## Protected-state registry (H-22) + +`_protectedstatelib.py` (B1/#564) is a generic path->policy registry — `marker- +gated`, `helper-only`, or `append-only` — enforced by a fifth `classify_protected` +class (`"state"`) across all three flanks: `pre-write.py`/`pre-edit.py` (Write/ +Edit) and `_bashguardlib.py`'s H-22 check (shell). All THREE planned consumers +are now enrolled, one per policy: + +| Path | Policy | Task | +|---|---|---| +| `.codearbiter/release-targets.md` | `marker-gated` | T-33 | +| `.codearbiter/open-tasks.md` | `helper-only` | T-66 | +| `.codearbiter/done-tasks.md` | `append-only` | T-65 | + +`release-targets.md` is marker-gated rather than helper-only because it has THREE sanctioned +authors (`context-creation`, the release skill's back-fill lane, and its +row-edit path), so a hard block would leave them no route; the marker is the +route. `git add` on it stays deliberately unblocked, or `commit-gate` could +not commit a sanctioned row edit. The residuals below were declared ahead of +enrolment and remain live. + +**Case and canonicalization are deliberately GLOBAL, not host-filesystem- +dependent.** Both flanks — `_protectedstatelib.lookup_policy`'s registry +comparison and `_bashguardlib._state_write_res`'s shell regexes — treat a +registered path case-INSENSITIVELY and tolerate a `./` prefix, a trailing +slash, a doubled slash, and a leading/trailing space. Matching the *host* +filesystem's own case-sensitivity was considered and rejected: it varies by +platform AND by volume on the same platform (Windows/NTFS and default macOS/ +APFS are case-preserving-but-insensitive; Linux ext4 and non-default macOS +volumes are case-sensitive), and `os.path.realpath` does not reliably fold +case for a path that does not yet exist on disk — exactly the case of a Write +that creates a protected-state file for the first time. A fixed, +case-insensitive rule that both flanks can apply without inspecting the +filesystem was judged safer: it only WIDENS what H-22 protects (a same- +directory file whose name differs from a registered path only by case is also +treated as protected), never narrows it. + +**The bare-basename shell anchor over-matches by design, and the known false +blocks are accepted.** H-22's shell flank matches a registered file's bare +basename with NO directory prefix requirement (unlike `CONTEXT_MD`/`GATE_MARKER`) +— this is forced by spec B1 (`taskwrite add -- "fix open-tasks.md schema"` and a +bare `tee open-tasks.md` run from inside `.codearbiter/` both need to be +distinguishable/catchable with no directory prefix in the command text). The +cost, verified and accepted (the sanctioned bypass for a false block is +`/ca:override`): + +- a longer filename that happens to END with a registered basename still + matches — `> my-open-tasks.md` blocks even though it targets a different + file. A right-edge lookahead (mirroring `DECISION_LOG_SHELL_RE`, #528) closes + the mirror-image case (`rm .codearbiter/open-tasks.md.bak` no longer + matches), but there is no equivalent left-edge anchor: the bare-basename + design has no directory context available to distinguish a genuine + no-prefix spelling from a longer name's suffix; +- a same-named file in an UNRELATED directory still matches — + `rm node_modules/somepkg/open-tasks.md`, `tee tests/fixtures/open-tasks.md` + — the direct, load-bearing consequence of the bare-basename anchor itself + (see `_bashguardlib.py`'s `_state_write_res` module comment for the full + B-07/B-08/T-08b rationale this over-match is forced by); +- a verb that only READS the protected file, then writes elsewhere, still + matches — `cp .codearbiter/open-tasks.md /tmp/backup` — the same + "ambiguity resolves CLOSED" stance the H-05 audit-log guard already applies + to the identical `cp overrides.log backup` shape. + +**H-22's write-verb list is wider than the H-05/H-11/H-18 baseline it was +copied from, and the extra verbs are declared, not merely implicit.** Past the +shared baseline (`rm|del|mv|cp|copy|dd|tee|sed|truncate|ni|New-Item| +Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content|Out-File| +Add-Content`), H-22 additionally blocks `sponge` (already in H-05's +`LOG_DESTROY_RE`), `ln`, `install`, `patch`, and `shred`. `ln`/`install` are +both real, if less common, English/shell words — `npm install` and `pip +install` are common phrases that could, in principle, sit lexically near a +protected basename in an unrelated command — accepted under the same +"ambiguity resolves CLOSED" stance applied throughout this file. `git +checkout`/`git restore` (a tracked worktree file can be rewritten through git +itself, bypassing every filesystem verb) are covered by a SEPARATE regex leg, +mirroring H-05's `LOG_GIT_RESTORE_RE` (#335) — deliberately not folded into +the general verb list, so it cannot also catch `git add` (commit-gate Phase 7 +runs `git add open-tasks.md` on every retained board flip, and must never trip +H-22). An arbitrary interpreter one-liner +(`python -c "open('open-tasks.md','w')..."`) is covered by a third leg +mirroring `GATE_MARKER_INTERP_RE` (#237) — the `helper-only` policy's whole +premise is that the sanctioned helper's own Python file I/O is the only +legitimate route, so an interpreter one-liner reusing that exact route while +naming the file lexically must be caught the same way #237 already catches it. +That leg matches on an INLINE-CODE SWITCH (`-c`, `-e`, `-r`, `deno eval`, +PowerShell's `-Command` and its abbreviations), not on the interpreter token +alone: running a script FILE and passing a protected basename as argv is the +sanctioned helper's own call shape, and matching it blocked every `/ca:task` +invocation whose description named an enrolled file. The interpreter list +covers `py` and `pwsh`/`powershell` — omitting them left the whole leg +bypassable on this repo's primary dev host +for gate markers. + +**`touch` is deliberately excluded — two positions are recorded, not one.** +security-reviewer traced every `.codearbiter`-state mtime consumer and found +none feeds an admission decision: `marker_gated_write_admitted` stats the +*marker's* mtime, never the protected file's, so even `touch -t` back-dating a +protected file admits nothing on its own. The adversarial pass counters that +for the `helper-only` policy specifically, *creation itself* is the violation +H-11's own precedent guards against — `touch`ing an absent board would create +an empty one outside the sanctioned helper, and `DECISIONS_WRITE_RE` (H-11's +own shell flank) does include `touch` for exactly that reason. The exclusion +stands: the admission-analysis argument is decisive for what this specific +guard can observe, and including `touch` would false-block a legitimate +description like `taskwrite add -- "touch up open-tasks.md wording"`. Both +positions are recorded here so a future reader sees a considered decision, not +an oversight. + +**Scanning the raw `cmd` (not the heredoc-stripped `git_view`) is a known, +consistent residual (LOW-5).** H-22's shell check, like H-05/H-11/H-18, scans +the RAW command rather than plumbing through the `git_view`/ +`heredoc_shell_fallback` machinery H-19's gate-marker check uses. A heredoc +body fed to a non-shell consumer that merely QUOTES a protected filename in +prose (e.g. a PR/issue body describing this very control) could, in principle, +false-trip H-22 the same way it could H-05/H-11/H-18 before H-19 grew that +extra plumbing for its own DOTALL-crossing concern. Left as-is for +consistency with the three guards it was modeled on; revisited only if it +proves to cost more false blocks in practice than the extra plumbing is worth. + +--- + ## Published tag immutability Four installable tag series are published from this repository: `v*` (ca), @@ -629,20 +750,21 @@ immutable Releases are available. --- -## Boundary crossings (declared exceptions) - -| Boundary | Exception | Rationale | -|----------|-----------|-----------| -| H-03 explicit staging | `farm.ts` stages `worker.filesWritten` explicitly — previously `git add -A`, corrected 2026-06-12 | Farm worktree commits are operator-initiated, reviewed in PR | -| Fail-open on hook input parse | `_hooklib.py:read_input()` | Parse failure must not brick the session | -| Unsigned dispatcher commits | `NOSIGN` constant in `farm.ts` | CI signing servers reject unattended commits; the integration PR is the signed artifact | -| Gate command shell execution | `plan.json` `gate.commands` / `test.command` and `FARM_MUTATION_CMD` run via `cmd.exe /c` / `bash -c` in `farm.ts` | Operator-authored, length-capped (≤1024), PR-reviewed; deterministic gate by design — no untrusted source. See ADR for the trust model | +## Boundary crossings (declared exceptions) + +| Boundary | Exception | Rationale | +|----------|-----------|-----------| +| H-03 explicit staging | `farm.ts` stages `worker.filesWritten` explicitly — previously `git add -A`, corrected 2026-06-12 | Farm worktree commits are operator-initiated, reviewed in PR | +| Fail-open on hook input parse | `_hooklib.py:read_input()` | Parse failure must not brick the session | +| Unsigned dispatcher commits | `NOSIGN` constant in `farm.ts` | CI signing servers reject unattended commits; the integration PR is the signed artifact | +| Gate command shell execution | `plan.json` `gate.commands` / `test.command` and `FARM_MUTATION_CMD` run via `cmd.exe /c` / `bash -c` in `farm.ts` | Operator-authored, length-capped (≤1024), PR-reviewed; deterministic gate by design — no untrusted source. See ADR for the trust model | | Loopback `http://` for API base | `assertSecureBaseUrl` in `farm.ts` allows `http://127.0.0.1`/`localhost` (no userinfo); farm POSTs use `redirect: "error"` | Test mocks bind without TLS; same WHATWG parser as `fetch` → connection target is loopback, and redirect refusal prevents a mock from forwarding the body to a remote cleartext target | -| Untrusted git clone | `ca-sandbox` clones an attacker-controlled url in a throwaway, `--rm`, networked `alpine/git` container | Input is allowlisted by `validateRepoUrl` + `--` end-of-options; blast radius is the disposable clone container only (no host bind, never co-run with the sandbox) — see ADR-0007 | +| Untrusted git clone | `ca-sandbox` clones an attacker-controlled url in a throwaway, `--rm`, networked `alpine/git` container | Input is allowlisted by `validateRepoUrl` + `--` end-of-options; blast radius is the disposable clone container only (no host bind, never co-run with the sandbox) — see ADR-0007 | | Pi selected-provider child authentication | Pi owns host authentication; `ca-pi` projects one selected stored-provider record only for an isolated child | ADR-0016 bounds the exception: private ephemeral roots, exact-provider selection, restrictive creation, retained-handle scrubbing, no observable sink, and fail-degraded cleanup | | Pi child inference brokering | `ca-pi` binds a per-child loopback broker and projects a credential-blind `models.json` whose `apiKey` is a per-child ephemeral token; no provider credential enters the child (ADR-0019) | ADR-0017 amends ADR-0016 for **configuration only**, never credentials: exact-provider record, key AND value-shape allowlist pinned to the reviewed Pi provider schema, `apiKey`/`headers` admitted only as whole-value `$NAME`/`${NAME}` references, positively-accepted endpoint-only `baseUrl` (`http(s)`, no userinfo, no query, no fragment, bounded case-insensitive route) that is also registered in the scrub set and retained behind a scrub handle, and fail-closed rejection of literal values, `!command` forms, and unreviewed keys or shapes | | Pi child process isolation | Fresh Pi processes run with discovery/session loading disabled and only explicit enforcement/skill/charter inputs | Cooperative process isolation for context and recursion control, not an OS sandbox; bounded IPC and process-tree cleanup limit accidental spill | | Trusted same-process Pi extensions | An operator-approved extension may execute arbitrary same-user code in Pi's process | Accepted ADR-0010 cooperative-agent residual; final governed-argument ordering remains a live promotion STOP under ADR-0016's carried-forward controls | +| Declared release-target commands | `.codearbiter/release-targets.md` rows carry `pre-tag`, `rebuild`, and `generate` shell commands that `/ca:release` executes before composing a tag, on a lane that later holds `contents: write` | Operator-authored executable input, on the `plan.json` `gate.commands` model above and length-capped identically (≤1024 chars, `VALUE_MAX_CHARS`, ADR-0002 precedent). Three controls bound it: commands are **check-only** and a clean-tree assertion runs after each (DECISION-0034), so a mutation blocks the release rather than reaching a tag; the runner (`_releaselib.py run-pre-tag`) enforces order, first-failure stop, and that assertion mechanically rather than by agent compliance; and the declaring file is itself protected under H-22 (ADR-0024), so planting a command requires a fresh authoring marker rather than any write. The residual is the cooperative-agent one ADR-0010 already accepts: a marker-holding session can still declare a command, and this is a governance boundary, not a sandbox | ### Closed exceptions diff --git a/.codearbiter/specs/portable-release-and-protected-state.md b/.codearbiter/specs/portable-release-and-protected-state.md new file mode 100644 index 00000000..f56fc94c --- /dev/null +++ b/.codearbiter/specs/portable-release-and-protected-state.md @@ -0,0 +1,228 @@ +# Sprint spec — portable release + protected-state machinery + +**Date:** 2026-07-31 +**Slug:** `portable-release-and-protected-state` +**Issues:** #563 (release portability, workstream A); #564 (protected-state machinery, workstream B) +**Companion spec:** `specs/release-portable-fixture.md` (rev 4, three adversarial passes, cleared) +**Decisions:** DECISION-0034; sprint-log D-1/D-2 closure 2026-07-31 + +## Goal + +Two workstreams that share one piece of machinery. `/ca:release` becomes a portable fixture whose +repo-specific facts live in project state — and the guard that makes those facts trustworthy is built +as **generic marker-gated protected-state infrastructure**, with the release file as one of three +consumers rather than its reason. + +## Standing steers (use these to break SMARTS ties) + +Stated by the user across the design session. A `tied` or `moderate` SMARTS call resolves toward +these before falling back to the §2 conflict hierarchy. + +1. **Context economy.** Minimize what is loaded into context on every turn while keeping it available + when relevant. This is why the release rows live outside `CONTEXT.md` and why JIT surfacing is + preferred over always-on inclusion. +2. **Helpers write; inference does not.** The project is moving toward project-state files mutated by + sanctioned helpers rather than by an agent composing markdown. The guard exists to make the helper + the only path. +3. **Generic over special-case.** The protected-state class is parameterized over a registry. A + one-off for a single file would have to be torn out at the first second consumer. +4. **Reliability and testability over convenience.** The observed pattern across prior decisions + (mutation-testing directive, dry-run-the-path, no-silent-reconcile) and the one applied in + DECISION-0034. +5. **No repo-local variant of a shipped skill**, and no behavior change to this repo's four-plugin + release. +6. **Never drop adversarial verification to save budget.** Sequence lanes instead; push WIP early so + interrupted work is recoverable. +7. **Determinism over suggestion — weight `Scalable` heavier than usual.** The project is moving work + out of prose "suggestions" that an agent may or may not honor, and into deterministic helpers and + hooks. Two reasons, both load-bearing: it cuts tokens, and it turns process rules into things that + are *enforced* rather than *advised*. When a SMARTS call is between adding skill prose and building + a helper or hook, `Scalable` carries extra weight and the deterministic option wins ties. + +## Review and completion standards + +- **Adversarial passes** run one pass with an **Opus** model at **medium** effort. (The Agent tool + pins the model but has no effort parameter; effort inherits the session setting.) +- **All HIGH findings must be remediated.** MEDIUM findings are fixed or filed at discretion. +- **The advisor is a maintainer proxy.** Questions that would otherwise stop the run for the user may + be put to the standing adversarial advisor and answered as if it were them — except the hard gates, + which remain true stops. +- **Completion bar: the replacement must be proven to work AND to port.** The sprint is not done when + the tasks are `ACCEPTED`; it is done when the new lane is demonstrated working in this repo and in a + clean consumer repo. Verifying against this repo's hand-built `.codearbiter/` state is exactly the + condition that hides consumer-facing bugs. + +## Workstream A — release portability + +Fully specified in `specs/release-portable-fixture.md` rev 4: 42 acceptance criteria across six +slices, cleared by three adversarial passes. Not restated here. Its dependency on this sprint is +narrow and explicit: **AC-2.6 and AC-2.7 are satisfied by workstream B's class**, with +`release-targets.md` registered as its first consumer. + +## Workstream B — generic protected-state machinery + +### B1 — The class + +A registry-based protected-write class over project-state files. **Each registry entry carries a +policy**, because the three consumers need materially different write semantics and a flat +marker-gated registry is wrong for two of them: + +| policy | semantics | consumer | +|---|---|---| +| `marker-gated` | Write/Edit/shell admitted only under a fresh authoring marker | `release-targets.md` | +| `helper-only` | Write/Edit/shell naming the file are **hard-blocked with no marker path**; the sanctioned helper's own file I/O is the only route | `open-tasks.md` | +| `append-only` | mutation only via the helper's append verb | `done-tasks.md` | + +**The policy enum is built in slice 1** even though only `marker-gated` is exercised then. Shipping a +marker-only schema would force a breaking rebuild at step 6. + +Why `open-tasks.md` must NOT be marker-gated: `taskwrite.py` already exists and its header states it +is "the ONLY blessed way to write `.codearbiter/open-tasks.md`". It is Python file I/O invoked via +Bash whose argv never lexically names the file, so it is **invisible to all three flanks by +construction**. A marker would therefore add nothing for the helper while *admitting* an agent that +composes board markdown under that marker — exactly what steer 2 forbids. The correct enrolment is a +hard block with no marker path at all. + +For the `marker-gated` policy only, the mechanism is the H-11 pattern +(`decision-lifecycle/SKILL.md:37,55` — mint immediately before the write, `rm -f` at lane exit; +`_hooklib.marker_fresh` is a 30-minute mtime window). + +**Registered on all three flanks**, using CONTEXT.md's existing guards as the template: + +- `pre-write.py` — the Write door +- `pre-edit.py` — the Edit door, via `classify_protected` per-class dispatch +- `_bashguardlib` — a redirect and write-verb regex pair mirroring `CONTEXT_REDIRECT_RE` / + `CONTEXT_WRITE_RE` (lines 355-356, checked at 1011) + +A one-flank implementation is a non-fix: it passes a Write-door test while +`echo '…' >> ` still lands. The shell-indirection residual (`f=…; sed -i "$f"`, novel +`python -c` spellings) is the same accepted ADR-0010 cooperative-attestation residual CONTEXT.md +already carries, and must be **named** in the new ADR rather than left implied. The marker's value is +audit friction, not authorization. + +**The registry is the deliverable, not the entries.** Adding a fourth protected file later must be a +registry entry with a policy, never a new hook branch. + +**Two flank regressions are B1 obligations, not enrolment-time discoveries.** `commit-gate` Phase 7 +runs `git add open-tasks.md` (`commit-gate/SKILL.md:113`) — a Bash argv naming a protected file — and +`/ca:task add -- "fix open-tasks.md schema"` puts the filename in argv as data. If the mirrored +verb-set drifts to include git verbs, **commit-gate blocks itself on every retained board flip**. +Pinned tests: `git add open-tasks.md` passes, a filename-in-description helper call passes, +`tee open-tasks.md` and `>> open-tasks.md` block. + +### B2 — Consumers + +| file | policy | write route | +|---|---|---| +| `release-targets.md` | `marker-gated` | `context-creation`, the back-fill lane, `/ca:release`'s row-edit path | +| `open-tasks.md` | `helper-only` | `taskwrite.py` exclusively; lands **last** per sequencing | +| `done-tasks.md` | `append-only` | the B4 archive verb exclusively | + +`done-tasks.md` is `helper-only`/`append-only` rather than joining the H-05 audit set. The audit set +would supply Write-block, tail-anchored append, and a shell flank for free, but it permits *any* +cooperative append — weaker than routing through the archive verb this sprint must build regardless. + +### B3 — Writer inventory (corrected; verified, not heuristic) + +The earlier "seven or eight writers" estimate was wrong in both directions. Verified inventory: + +**Already helper-routed — no conversion needed.** `harvest.md:51-52` promotes work via +`{{CMD:task}} add`, so every harvest-invoking surface (`commit-gate` Phases 7/151, `tdd`, +`brainstorming:75`, `writing-plans:102`) already writes through the helper. + +**Not writers at all.** `using-git-worktrees:34` explicitly says *not* the backlog; +`standup.md:44` states "the board is never mutated here" (it becomes a writer only via B4); +`reference-map.md` is routing. `commit-gate`'s apparent four paths overcount — line 97 is diff +retention and line 113 is `git add`. + +**Hook-layer writers the surface scan cannot see.** `init-codearbiter.py:96` *creates* +`open-tasks.md`, and `taskwrite.py` itself. Both are helper-path and flank-invisible. Verified +read-only: `boardsync.py` (its header says "Writes nothing"), `session-start.py:1123`, +`statusline.py`, `_arbiterstatelib.py`. + +**The only two surfaces needing real work:** + +1. `debug/SKILL.md:80` instructs a **direct append** carrying an indented `- Desc:` rationale + sub-bullet. `taskwrite add` has no sub-bullet support, so this is a conversion *plus a helper + extension*, not a prose swap. +2. `context-creation/SKILL.md:103` **populates** `open-tasks.md` via the Write tool during + doc-writing — blocked under `helper-only`. Needs either repeated `taskwrite add` seeding or a + scaffold-time exemption while the file is uninitialized. + +**ADR-0008 composes cleanly — verified by trace.** `/ca:task done X` → `taskwrite.py` stamps +`(done YYYY-MM-DD)` (`_taskboardlib.py:761`) → the Bash flank sees no lexical filename and passes → +`commit-gate` Phase 6 `classify_board_diff` sees a clean done-flip (the stamp is *required* by the +classifier at line 406/453) → RETAINED → Phase 7 stages by explicit path. The write-time guard and the +commit-time classifier act on different objects — a tool call versus a staged diff — so they are +complementary, not double-gating. + +**Circularity hazard — the structural mitigation, not the sequencing one.** Under `helper-only` the +guard *cannot* block `/ca:task`, because the lexical flanks never see the helper's write. That +construction, not "enrolment lands last," is what makes the hazard safe, and it gets a pinned test +(helper invocation succeeds with enrolment live). The residual is an overbroad flank regex blocking +unrelated Bash, where `/ca:override` is available and logged. + +### B4 — Archival sweep (closes D-2) + +**The sweep has no implementation path today and needs a new helper verb.** `taskwrite.py`'s verbs are +exactly `add`/`start`/`done` with no removal, and the routing table says "never delete to complete." +With `open-tasks.md` helper-only and `done-tasks.md` append-only, a model Edit performing the move is +blocked by this sprint's own guard. + +- **New verb `taskwrite archive [--date]`** (or `sweep --cutoff N`): **per-item** + append-to-`done-tasks` first, then remove from `open-tasks`, rerun-safe via dedup on dotted ID + (exact text for ID-less entries). Batch ordering is unsafe — appending all N then removing all N + duplicates every item if interrupted between phases, and the reverse order loses records. Per-item + ordering also maps standup's per-item confirmation 1:1 onto helper calls. +- **Owner: `/ca:standup`** — the daily-hygiene lane with per-action confirmation and a + never-destructive-without-a-yes contract, which is verbatim D-2's requirement. This holds + *conditional on the archive verb existing*; without it the composition genuinely fails, which is + why the arbitration was logged `confidence: low`. +- **Cutoff: done > 14 days**, a named constant per D-3's precedent, tested against an injected date. +- **Undated `[x]` items** archive only under explicit per-item confirmation. Both `taskwrite done` and + the ADR-0008 classifier enforce `(done …)` stamps, so an undated entry is legacy or override-era. + +**D-1 closes as fact, not decision** — verified: `taskwrite.py:2` literally says "resolves D-1". The +`open-questions.md` D-1 text predates the writer and is stale. What D-1 left open is that the +sanctioned path is not *enforced*, which is B1. + +## Sequencing + +1. **B1** — the class and registry **including the full policy enum**, with `release-targets.md` as + its only live consumer. The enum is designed now, from B3's verified inventory, even though only + `marker-gated` is exercised at this point; deferring it forces a breaking schema rebuild at step 6. + The two flank regressions are obligations here. +2. **A slices 1–4** — mechanism ships, data loads, pre-tag execution, CI repointed. +3. **A slice 5** — onboarding and back-fill. +4. **B3 conversions** — `debug` and `context-creation` onto the helper path, plus the `taskwrite add` + sub-bullet extension `debug` needs. The inventory itself is already verified, so this is bounded + conversion work rather than discovery. +5. **B4** — the `taskwrite archive` verb, `done-tasks.md`, and the standup sweep. Runs + sanctioned-but-unguarded in the gap before step 6, which is fine. +6. **B2 open-tasks enrolment** — last, once both conversions have landed. +7. **A slice 6** — surface reconciliation. The docs-site half (AC-6.5) is separable and carries its + own verification regime (`npm test` over the generator suites). + +## Expected hard gates + +Named in advance so their tripping is a planned stop rather than a signal the spec was thin: + +- **`security-controls.md` boundary entry** (A's AC-2.5) — a trust-boundary change, never auto-decided. +- **The protected-write class itself** — it modifies hook guards that run in every consumer repo. +- **The new ADR** for the executable-input boundary and the ADR-0010 residual — `/ca:adr` requires + user attribution by hard rule. +- **Merge to the default branch** — `/ca:sprint` auto-selects open-PR and never merges. + +Everything else is auto-decidable under SMARTS with the steers above. + +## Out of scope + +- Relocating `.github/published-tags.json` (D-6 stays deferred; the row field makes the lane coherent). +- Changing what any release gate *does*, beyond what rev 4 specifies. +- Enrolling project-state files beyond the three named consumers. The registry makes that a later + one-line change, which is the point. + +## Non-gating open question + +`[CONFIRM-05]` (the `--farm` Feature Forge promotion bar) is open but unrelated: this sprint runs the +premium backend and does not touch the farm seam. It is not resolved here and does not block. diff --git a/.codearbiter/specs/release-portable-fixture.md b/.codearbiter/specs/release-portable-fixture.md new file mode 100644 index 00000000..138a4847 --- /dev/null +++ b/.codearbiter/specs/release-portable-fixture.md @@ -0,0 +1,446 @@ +# Spec — `/ca:release` as a portable, shippable fixture + +**Date:** 2026-07-30 (rev 4, after three adversarial review passes — cleared for planning) +**Status:** awaiting approval +**Issue:** #563 +**Governs:** core/surface/skills/release/**, core/surface/commands/release.md, core/pysrc/_releaselib.py, plugins/ca/skills/release/**, .github/scripts/_releaselib.py, .codearbiter/release-targets.md + +## Problem + +`/ca:release` encodes this repository's release mechanics as skill logic and depends on three files +outside the plugin payload — `.github/scripts/_releaselib.py`, `tools/build-host-packages.py`, and +`.github/published-tags.json`. None ship under `plugins/ca/`, so the lane cannot execute in any repo +that installs codeArbiter. Pre-flight's first resolution step calls a helper that is not there. + +The skill was portable at `6a45173` (51 lines, `git describe --tags`, root `CHANGELOG.md`) and lost +portability at `c12b1a3` (#125), which is an ancestor of the multi-plugin work at `c20c2d0` (#497). +Every hardening commit fixed a real failure; the defect is that each fix was written as a hardcoded +fact about this repository instead of a parameter read from project state. + +**Measurement rule.** A reference is contaminating when it names a path **belonging to this +repository** that the skill *executes or reads*. Bare substring matching is rejected — +`context-creation/SKILL.md:49` has a scout read `.github/workflows/` in the **consumer's** repo, +which is correct and must never be flagged. Under this rule three shipped skills are contaminated: +`release` (heavily), `subagent-driven-development:45` (`tools/farm.js`, which ships at +`plugins/ca/tools/farm.js` and so resolves in neither form), and `decision-lifecycle:70`. + +## Scope + +**In scope.** Split `_releaselib` into portable mechanism (to `core/pysrc/`) and repo-specific data +(to project state); a new `.codearbiter/release-targets.md` with a defined grammar and parser +contract; check-only pre-tag commands per row (DECISION-0034); a new protected-write class for the +declaration file; `context-creation` full elicitation with `decompose` intent-only and a release-time +back-fill; CI repointed to the declared source with name-keyed target selection; all three +contaminated skills fixed; `commands/release.md` reconciled with the skill. + +**Explicitly out of scope.** Changing what the gates *do* — immutable-tag doctrine, the +`CHANGELOG:`-footer BLOCK, publish read-back, derive-don't-guess all survive unchanged in behavior. +Redesigning `_releaselib`'s algorithms. The `release.yml` job structure beyond name-keying selection. +Adding a governance host. Changing this repo's four target definitions. **Relocating** +`.github/published-tags.json` — its path becomes an optional row field; where this repo's copy lives +is deferred to D-6. + +## Source of truth + +Every skill and command edit lands in `core/surface/`, never a generated `plugins/*/` copy. Guards +and structural assertions target the surface source. + +**The release skill ships in five payloads, and this list was wrong twice.** Measured: + +| payload | `.github/scripts` refs | +|---|---| +| `plugins/ca/skills/release/SKILL.md` | 7 | +| `plugins/ca-codex/routines/release/SKILL.md` | 7 | +| `plugins/ca-pi/routines/release/SKILL.md` | 7 | +| `plugins/ca-codex/skills/ca-release/SKILL.md` | 0 (stub) | +| `plugins/ca-pi/skills/ca-release/SKILL.md` | 0 (stub) | + +*(Rev 4.7 — an earlier revision named four payloads and got the significant ones backwards: it listed +the two `ca-release` stubs, which carry no contamination, and omitted +`plugins/ca-codex/routines/release/` entirely, which carries a full set. An adversarial review caught +the `ca-pi` omission; generalizing that fix surfaced the `ca-codex` one, which no review or spec had +named. Any assertion scoped to one payload — including T-79's terminal "the list is empty" — would +have certified `ca` while two identically contaminated copies kept shipping to Codex and Pi +consumers.)* + +## Migration ordering (load-bearing) + +Six sites shell out to `python3 .github/scripts/_releaselib.py` — `release.yml:135,171` and +`.github/actions/publish-release/action.yml:125,164,180,228` — and `payload_version_gate.py:53` +imports `RELEASE_TAG_PREFIXES` from it at module load. + +**The shim is permanent.** `.github/scripts/_releaselib.py` remains as CI's stable entry point, +becoming a thin, **data-free** re-export of the generated mechanism plus a CLI that loads target data +from the declared file. It is never deleted. This deliberately avoids coupling six CI call sites to a +generated payload path. + +1. **Slice 1** lands `core/pysrc/_releaselib.py` (mechanism only) and converts the shim to re-export + it while **temporarily retaining** the data constants. Every CI consumer keeps working unchanged. +2. **Slice 4** removes the constants from the shim once the gate and workflow read the declared file. + The shim itself survives. + +The invariant is scoped, not absolute: **until slice 4 completes**, no commit may leave +`RELEASE_TAG_PREFIXES` unimportable from the shim. AC-1.9 is explicitly **transitional** — it is +superseded by AC-4.4, and the obligation test it creates (`test_releaselib_shim_exports_constants`) +is retired in the same commit that satisfies AC-4.4. + +The shim locates the declared file from its own `__file__` rather than the working directory, using +the cwd-independent pattern already at `payload_version_gate.py:56`. All six shell-out sites run at +the checkout root of a full `actions/checkout` and the file is tracked, so it is present wherever the +shim is. **An absent or unparseable file fails closed** — the loud parser error turns preflight or +the gate red, which is the correct direction for a `contents: write` publisher. Nobody should later +"fix" that into a default. + +## File grammar + +Per-target sub-blocks of `key: value` lines inside the HTML-comment delimiters `_scopelib` +recognizes. **Not a markdown table** — this repo's pre-tag commands contain pipes. + +``` + +[ca] +prefix: v +manifest: plugins/ca/.claude-plugin/plugin.json +changelog: CHANGELOG.md +payload: plugins/ca/ +rebuild: cd plugins/ca/tools && npm run build +artifacts: plugins/ca/tools/farm.js +provenance-manifest: .github/published-tags.json +latest-eligible: true +pre-tag: python3 .github/scripts/check_badge_consistency.py +pre-tag: python3 .github/scripts/check_command_catalog.py + +[ca-pi] +prefix: ca-pi-v +manifest: plugins/ca-pi/package.json +manifest: package.json +changelog: plugins/ca-pi/CHANGELOG.md +payload: plugins/ca-pi/ +payload-exclude: plugins/ca-pi/tools/ +rebuild: node plugins/ca-pi/tools/build.mjs +artifacts: plugins/ca-pi/extensions/codearbiter.js +artifacts: plugins/ca-pi/extensions/codearbiter-child.js +latest-eligible: false +pre-tag: python3 tools/build-host-packages.py --check + +``` + +A single-artifact consumer declares one block: + +``` + +[app] +prefix: v +manifest: package.json +changelog: CHANGELOG.md +payload: . + +``` + +`prefix`, `changelog`, `payload` are required. All other keys are optional. + +**Script status.** `check_badge_consistency.py` exists. `tools/build-host-packages.py --check` exists +(line 177) and is used directly rather than wrapping it in a new script. `check_command_catalog.py` +does **not** exist and must be authored (AC-2.8). + +### Parser contract + +Every case below is a declared, distinguishable error unless stated otherwise — never a silent +default, never a partial parse. + +- Values split on the **first** colon; later colons are part of the value. +- A trailing `\r` is stripped from every line before parsing. This repo has documented LF→CRLF drift + from editing on Windows, and a naive parse would turn `latest-eligible: true\r` into a value that + is not `true`, silently dropping `ca`'s Latest badge — exactly the silent-default failure the + loud-failure criteria exist to forbid. +- Booleans are exactly `true` or `false`; any other value errors. +- A duplicate scalar key within a block errors. Keys that are lists (`manifest`, `artifacts`, + `pre-tag`, `payload-exclude`) repeat by design and preserve order. +- A duplicate `[target]` block errors. +- An unknown key errors, so a typo (`latest-eligibile:`) cannot silently drop a setting. +- More than one delimiter block in the file errors. +- A value containing the literal closing delimiter errors, rather than truncating the block under + non-greedy matching as `_scopelib.py:48` would. +- The `[target]` header grammar is covered by the generic malformed-block error: an empty `[]`, a + header carrying characters outside `[A-Za-z0-9._-]`, and any key appearing before the first header + all error rather than being skipped. + +## Acceptance criteria + +Grouped by slice; each is one `tdd` Phase 1 obligation and individually testable. + +### Slice 1 — mechanism ships, data loads, this repo proven unchanged + +1.1 `core/pysrc/_releaselib.py` exists and `python tools/sync-core.py --check` passes with it in the + generated set. +1.2 The mechanism contains no literal from this repo's namespace or CI vocabulary — a denylist over + `[REPO]`, `ca-pi`, `ca-codex`, `ca-sandbox`, `plugins/`, and the tag-prefix constants. +1.3 Repo-specific defaults become required parameters: `classify_merge_readiness` takes the check + name, `last_tag_select` takes the prefix, and `select_release_target` takes the target list — + none with a default, so no module-global survives to detonate at slice 4. +1.4 `load_targets(path)` returns rows carrying `target`, `prefix`, `manifest[]`, `changelog`, + `payload`, `payload_exclude[]`, `rebuild`, `artifacts[]`, `provenance_manifest`, `pre_tag[]`, + `latest_eligible`, stdlib only. +1.5 An **absent** block raises a distinguishable declared error. +1.6 Each parser-contract violation raises its own distinguishable declared error: malformed block + (including a bad `[target]` header), non-boolean boolean, duplicate scalar key, duplicate target + block, unknown key, multiple delimiter blocks, delimiter-in-value. A missing required key errors + too, rather than silently defaulting. *(Rev 4.2: an earlier draft listed "CRLF-bearing boolean" + among the violations. That is the opposite — `latest-eligible: true\r` must parse cleanly to + `True`. It is a positive case and is asserted as one.)* + + **A dedicated CR-stripping pass is dead code.** Every line is independently `.strip()`-ed and + Python's `str.strip()` already removes `\r`, so a separate pass can be deleted with the CRLF + test still green. Do not reintroduce one. + + *(Rev 4.3 correction: an earlier revision of this note also claimed "a single-point mutant + cannot kill the CRLF test." That is false and was written in from an unverified report. Measured, + removing `raw_line.strip()` alone DOES kill it — and for an incidental reason: with CRLF input + the extracted block's first line is a bare `\r`, which unstripped becomes a spurious key line and + raises `MalformedBlockError` before the boolean assertion is reached. The other two strip points + do survive individually. The CRLF property therefore needs a test that asserts the parsed boolean + directly against a CRLF fixture, not one that passes because an earlier error path fires.)* +1.7 An **empty** block raises a distinguishable declared error. +1.8 Series isolation holds against loaded data: a fixture with `v1.0.0` and `ca-pi-v0.1.0` resolves + each declared prefix to its own newest tag, pre-releases excluded. +1.9 *(transitional — retired by AC-4.4)* The shim re-exports the generated mechanism and still + exposes `RELEASE_TAG_PREFIXES`; `payload_version_gate.py` imports and runs unchanged. +1.10 This repo's four rows load, and `target` and `prefix` equal the recorded pre-change constants. +1.11 **Resolution trace.** Constructed by: (a) freezing a synthetic fixture — tag list, manifest + files, a small commit graph, the four rows; (b) implementing the pre-change pre-flight as a + test-only script whose helper calls pin to the pre-change module via + `git show :.github/scripts/_releaselib.py`; (c) recording the resolved-variable + dict (`TAG_PREFIX`, `LAST_TAG`, window commit set, manifest versions, artifact list); (d) + asserting the new lane reproduces it. The old-lane script is validated once against the live + repo before freezing. The trace covers **`ca` and `ca-pi`** — `ca` alone exercises neither + `payload-exclude`, nor multiple manifests, nor the generated-root rule, which are the three + behaviors most likely to change in migration. *Honest limit:* variables that existed only as + prose still enter the old-lane script by transcription, so this narrows the oracle problem + rather than eliminating it. + + **A second, sharper limit on the window variable.** `LAST_TAG`, manifest versions and artifacts + are genuinely independent between the two lanes. The *window commit set* is not: window + derivation has never lived in `_releaselib.py`, old or new — it is release-skill prose + (`git log LAST_TAG..HEAD`). So any straightforward trace shares one walk function across both + lanes, and the window comparison can only fail where `LAST_TAG` has already failed. It carries + no independent discriminating power **and no fixture change can give it any**, because a merge + commit or any other structure changes both sides identically. The window is therefore a + consistency check on the harness's own walk, not a behavior trace, and the criterion must not be + read as if it proves anything about the migration. + + *(Rev 4.6 corrects rev 4.5, which claimed a merge commit confers discriminating power. It does + not, for the reason above — that claim was written in from an unverified report and is the third + such error in this campaign. The fixture's merge commit does earn its place, but for a different + property: it catches a first-parent-only walk in the harness. The test docstring stated this + correctly while the spec stated the opposite.)* + +1.12 **The trace must state what it knowingly does NOT hold constant.** `last_tag_select` was + deliberately changed during this campaign: the pre-release marker test moved from the whole tag + to the prefix-stripped version portion, so a consumer prefix containing `-beta`/`-rc`/`-alpha` + no longer loses its entire series. That is an intended behavior change, and a trace asserting + "nothing changed" across it is asserting something false — it only reads as true because no + fixture prefix carries a marker. The fixture must include a marker-bearing prefix row and assert + the **intended** divergence explicitly (old lane yields the never-released sentinel, new lane + resolves the tag), converting the trace from "nothing changed" into "exactly this changed, on + purpose". + +### Slice 2 — pre-tag execution (check-only, DECISION-0034) + +2.1 Declared commands execute in declared order. +2.2 A non-zero exit from any pre-tag command blocks the release. +2.3 A pre-tag command that leaves the tree dirty blocks the release; the assertion is unconditional + with no per-row opt-out. It is evaluated **before** any `rebuild` runs, so a rebuild's legitimate + bundle rewrite is never attributed to a pre-tag command. +2.4 A `pre-tag` entry exceeding 1024 characters is rejected, per ADR-0002's precedent. +2.5 `security-controls.md` carries a boundary-crossings entry naming `release-targets.md` as + operator-authored executable input. +2.6 **A new protected-write class** admits mutations of `release-targets.md` only under a fresh + authoring marker **of its own**, on the H-11 pattern (`decision-lifecycle/SKILL.md:37,55` — mint + immediately before the write, `rm -f` at lane exit; `marker_fresh` is a 30-minute mtime window). + Reusing `adr-authoring-active` is wrong in both directions: an `/adr` session could write rows, + and a row edit would arm ADR authoring. Every existing class is unusable — `context` + (`pre-write.py:68-90`) admits any write whose result keeps `arbiter: enabled` frontmatter, which + this file does not have, so every write would block; `marker` blocks outright; `audit` is + append-only; `decisions` requires a marker only `/adr` mints. **The class registers on all three + flanks**, with CONTEXT.md's guards as the template: `pre-write.py`, `pre-edit.py`'s per-class + `classify_protected` dispatch, and a `_bashguardlib` redirect/write-verb pair mirroring + `CONTEXT_REDIRECT_RE` / `CONTEXT_WRITE_RE` (lines 355-356, checked at 1011). Sanctioned minters: + `context-creation`, the back-fill lane, and `/ca:release`'s own row-edit path. +2.7 **Four-case flank test:** a Write blocks, an Edit blocks, a shell redirect and a `sed -i`-class + write verb block, and the AC-5.4 marker-fresh back-fill write succeeds. Testing the Write door + alone passes while `echo 'pre-tag: ...' >> .codearbiter/release-targets.md` still plants a + command the release lane later executes — which is the whole attack the class exists to price up. +2.8 `check_command_catalog.py` exists and asserts, without mutating, that the canonical catalog + enumerates exactly the command files and that the README table lists every one. +2.9 This repo's declared rows execute end-to-end green on a clean, reconciled tree — the criterion + that stops slice 2 passing on fixtures while the first real release blocks. +2.10 A change to a row's `pre-tag` content hash forces re-confirmation before the next execution. The + hash is minted by a sanctioned Python producer, since H-19 (`pre-write.py:63`) blocks Write-tool + writes under `.markers/`. Re-confirmation is cooperative-grade per ADR-0010, which the new ADR + states rather than implies. + +### Slice 3 — schema completeness and consumer viability + +3.1 A row declaring a manifest asserts version equality and BLOCKs on mismatch. +3.2 A row declaring no manifest proceeds with the derived tag as version source, no assertion. +3.3 A declared `rebuild` runs and every `artifacts` entry is asserted clean afterward; a stale bundle + blocks. A nondeterministic bundler makes this permanently blocking, which is acceptable since + `rebuild` is optional — but the block report must name that as the cause. +3.4 `payload-exclude` entries are excluded from the commit window, verified against `ca-pi`'s + `tools/` exclusion. +3.5 `provenance-manifest` is optional; when absent the tag-provenance recording step is skipped and + the report says so explicitly. +3.6 Interpreter resolution succeeds where `python3` is absent but `python` is present, matching the + hook layer's existing fallback. + +### Slice 4 — CI reads the declared source + +4.1 `payload_version_gate.py` derives prefixes from the declared file; no tag-prefix literal remains. +4.2 Target selection is **name-keyed**: each confirmation input carries its target name, and + selection never depends on row order. +4.3 A workflow-contract test fails when the declared target set and the workflow's inputs disagree by + name. +4.4 The data constants are removed from the shim, the shim survives as CI's entry point loading data + from the declared file, all six shell-out sites and `payload_version_gate.py` still pass, and + AC-1.9's transitional test is retired in the same commit. + +### Slice 5 — onboarding and back-fill + +5.1 `decompose` elicits intent only — tag prefix, whether a changelog is kept — and writes no row it + cannot substantiate, since it runs before any manifest or tag exists. +5.2 `context-creation` scouts candidate manifests and changelogs and writes a file that + `load_targets` accepts; the assertion is on the **written file's validity**, not skill prose. +5.3 With no declared file, the back-fill presents a detected shape and does not proceed without + explicit confirmation. +5.4 On confirmation the back-fill persists the file, and a second run reads it rather than + re-detecting. +5.5 First release after adoption: with `LAST_TAG=`, the lane offers a changelog baseline at the + adoption commit instead of BLOCKing once per pre-adoption commit missing a `CHANGELOG:` footer. +5.6 `.codearbiter/.provenance/release-targets.json` records the rows' **own referenced paths** + (`manifest`, `changelog`, each `artifacts` entry) as drift triggers. A CONTEXT.md-Scope trigger is + explicitly not used: `_provenancelib.compute_drift` compares whole-file git oids with no + section-level machinery, so it would fire on a `stage:` flip and stay silent when a manifest path + moves. Routine per-release version bumps **will** trip these triggers by design; `heal_worklist` + auto-heals them in the same release commit, and the spec records this so a later maintainer does + not delete the triggers to quiet the noise. + +### Slice 6 — surfaces agree + +6.0 **The release skill itself resolves its targets from the declared file.** Its Targets table is + replaced by `load_targets`, its helper invocations resolve under `${CLAUDE_PLUGIN_ROOT}`, its + Phase-3 tag-provenance step reads the `provenance-manifest` row field, and its hosted-lane and + immutability prose are conditional on what the consumer's repo actually has. *(Added rev 4.1 — a + review of the plan found that no criterion required rewriting the skill, so a task set could + prove bijective coverage while the campaign's central deliverable was missing.)* +6.1 A guard scans `core/surface/skills/**` and fails on any reference naming a **this-repo path the + skill executes or reads**, permitting `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PROJECT_DIR}` + prefixes and permitting repo-path *patterns* inside scout scan-target lists. The guard states its + matching rule in its own docstring. +6.2 `subagent-driven-development`'s reference resolves as `${CLAUDE_PLUGIN_ROOT}/tools/farm.js`. +6.3 `decision-lifecycle`'s line is **reworded to a conditional CI reference** rather than naming a + script the skill executes, since `check_adr_identity.py` is CI-only and is not shipped. +6.4 `commands/release.md` documents the same arguments and phase numbering as the skill. +6.5 The docs-site release guide distinguishes the general lane from this repo's configuration. +6.6 **Portability is proven in a clean consumer repo, not asserted.** The plugin is materialized by + `git archive HEAD -- plugins/ca` extracted into a scratch cache — **not** by pointing + `CLAUDE_PLUGIN_ROOT` at the in-repo tree, and **not** by a recursive copy. Both alternatives carry + dev-tree state: an in-repo pointer is the dev tree wearing a costume, and a copy brings + uncommitted and gitignored files, so a skill referencing a file that exists locally but was never + committed would still pass. `git archive` delivers exactly the committed payload. The scratch + consumer repo holds one `package.json`, a `CHANGELOG.md`, one `v*` tag, synthetic conventional + commits carrying `CHANGELOG:` footers, and **no file from this repository**. + + Three layers, with different reach: + + - **Reference resolution.** Every executed-or-read path in the *installed* `SKILL.md` resolves + against the scratch plugin root or scratch consumer repo. This catches the defect class that + started #563 with no model in the loop. It is distinct from AC-6.1's guard: the guard checks + the source lexically and misses a payload-packaging failure; this checks the installed result + physically. + - **Lane driver.** The mechanical sequence runs *as the prose spells it* — invoking the helper + CLI with **invocation strings extracted from the skill text**, never by direct import — through + target resolution, window derivation, bump classification, changelog rolling, and tag-message + composition. A drift between what the prose says to run and what the CLI accepts must fail + here rather than be papered over by a direct-import test. + - **Agent judgment (irreducible, and narrow).** Whether a model following the prose confirms the + bump, BLOCKs on a missing footer, STOPs without publication authorization, and handles the + back-fill confirmation honestly. Nothing mechanical proves prose-followability. This layer's + scope must be stated in its own docs so it is neither inflated nor skipped as already covered. + + Assertions are on **derived outputs** — the resolved row, `LAST_TAG`, the computed bump, the + rolled changelog text — never on exit codes alone. + + *Install-mechanism defects are explicitly out of reach here* — manifest parse errors, hook wiring + at install. Those belong to the clean-home install smoke test performed at release, and must not + be pulled into per-PR CI. + +6.7 **This repo still releases — proven without creating a ref.** The T-27 trace apparatus extends to + live-repo HEAD: the pinned pre-change lane and the new lane each derive the next version, the + commit window, and the composed tag **message file**, and the two must be equal. `notes-match` + and `dates-consistent` run against the composed message. **Zero refs are created.** + + *(Rev 4.4 — this replaces "compose a tag on a scratch branch and discard it". Two hazards killed + that: `payload_version_gate.py:115-117` checks local tags, so an interrupted proof leaves one + behind and every later gate run reports "already released"; and a single habitual + `git push --tags` publishes at the real next version with no `published-tags.json` entry, + tripping the immutability audit and forcing exactly the cleanup release doctrine calls a + deliberate, announced maintainer action. A tag's informative content is its message file and its + target derivation, both comparable without a ref. Real annotated-tag mechanics are exercised + inside the AC-6.6 scratch fixture, where refs are disposable by construction and the same code + path runs.)* + +6.8 **The agent-layer proof cannot rot silently.** The proof run records the content hash of the + shipped release skill it exercised. A declared `pre-tag` check asserts at release time that the + hash still matches the shipped skill, so editing the skill without re-running the proof **blocks + the next release**. Enforcement at the frequency the proof matters — per release, not per PR — + using this campaign's own `pre-tag` mechanism as the enforcement point. + +## Decisions on record + +- **DECISION-0034** (supersedes 0033) — pre-tag commands are declared per row and check-only; no + assert-clean flag. Reconciliation is a separate operator action through `commit-gate`. +- Declaration home is a separate `.codearbiter/release-targets.md` rather than a `CONTEXT.md` block, + on **context economy**: `CONTEXT.md` is read every session, release config only when tagging. The + write guard is recovered explicitly by AC-2.6 and relevance by JIT enrolment. +- `context-creation` owns full elicitation; `decompose` owns intent only; release-time detection is + back-fill. + +## Splitting + +Three parts are separable and should be planned as their own clusters rather than interleaved with +the release slices. + +**The protected-write class (AC-2.6, 2.7) is the significant one, and it is not release machinery.** +It touches three hook flanks that run on every session in every consumer repo, so its blast radius is +far wider than this lane. It is also the first instance of a pattern the project is heading toward +deliberately: more project-state files that are **written by helpers rather than by inference**, with +a guard making the helper the only path. `_taskboardlib`'s `next_seq` / `add_entry` / `set_state` +already supply the helper half for `open-tasks.md`; what is missing is the guard. + +It therefore MUST be designed as **generic marker-gated project-state machinery** parameterized over +a registry of protected files, never as a `release-targets.md` special case. `release-targets.md` is +its first consumer, not its reason. A one-off implementation here would have to be torn out the first +time `open-tasks.md` adopts it. + +**AC-6.5 (docs-site guide)** has no code coupling to any slice and carries its own verification +regime (`npm test` over the generator suites), so it belongs in a docs lane. + +**AC-3.6 (interpreter fallback)** likely generalizes to any skill that shells `python3`. Keep it here, +but if implementation reveals a shared convention change, split it rather than widening this campaign +silently. + +## Open questions + +None blocking. **D-6** is narrowed: the `provenance-manifest` field now exists, so the portable lane +is coherent without relocating the file. Deferred is only whether this repo's copy physically moves +from `.github/` to `.codearbiter/`. + +## Review provenance + +Two adversarial passes. Pass 1 found the rev-1 criteria set unsatisfiable, the MVP slice ordering +CI-breaking, the drift trigger decorative, the golden test's oracle circular, the positional +`select-target` a reorder-to-mispublish hazard, and corrected the contamination measurement. Pass 2 +verified 11 of 15 repairs sound and found four defects, two introduced by the repairs themselves: +AC-2.6 was specified against a false model of the protected-write machinery and would have blocked +the very onboarding writes it sits beside, and the migration invariant contradicted AC-4.4 while +shim removal would have broken six CI call sites uncovered by any criterion. Both are fixed above. diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index 9a909c74..81a6c999 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -1,245 +1,245 @@ -# Sprint log — checkpoint-remediation-2026-06-12 - -Append-only. Every auto-decision logged with SMARTS verdict and confidence flag. -`low` entries = review these in the morning. - ---- - - - ---- - -# Sprint log — checkpoint-2026-06-13-remediation -Started 2026-06-13. Append-only. SMARTS-scored auto-decisions; `low` = review these. - -## SD-01 — Pre-flight checkpoint-artifact commit path · confidence: low -- **Point:** How to commit the outstanding checkpoint artifacts (2026-06-13.md, last-checkpoint, overrides.log) at task 0 without violating §3 "MUST NOT commit without commit-gate." -- **Options:** (a) direct git commit now; (b) leave staged, land via commit-gate as a separate logical `chore(checkpoint)` commit during the landing phase. -- **SMARTS:** Reliable/Securable favor (b) — honors the §3 hard rule, single gated commit path, no precedent of bypassing commit-gate for "just docs." Maintainable neutral. -- **Chosen:** (b). Branch + farm.js revert done at task 0; artifacts carried uncommitted into the gated landing. Strength: moderate. - -## SD-02 — Apply LOW URL-parse hardening to assertSecureBaseUrl · confidence: low -- **Point:** security-reviewer PASS on Workstream B with one LOW (optional): regex-based scheme/loopback check vs new URL() parsing. "No present vulnerability," remediation provided. -- **Options:** (a) accept PASS as-is, log LOW as deferred; (b) apply the URL-parse hardening now within the same guard function. -- **SMARTS:** Securable/Reliable favor (b) — the guard protects a Bearer token; parse-don't-regex eliminates userinfo/normalization edge cases the reviewer named; reviewer supplied exact code; change is in-scope (same function, workstream B) and small. Maintainable favors (b): URL parsing reads clearer than a hand-anchored regex. -- **Chosen:** (b). Strength: moderate. Re-verify: 62+ vitest green incl. localhost.evil rejection; rebuild farm.js. - -## SD-02-note — correction to SD-02 premise -- The hardening agent found the OLD anchored regex already rejected userinfo (`http://localhost@evil`, `http://user:pass@127.0.0.1`) — so there was no userinfo red→green; the regex closed that gap by anchoring. -- Genuine delta of new URL(): HOST NORMALIZATION. `new URL("http://①27.0.0.1")` normalizes to 127.0.0.1 and is now ACCEPTED where the regex REJECTED it. Result is still loopback, so no cleartext-to-remote leak, but it is a behavioral loosening. Author left it unlocked, flagged [NEEDS-TRIAGE]. -- Action: re-run security-reviewer on the URL-parse version, focused on the normalization question, before surfacing B at the hard gate. SD-02 still stands (URL parsing is cleaner + explicitly rejects userinfo on the http path), but verification is warranted. - -## SD-03 — Decision-log Status field for proposed ADRs · confidence: low -- **Point:** smarts.md decision-log Status enum is {accepted|superseded|deferred}; user chose ADR status `proposed` (declined the "mark accepted" option). No enum value matches. -- **Options:** (a) force Status: accepted (contradicts the user's explicit proposed choice); (b) use Status: proposed per the decision-lifecycle ADR lifecycle, note the reconciliation. -- **SMARTS:** Reliable favors (b) — fidelity to the user's explicit decision over enum-strictness; the conflict is surfaced in the log header (not silently reconciled, per ORCHESTRATOR §0). -- **Chosen:** (b). Strength: moderate. Surfaced to user in the gate message. - -## SD-04 — Version bump for the landing commit · confidence: low -- **Point:** CI `version-bump` job fails a payload change (`plugins/ca/**`) on an already-published tagged version. farm.ts/farm.js changed under the published `2.1.0-beta.2`; landing requires a bump. -- **Options:** (a) bump patch-preview `2.1.0-beta.2` → `2.1.0-beta.3`; (b) bump minor `2.1.0` → `2.2.0`. -- **SMARTS:** Maintainable/Reliable favor (a) — the change is a remediation + governance set within the in-flight beta line, not a new feature surface; beta preview increments are the established cadence (beta.1 → beta.2 → beta.3). Bumping the minor would imply a finished feature the sprint did not add. -- **Chosen:** (a). plugin.json + README version badge + CHANGELOG `[2.1.0-beta.3] — 2026-06-13`. Strength: moderate. - -## SD-05 — PR base branch · confidence: low -- **Point:** sprint branch is 6 commits off `checkpoint-remediation-2026-06-12` (itself 5 commits ahead of and unmerged to `main`). PR base: `main` or the parent branch? -- **Options:** (a) base on `main` — PR bundles the parent's 5 unmerged commits with this sprint's 6; (b) base on `checkpoint-remediation-2026-06-12` — stacked PR showing exactly the 6 sprint commits. -- **SMARTS:** Reviewable/Maintainable favor (b) — a stacked PR isolates this checkpoint's remediation for review; bundling the parent's prior work muddies the diff and conflates two checkpoints. Merge order stays the user's. -- **Chosen:** (b). Strength: moderate. PR opened, NOT merged (merge-to-default is the user's hard gate). - ---- - -# Sprint log — session-hygiene -Started 2026-06-13. Append-only. SMARTS-scored auto-decisions; `low` = review these. -Posture: BALANCED (tie-break toward cheap hardening, then ORCHESTRATOR §2). -Backend: premium subagents (no --farm, per user invocation) — confidence high. - -## SH-T1 — Task 1 ACCEPTED · confidence: high -- 6 pure parsers in `_standuplib.py`; 38 unittests green (fresh-run verified by orchestrator); py_compile clean; stdlib-only; no os.environ use. Spec-compliance + verification PASS. Prune rule chosen: `: gone]` upstream marker, current/default excluded. - -## SD-A1 — Merge plan tasks 2+3 into one author dispatch · confidence: high -- **Point:** plan split "RED failing test" (task 2) from "impl" (task 3); the tdd skill performs RED→GREEN within a single author. -- **Options:** (a) two subagent dispatches (test-only, then impl); (b) one author doing RED→GREEN per tdd's natural flow. -- **SMARTS:** Maintainable/Efficient favor (b) — a subagent that writes only a failing test then exits wastes a context; tdd is designed as one RED→GREEN loop. No loss of rigor: orchestrator still fresh-verifies. -- **Chosen:** (b). Tasks 2 & 3 both marked ACCEPTED on the single dispatch's verification. Strength: strong. - -## SH-T4 — Task 4 ACCEPTED · confidence: high -- `briefing_mode(marker_present, actionable)` three-mode contract (full/offer/none); offer is exactly one line; clean-repo later sessions silent. 64 tests green (49 standup + 15 session_start), fresh-verified. SH-2 met. - -## SD-A2 — Combine tasks 6+8 and 5+7 · confidence: moderate -- **Point:** tasks 5,6,7,8 all modify session-start.py; 8 (content assembly) consumes 6's git-invocation layer; 5 (dormancy) and 7 (read-only proof) are guard-tests over the assembled behavior. -- **Options:** (a) four separate dispatches; (b) combine 6+8 (cohesive git layer + content), then 5+7 (cohesive guard tests). -- **SMARTS:** Efficient/Maintainable favor (b) — content assembly and the git layer it depends on belong in one context; splitting forces the 8-author to re-derive 6's subprocess layer. Reliable neutral (orchestrator fresh-verifies each). Tie broken toward balanced/efficient. -- **Chosen:** (b). Strength: moderate. Order: 6+8 → 5+7 → task 9 sweep. - -## SH-TRIAGE-2 — coding-standards.md missing · [NEEDS-TRIAGE] -- Every backend-author reports `.codearbiter/coding-standards.md` is named as required reading but absent. Framework gap, not in this sprint's scope. Authors fell back to tech-stack.md + house style. Flag for a future chore. - -## SH-SLICE-A — Slice A (tasks 1–9) ACCEPTED + Phase 4 PASS · confidence: high -- 83 unit tests + cold-install 131 assertions green (fresh-verified). security-reviewer over combined diff: PASS, 0 CRIT/HIGH/MED, 1 informational LOW (deferred-fetch-freshness, by design, no action). Subprocess layer: argv-list only (no shell), static git verbs, DEVNULL-detached fetch, 2.5s read timeout, marker-only write, read-only proven. SH-1..5 met. - -## SD-B1 — stale-worktree classification rule (carried from task 8) · confidence: low -- **Point:** task 8 deferred `stale_worktrees` population (no rule defined); briefing + /ca:standup worktree-cleanup (task 10) both need it. -- **Options:** (a) stale = non-main worktree whose branch is gone/merged on remote OR whose path no longer exists on disk; (b) only path-missing (git's own `worktree prune` definition); (c) only branch-gone. -- **SMARTS:** Reliable/Securable favor (a) — broadest *candidate* set is safe because /ca:standup only ever LISTS and removes per explicit confirm; never auto-removes. Maintainable neutral. Posture balanced → include both signals so the briefing surfaces real cruft, with removal always user-gated. -- **Chosen:** (a), as a PURE classifier over parsed worktrees + the gone/merged branch set. Strength: moderate. confidence low (a rule choice the user may want to narrow). - -## SH-T10 — Task 10 ACCEPTED · confidence: high -- `ff_pull_eligible`, `stale_worktree_candidates`, SH-8 exclusion confirmed; assembly now populates stale_worktrees; 102 tests green + cold-install 131. SH-6/SH-8 logic met. - -## SD-B2 — Prose tasks authored by orchestrator, not a TDD code agent · confidence: high -- **Point:** tasks 11,12,14,15,16 are prose (command markdown + catalog/routing). sdd's "fresh subagent per task" + tdd "RED first" target implementation CODE; there is no RED for a prose command, and backend-author is a test-first code agent. -- **Options:** (a) dispatch backend-author to write markdown (forces a fake TDD flow, poor fit); (b) orchestrator authors the prose, verified by the real gate — `check-plugin-refs.py` (cross-reference graph) + the body satisfying the spec criteria; CODE tasks (13) still get a fresh subagent. -- **SMARTS:** Maintainable/Reliable favor (b) — the ref-checker IS the command's verification gate; commands are conventionally authored by the orchestrator/skill-author, not backend-author. No loss of rigor. -- **Chosen:** (b). Strength: strong. - -## SH-TRIAGE-1 — root node_modules not gitignored · [NEEDS-TRIAGE] -- `.gitignore:30` ignores only `plugins/ca/tools/node_modules/`; repo-root `node_modules/` is untracked and unignored (present in `git status`). MUST be addressed before any `git add` at landing (task 18/19): add `/node_modules/` to `.gitignore` or stage explicit paths only. Not acted on mid-task. - -## PB-T13 — Task 13 ACCEPTED · confidence: high -- `_babysitlib.py` `babysit_config(env,root,arbiter_active=None)`: enabled from `CODEARBITER_BABYSIT` (on/true/1 case-insensitive, default off), two-layer gated by `arbiter_active(root)`; on_red from `CODEARBITER_BABYSIT_ONRED` (propose|branch, default propose). 12 tests green via `env=` injection (PB-5/PB-8/PB-10). Mirrors `CODEARBITER_PRUNE` reader. - -## PB-T14/15/16 — Tasks 14,15,16 ACCEPTED · confidence: high -- `watch.md` authored: detached `gh pr checks --watch` (server-side block, no poll/model-wake loop), `gh` auth precondition surfaced as a STOP, on-red depth `propose` (no tracked-file edit) | `branch` (unmergeable `spike/fix-*`), on-green notify + merge **offer** never `gh pr merge`, default-branch merge routes through the merge-to-default hard gate (PB-1,2,3,4,6,7). -- `pr.md` step 6 + Hard gate clause: auto-attach watcher ONLY when `CODEARBITER_BABYSIT` on, never enable the flag for the user (PB-8,9; mirrors prune.md MUST-NOT-auto-enable). -- Catalog/routing wired: COMMANDS.md `/ca:watch` row, routing-table.md row, README full-catalog row + counts 32→34 (badge, summary, tree; verified 34 command files on disk). `check-plugin-refs.py` green. - -## PB-SLICE-C — Slice C Phase 4 quality review · confidence: high -- **Point:** does the babysitter introduce a committed executable security surface (gh subprocess / shell injection)? -- **Finding:** the only new executable code is `_babysitlib.py` — a pure env reader, zero subprocess/shell/eval (grep-confirmed). The `gh` calls live entirely in orchestrator-executed command prose, already guarded: auth precondition (STOP), no poll loop, no auto-merge, default-branch routes through the hard gate. -- **SMARTS:** Secure/Reliable — no committed code surface to review; the prose guards are the security controls and are present. Dispatching security-reviewer over markdown prose adds ceremony without signal (balanced posture). -- **Chosen:** no security-reviewer dispatch for Slice C. Strength: strong. - -## SH-TRIAGE-1-RESOLVED — root node_modules gitignored · confidence: high -- Added `/node_modules/` to `.gitignore` (with the existing `plugins/ca/tools/node_modules/`); also gitignored the transient `.codearbiter/sprint-active` sprint lock (never tracked, holds the active slug). `git status` confirms node_modules no longer surfaces. SH-TRIAGE-1 closed before staging. - -## SH-T17 — Task 17 ACCEPTED · confidence: high -- Version bumped 2.1.0-beta.3 → 2.1.0-beta.4 across plugin.json, README badge, and CHANGELOG (new dated `[2.1.0-beta.4] — 2026-06-13 — preview` section covering both features). Three version strings verified in agreement; `version-bump` CI guard satisfied (payload changed + version bumped). - -## SH-T18 — Task 18 ACCEPTED · confidence: high -- Full CI-parity sweep green locally: cold-install matrix (131 assertions), guard-logic matrix (62), ref-graph intact, all tracked JSON parse, py_compile on the 3 touched hooks, and the standup/babysit/session-start unittest suites (114 tests). No `plugins/ca/tools/**` change → farm typecheck/test/build leg not triggered (confirmed via `git status`); farm.js cannot be stale from this sprint. - -## SH-T19 — Task 19 LANDED (PR opened) · HARD gate: merge to default deferred to user -- Branch `sprint/session-hygiene` committed (19 files, +2332), pushed, PR **#46** opened against `main`: https://github.com/SUaDtL/codeArbiter/pull/46. H-03 commit guard enforced explicit per-path staging (no `git add -A`). Commit message corrected after a PowerShell-heredoc artifact (`@`) leaked into the Bash subject — amended via POSIX heredoc. -- **STOP here.** The merge-to-default hard gate is the user's call — the sprint does NOT merge. Squash-merge #46 when ready (or run `/ca:watch 46` to babysit its CI first). - ---- - -# Sprint complete — session-hygiene · 2026-06-13 - -All 19 tasks ACCEPTED. Two features shipped to PR #46, awaiting the user's merge. No low-confidence auto-decisions to review; one triage item (SH-TRIAGE-1, root node_modules) was resolved in-sprint. - ---- - -# Sprint: review-remediation · 2026-06-16 - -Spec `.codearbiter/specs/review-remediation.md`, plan `plans/review-remediation.md` — APPROVED at the Phase 1 gate. Premium backend. Closing the 6-pass review findings. Workstream A (enforcement hooks) is hard-gate-dense; each code-fix surfaces for user approval. - -## RR-A-DISCOVERY — pre-bash guard tests live in .github/scripts · confidence: high -- **Point:** the review's Pass C/D cited `test_hook_guards.py` for the `pre-bash.py` H-01/H-05 guards; no such file exists under `plugins/ca/hooks/tests/`. -- **Finding:** the file is `.github/scripts/test_hook_guards.py` (the guard-logic regression matrix, run in CI). `pre-bash.py` IS tested there, just not in the hooks unittest dir. Pass D's empirical citations were correct; only the path label was off. -- **Chosen:** add the new Workstream-A failing cases to `.github/scripts/test_hook_guards.py` (the existing matrix) rather than create a parallel test file. Strength: strong. - -## RR-A1-A3-RED — failing cases added for both confirmed bypasses · confidence: high -- **Point:** test shape for the `--all`/`--mirror` push bypass (H-01) and the `>|` force-clobber bypass (H-05/H-11). -- **Chosen:** `--all`/`--mirror` blocked unconditionally on push (they write protected refs from any branch; matches the file's CLOSED-ambiguity philosophy — the hook can't cheaply enumerate refs). `>|` added to both the audit-log and ADR-redirect block lists; the no-space `>|path` form already blocks (kept as a regression case). Matrix run: 71 assertions, 8 failed — exactly the genuine bypasses (RED confirmed). Strength: strong. -- **NEXT: HARD GATE** — the `pre-bash.py` fix (tasks 2 & 4) is a trust-boundary change; halting for user approval before applying. - -## RR-A2-A4-GREEN — both confirmed bypasses closed · HARD gate: user-approved -- **Applied (user-approved trust-boundary change):** `pre-bash.py` — new `PUSH_ALL_RE` blocks `--all`/`--mirror` on push (H-01); `LOG_TRUNC_RE` and `DECISIONS_REDIRECT_RE` gained an optional `\|?` to catch the `>|` force-clobber (H-05/H-11). `exec 3>` left as documented residual (spec out-of-scope). -- **Verification:** `py_compile` OK; guard matrix 71 assertions, 0 failed (was 8 failed at RED). Plain `>`/`>>` and bare-push allow cases unchanged. Tasks 1–4 ACCEPTED. - -## RR-A5-A10-GREEN — Workstream A complete · HARD gate: user-approved (batch) -- **Discovery:** `pre-write.py`'s H-05/H-11 Write guards had NO direct test (`test_write.py` covers the pruner engine, not the hook) — created `plugins/ca/hooks/tests/test_pre_write.py` to cover them. The review's `test_hook_guards.py` path was `.github/scripts/`, not the hooks tests dir. -- **Applied (user-approved trust-boundary batch):** - - H-11 regex broadened `decisions/[0-9]+-.+\.md` → `decisions/.+\.md` in `pre-write.py` + `pre-edit.py` (catches non-numbered drafts + nested paths). - - `sprint-log.md` added to the H-05 append-only set in all three hooks (shared `LOG_NAMES` in `pre-bash.py`; regex alternation in pre-write/pre-edit). `>>` append still allowed. - - `hooks.json` Edit matcher → `Edit|MultiEdit`; `pre-edit.py` now reads `tool_name` and blocks a MultiEdit on an append-only log (can't express a verified pure append). **NotebookEdit deliberately out of scope** — it only targets `.ipynb`, so it cannot reach a `.log` audit file or `.md` ADR (logged in spec). - - `pre-bash.py`: case-insensitive protected-branch check (`is_protected_branch`) + `head_on_protected_tip` so a commit in a detached HEAD at main/master's tip blocks. -- **SMARTS (task 9 MultiEdit posture):** block-outright vs shape-aware append-parsing — chose block-outright (Secure + Simple; MultiEdit on an audit log is never a legitimate pattern, the sanctioned append is a single Edit/`>>`). Strength: strong. User approved the batch. -- **Verification:** py_compile OK; hooks.json valid; guard matrix 79/0 (was 6-red); pre-edit+pre-write 27/0; **full hook suite 404 tests OK**; cold-install 134/0; ref-graph intact. Tasks 5–10 ACCEPTED. Workstream A complete. - -## RR-B-GREEN — ADR-format unified (#3) · confidence: high -- **Point:** decompose authored ADRs in a non-frontmatter `**Status:**` format that `/adr-status` (which reads YAML `status:`) cannot parse. Canonical chosen at the Phase 1 gate = decision-lifecycle's YAML `NNNN-` format. -- **Applied:** new shared `skills/decision-lifecycle/references/adr-template.md` (single source of truth, documents the draft→accepted lifecycle). `decision-lifecycle` and `decompose` both now point at it; decompose authors `status: draft` and promotes to `status: accepted` at Phase 5 (frontmatter field + `## Status` mirror). Reconciled all `Status: DRAFT/Accepted` prose in decompose to the frontmatter `status:` field. Filenames were already compatible (both 4-digit `NNNN-`); the real fork was frontmatter + missing Alternatives/Risks sections. -- **Verification:** ref-graph intact (new reference resolves from both skills). Tasks 11–13 ACCEPTED. - -## RR-C-GREEN — skill-overlap dedup complete (tasks 14–20) · confidence: high (19 = moderate) -- **C-14 (#4):** writing-plans `--farm` Phase-4 extension → `skills/writing-plans/references/farm-plan.md`; body now a pointer (mirrors farm-dispatch.md). -- **C-15 (#5):** fresh-run verification principle → `includes/fresh-verification.md`; subagent-driven-development P5 + commit-gate P5 both reference it, each keeping its own target (per-task command vs spec acceptance). -- **C-16:** the verbatim "never-scaffold cut docs" list → `includes/cut-docs.md`, referenced from decompose + context-creation (3 sites). **Scoping decision:** the broader Phase-6 lock-mechanics were left per-skill — decompose's required-file set (decisions/, plans/01-03, .decompose-draft cleanup) genuinely differs from context-creation's, so a shared lock-contract would flatten real differences and risk parity. Took the safe verbatim dedup only. -- **C-17:** maturity→coverage table → `includes/maturity-coverage.md`; tdd P5 + refactor P2 reference it. -- **C-18:** crypto/secret "On pass — record the gate" block → `includes/security-gate-record.md`; crypto (H-09b) + secret (H-10b) reference it, hook ids preserved. -- **C-19 (decision-lifecycle↔decision-variance) — SMARTS, confidence moderate:** options were (a) full merge over a shared decision-log reference vs (b) clarify the boundary. Chose **(b)**: the only genuinely-shared artifact (the decision-log format) is *already* single-sourced in `smarts.md`; the remaining overlaps (supersession, challenger-dispatch, same-level-conflict) are principle restatements governing *different* artifacts (ADR files vs log entries vs variance arbitration). A merge of two governance skills = real parity risk for marginal gain. Added a boundary note (authoring vs arbitration) instead. User flagged this task as "split out if hairy" — this is that split. **Flagged for user review.** -- **C-20:** finishing-a-development-branch open-PR path no longer says "owned by /pr; route there" (circular — and wrong under /sprint where /pr never ran); it now *executes* the pr.md pipeline steps without re-invoking the command. -- **Verification:** ref-graph intact (6 new include/reference files resolve). Tasks 14–20 ACCEPTED. - -## RR-D-GREEN — catalog & docs hygiene (tasks 21–24) · confidence: high -- **D-21 (#8):** new-skill.md stripped of its inline 5-phase re-spec (which contradicted skill-author, used the banned word "trigger", and dropped the routing-integration gate) — now a faithful wrapper naming skill-author's phases without restating them. -- **D-22:** commands.md inline `## Catalog` table (stale, missing ~11 commands) deleted; it now renders from `COMMANDS.md` only, with a Hard-gate rule forbidding a second copy. -- **D-23:** INDEX.md `release` corrected two→three phases (Publish/Phase 3 was missing). `arbiter.md` gained a `## Hard gate` (write `DEV: exit`, remove marker, append-only — symmetric with dev.md). **Correction to the review:** the `/spike` routing-table row was NOT missing (Pass B3 was inaccurate) — it existed; I enriched it (names the spike skill + commit-gate exemption). -- **D-24:** folded the two dangling triage items into `open-tasks.md` (SH-TRIAGE-2 missing coding-standards.md; SD-02 farm host-normalization re-review — noting Pass D already confirmed it loopback-bounded). Annotated both `checkpoints/2026-06-{12,13}.md` with a superseded-note: their "0 ADRs / decisions absent" claims are stale (ADRs 0001-0004 landed in a365ee1). -- **Verification:** ref-graph + INDEX/COMMANDS catalog consistency intact. Tasks 21–24 ACCEPTED. - -## RR-E-GREEN — engine test-debt (tasks 25–27) · confidence: high -- **E-26:** new `plugins/ca/hooks/tests/test_security_pass.py` — 6 tests covering security-pass.py's previously-untested branches: no-.codearbiter exit-1, empty-digest write, untracked-file inclusion, MAX_UNTRACKED_BYTES skip, unborn-branch (ls-files fallback), and diff-HEAD added line. -- **E-25 (SMARTS, confidence high):** the Pass-D "self_heal false-positive on a growing transcript" is already prevented by the existing guard — a healthy mid-append's bad line is the FINAL line, so `end_off > len(backup)` (`_prunelib.py:881`) returns "tail differs" and does not heal. Chose a **characterization test** pinning that conservative behavior over a risky change to the crash-recovery path. Added `test_does_not_heal_growing_file_with_partial_final_line` to test_write.py. -- **E-27 DEFERRED (LOW/optional):** same-second backup-filename disambiguation. A clean fix collides with the lexicographic `entries[-1]`-is-newest assumption (a `-N` suffix sorts before `.jsonl`), and two prunes in one wall-second is very unlikely given min_growth gating. Not worth the risk in this sprint; left as a noted optional. -- **Verification:** full hook suite **411 tests OK** (+7), guard matrix 79/0, cold-install 134/0, ref-graph intact. Tasks 25–26 ACCEPTED; 27 deferred. - ---- - -# Sprint complete — review-remediation · 2026-06-16 - -28 of 29 tasks ACCEPTED (E-27 same-second backup naming DEFERRED, logged). Landed as 3 type-homogeneous commits on `sprint/review-remediation`, **PR #68** opened against `main` (https://github.com/arbiterForge/codeArbiter/pull/68) — **NOT merged**; the merge is the user's call (the `/sprint` hard gate). - -- `dcb0448` fix(hooks): close confirmed enforcement bypasses (9 files) -- `c1b61a7` refactor(skills): dedup overlap, unify ADR format, fix catalog/routing drift (22 files) -- `25215fa` chore(governance): review-remediation sprint artifacts + triage folding (7 files) - -**Verification at land:** full hook suite 411 tests OK · guard matrix 79/0 · cold-install 134/0 · ref-graph intact. - -**Auto-decisions flagged for review (per /sprint contract):** two moderate-confidence SMARTS calls — C-19 (lifecycle↔variance boundary-clarified, not merged) and the task-9 MultiEdit block-outright posture. Both logged above with rationale. **Two corrections to the original review** were recorded: pre-bash guards live in `.github/scripts/test_hook_guards.py` (not the hooks tests dir), and the `/spike` routing-table row was not actually missing. - -**Deferred (own decision):** review finding #6 (mechanical red-suite / commit-gate enforcement) — non-blocking note in `open-questions.md`. **Carried:** SH-TRIAGE-2 (missing coding-standards.md) and SD-02 (farm host-normalization re-review) folded into `open-tasks.md`. - ---- - -# Sprint — ux-conversion-trio (#82 + #84 + #83) · 2026-06-17 - -Spec + plan approved by the user at the Phase-1 gate. Branch `sprint/ux-conversion-trio` off `main`. - -## D-01 — execution model (SMARTS, confidence: moderate → flag for review) -- **Decision point:** subagent-driven-development prescribes one fresh author subagent per task. The 8 tasks are tightly-coupled prose edits sharing one marker scheme and one test file (`test_ux_conversion.py`). -- **Options weighed:** (a) fresh author per task — max anti-drift isolation, but fragments voice across coordinated copy and risks marker-scheme divergence on a shared test; (b) coordinate authoring in the orchestrator context, then enforce the load-bearing guarantee via independent fresh reviewers (spec-compliance + quality) + mechanical fresh-run verification (the structural test + check-plugin-refs). -- **SMARTS verdict:** Maintainable/Testable favor (b) for coherence of a shared marker scheme; the anti-drift guarantee is preserved by independent review + a mechanical test, so nothing is accepted on the author's own word. Scalable/Available/Reliable/Securable neutral (copy-only, no logic change). -- **Chosen:** (b). Strength: moderate. Flagged for morning review. - -## Execution — tasks T-01..T-08 (confidence: high) -- All six framework files edited test-first; structural test `test_ux_conversion.py` drove 22-red → green. One brittle assertion corrected (markdown bold `**exactly one**` split the literal phrase) — assertion fixed to component-presence, semantics unchanged (not a relaxed implementation pass). -- Wired into CI (`ci.yml`) + `tech-stack.md`. Ref graph intact. `security-controls.md` untouched; secret-handling/commit-gate edits confirmed purely additive copy (no gate-logic/MUST change). - -## Two-pass independent review (per D-01) -- **Spec-compliance:** 10/11 ACs COVERED; one real GAP — AC-5 ("tdd Phase 4 & 5") had a Stakes line on Phase 4 only, and the test did not guard Phase 5. -- **Copy-quality:** no BLOCK; 5 NITs — duplicated warm example, 4×-restated no-crawl gloss, "no-op" wording drift, a purple "under cover of" clause, a secret-handling run-on. All 5 applied. - -## D-02 — AC-5 Phase 5 stakes gap (SMARTS, confidence: high) -- **Decision point:** spec/plan say tdd "Phase 4 & 5" stakes; implementation + test covered only Phase 4. Resolve toward the approved spec or narrow scope? -- **Chosen:** honor the approved contract — added a Stakes line to tdd Phase 5 (Coverage) and a Phase-5 test assertion. Below-threshold coverage is the same "untested code ships" finding class; narrowing would weaken a user-approved AC. Strength: strong. - ---- - -# Sprint complete — ux-conversion-trio · 2026-06-17 - -8 of 8 tasks ACCEPTED. Landed as 3 type-homogeneous commits on `sprint/ux-conversion-trio`, **PR #88** opened against `main` (https://github.com/arbiterForge/codeArbiter/pull/88) — **NOT merged**; the merge is the user's call (the `/sprint` hard gate). - -- `4a32a31` feat(ux): reflect prevention back at the close and caught findings (7 files) -- `4255727` ci(ux): run test_ux_conversion.py in CI; list it in tech-stack (2 files) -- `f28603f` chore(sprint): ux-conversion-trio spec, plan, decision log (3 files) - -**Verification at land:** full Python suite green (hook-guards · cold-install · preview · ux-conversion) · ref graph intact · PR CI 7/7 green incl. version-bump gate. Two-pass review: spec-compliance 11/11 (after the AC-5 fix), copy-quality clean (5 NITs applied). - -**Auto-decisions flagged for review:** D-01 (execution model — coordinate-author + independent review, moderate) is the one low-confidence call. D-02 (AC-5 Phase-5 stakes) resolved high toward the approved spec. +# Sprint log — checkpoint-remediation-2026-06-12 + +Append-only. Every auto-decision logged with SMARTS verdict and confidence flag. +`low` entries = review these in the morning. + +--- + + + +--- + +# Sprint log — checkpoint-2026-06-13-remediation +Started 2026-06-13. Append-only. SMARTS-scored auto-decisions; `low` = review these. + +## SD-01 — Pre-flight checkpoint-artifact commit path · confidence: low +- **Point:** How to commit the outstanding checkpoint artifacts (2026-06-13.md, last-checkpoint, overrides.log) at task 0 without violating §3 "MUST NOT commit without commit-gate." +- **Options:** (a) direct git commit now; (b) leave staged, land via commit-gate as a separate logical `chore(checkpoint)` commit during the landing phase. +- **SMARTS:** Reliable/Securable favor (b) — honors the §3 hard rule, single gated commit path, no precedent of bypassing commit-gate for "just docs." Maintainable neutral. +- **Chosen:** (b). Branch + farm.js revert done at task 0; artifacts carried uncommitted into the gated landing. Strength: moderate. + +## SD-02 — Apply LOW URL-parse hardening to assertSecureBaseUrl · confidence: low +- **Point:** security-reviewer PASS on Workstream B with one LOW (optional): regex-based scheme/loopback check vs new URL() parsing. "No present vulnerability," remediation provided. +- **Options:** (a) accept PASS as-is, log LOW as deferred; (b) apply the URL-parse hardening now within the same guard function. +- **SMARTS:** Securable/Reliable favor (b) — the guard protects a Bearer token; parse-don't-regex eliminates userinfo/normalization edge cases the reviewer named; reviewer supplied exact code; change is in-scope (same function, workstream B) and small. Maintainable favors (b): URL parsing reads clearer than a hand-anchored regex. +- **Chosen:** (b). Strength: moderate. Re-verify: 62+ vitest green incl. localhost.evil rejection; rebuild farm.js. + +## SD-02-note — correction to SD-02 premise +- The hardening agent found the OLD anchored regex already rejected userinfo (`http://localhost@evil`, `http://user:pass@127.0.0.1`) — so there was no userinfo red→green; the regex closed that gap by anchoring. +- Genuine delta of new URL(): HOST NORMALIZATION. `new URL("http://①27.0.0.1")` normalizes to 127.0.0.1 and is now ACCEPTED where the regex REJECTED it. Result is still loopback, so no cleartext-to-remote leak, but it is a behavioral loosening. Author left it unlocked, flagged [NEEDS-TRIAGE]. +- Action: re-run security-reviewer on the URL-parse version, focused on the normalization question, before surfacing B at the hard gate. SD-02 still stands (URL parsing is cleaner + explicitly rejects userinfo on the http path), but verification is warranted. + +## SD-03 — Decision-log Status field for proposed ADRs · confidence: low +- **Point:** smarts.md decision-log Status enum is {accepted|superseded|deferred}; user chose ADR status `proposed` (declined the "mark accepted" option). No enum value matches. +- **Options:** (a) force Status: accepted (contradicts the user's explicit proposed choice); (b) use Status: proposed per the decision-lifecycle ADR lifecycle, note the reconciliation. +- **SMARTS:** Reliable favors (b) — fidelity to the user's explicit decision over enum-strictness; the conflict is surfaced in the log header (not silently reconciled, per ORCHESTRATOR §0). +- **Chosen:** (b). Strength: moderate. Surfaced to user in the gate message. + +## SD-04 — Version bump for the landing commit · confidence: low +- **Point:** CI `version-bump` job fails a payload change (`plugins/ca/**`) on an already-published tagged version. farm.ts/farm.js changed under the published `2.1.0-beta.2`; landing requires a bump. +- **Options:** (a) bump patch-preview `2.1.0-beta.2` → `2.1.0-beta.3`; (b) bump minor `2.1.0` → `2.2.0`. +- **SMARTS:** Maintainable/Reliable favor (a) — the change is a remediation + governance set within the in-flight beta line, not a new feature surface; beta preview increments are the established cadence (beta.1 → beta.2 → beta.3). Bumping the minor would imply a finished feature the sprint did not add. +- **Chosen:** (a). plugin.json + README version badge + CHANGELOG `[2.1.0-beta.3] — 2026-06-13`. Strength: moderate. + +## SD-05 — PR base branch · confidence: low +- **Point:** sprint branch is 6 commits off `checkpoint-remediation-2026-06-12` (itself 5 commits ahead of and unmerged to `main`). PR base: `main` or the parent branch? +- **Options:** (a) base on `main` — PR bundles the parent's 5 unmerged commits with this sprint's 6; (b) base on `checkpoint-remediation-2026-06-12` — stacked PR showing exactly the 6 sprint commits. +- **SMARTS:** Reviewable/Maintainable favor (b) — a stacked PR isolates this checkpoint's remediation for review; bundling the parent's prior work muddies the diff and conflates two checkpoints. Merge order stays the user's. +- **Chosen:** (b). Strength: moderate. PR opened, NOT merged (merge-to-default is the user's hard gate). + +--- + +# Sprint log — session-hygiene +Started 2026-06-13. Append-only. SMARTS-scored auto-decisions; `low` = review these. +Posture: BALANCED (tie-break toward cheap hardening, then ORCHESTRATOR §2). +Backend: premium subagents (no --farm, per user invocation) — confidence high. + +## SH-T1 — Task 1 ACCEPTED · confidence: high +- 6 pure parsers in `_standuplib.py`; 38 unittests green (fresh-run verified by orchestrator); py_compile clean; stdlib-only; no os.environ use. Spec-compliance + verification PASS. Prune rule chosen: `: gone]` upstream marker, current/default excluded. + +## SD-A1 — Merge plan tasks 2+3 into one author dispatch · confidence: high +- **Point:** plan split "RED failing test" (task 2) from "impl" (task 3); the tdd skill performs RED→GREEN within a single author. +- **Options:** (a) two subagent dispatches (test-only, then impl); (b) one author doing RED→GREEN per tdd's natural flow. +- **SMARTS:** Maintainable/Efficient favor (b) — a subagent that writes only a failing test then exits wastes a context; tdd is designed as one RED→GREEN loop. No loss of rigor: orchestrator still fresh-verifies. +- **Chosen:** (b). Tasks 2 & 3 both marked ACCEPTED on the single dispatch's verification. Strength: strong. + +## SH-T4 — Task 4 ACCEPTED · confidence: high +- `briefing_mode(marker_present, actionable)` three-mode contract (full/offer/none); offer is exactly one line; clean-repo later sessions silent. 64 tests green (49 standup + 15 session_start), fresh-verified. SH-2 met. + +## SD-A2 — Combine tasks 6+8 and 5+7 · confidence: moderate +- **Point:** tasks 5,6,7,8 all modify session-start.py; 8 (content assembly) consumes 6's git-invocation layer; 5 (dormancy) and 7 (read-only proof) are guard-tests over the assembled behavior. +- **Options:** (a) four separate dispatches; (b) combine 6+8 (cohesive git layer + content), then 5+7 (cohesive guard tests). +- **SMARTS:** Efficient/Maintainable favor (b) — content assembly and the git layer it depends on belong in one context; splitting forces the 8-author to re-derive 6's subprocess layer. Reliable neutral (orchestrator fresh-verifies each). Tie broken toward balanced/efficient. +- **Chosen:** (b). Strength: moderate. Order: 6+8 → 5+7 → task 9 sweep. + +## SH-TRIAGE-2 — coding-standards.md missing · [NEEDS-TRIAGE] +- Every backend-author reports `.codearbiter/coding-standards.md` is named as required reading but absent. Framework gap, not in this sprint's scope. Authors fell back to tech-stack.md + house style. Flag for a future chore. + +## SH-SLICE-A — Slice A (tasks 1–9) ACCEPTED + Phase 4 PASS · confidence: high +- 83 unit tests + cold-install 131 assertions green (fresh-verified). security-reviewer over combined diff: PASS, 0 CRIT/HIGH/MED, 1 informational LOW (deferred-fetch-freshness, by design, no action). Subprocess layer: argv-list only (no shell), static git verbs, DEVNULL-detached fetch, 2.5s read timeout, marker-only write, read-only proven. SH-1..5 met. + +## SD-B1 — stale-worktree classification rule (carried from task 8) · confidence: low +- **Point:** task 8 deferred `stale_worktrees` population (no rule defined); briefing + /ca:standup worktree-cleanup (task 10) both need it. +- **Options:** (a) stale = non-main worktree whose branch is gone/merged on remote OR whose path no longer exists on disk; (b) only path-missing (git's own `worktree prune` definition); (c) only branch-gone. +- **SMARTS:** Reliable/Securable favor (a) — broadest *candidate* set is safe because /ca:standup only ever LISTS and removes per explicit confirm; never auto-removes. Maintainable neutral. Posture balanced → include both signals so the briefing surfaces real cruft, with removal always user-gated. +- **Chosen:** (a), as a PURE classifier over parsed worktrees + the gone/merged branch set. Strength: moderate. confidence low (a rule choice the user may want to narrow). + +## SH-T10 — Task 10 ACCEPTED · confidence: high +- `ff_pull_eligible`, `stale_worktree_candidates`, SH-8 exclusion confirmed; assembly now populates stale_worktrees; 102 tests green + cold-install 131. SH-6/SH-8 logic met. + +## SD-B2 — Prose tasks authored by orchestrator, not a TDD code agent · confidence: high +- **Point:** tasks 11,12,14,15,16 are prose (command markdown + catalog/routing). sdd's "fresh subagent per task" + tdd "RED first" target implementation CODE; there is no RED for a prose command, and backend-author is a test-first code agent. +- **Options:** (a) dispatch backend-author to write markdown (forces a fake TDD flow, poor fit); (b) orchestrator authors the prose, verified by the real gate — `check-plugin-refs.py` (cross-reference graph) + the body satisfying the spec criteria; CODE tasks (13) still get a fresh subagent. +- **SMARTS:** Maintainable/Reliable favor (b) — the ref-checker IS the command's verification gate; commands are conventionally authored by the orchestrator/skill-author, not backend-author. No loss of rigor. +- **Chosen:** (b). Strength: strong. + +## SH-TRIAGE-1 — root node_modules not gitignored · [NEEDS-TRIAGE] +- `.gitignore:30` ignores only `plugins/ca/tools/node_modules/`; repo-root `node_modules/` is untracked and unignored (present in `git status`). MUST be addressed before any `git add` at landing (task 18/19): add `/node_modules/` to `.gitignore` or stage explicit paths only. Not acted on mid-task. + +## PB-T13 — Task 13 ACCEPTED · confidence: high +- `_babysitlib.py` `babysit_config(env,root,arbiter_active=None)`: enabled from `CODEARBITER_BABYSIT` (on/true/1 case-insensitive, default off), two-layer gated by `arbiter_active(root)`; on_red from `CODEARBITER_BABYSIT_ONRED` (propose|branch, default propose). 12 tests green via `env=` injection (PB-5/PB-8/PB-10). Mirrors `CODEARBITER_PRUNE` reader. + +## PB-T14/15/16 — Tasks 14,15,16 ACCEPTED · confidence: high +- `watch.md` authored: detached `gh pr checks --watch` (server-side block, no poll/model-wake loop), `gh` auth precondition surfaced as a STOP, on-red depth `propose` (no tracked-file edit) | `branch` (unmergeable `spike/fix-*`), on-green notify + merge **offer** never `gh pr merge`, default-branch merge routes through the merge-to-default hard gate (PB-1,2,3,4,6,7). +- `pr.md` step 6 + Hard gate clause: auto-attach watcher ONLY when `CODEARBITER_BABYSIT` on, never enable the flag for the user (PB-8,9; mirrors prune.md MUST-NOT-auto-enable). +- Catalog/routing wired: COMMANDS.md `/ca:watch` row, routing-table.md row, README full-catalog row + counts 32→34 (badge, summary, tree; verified 34 command files on disk). `check-plugin-refs.py` green. + +## PB-SLICE-C — Slice C Phase 4 quality review · confidence: high +- **Point:** does the babysitter introduce a committed executable security surface (gh subprocess / shell injection)? +- **Finding:** the only new executable code is `_babysitlib.py` — a pure env reader, zero subprocess/shell/eval (grep-confirmed). The `gh` calls live entirely in orchestrator-executed command prose, already guarded: auth precondition (STOP), no poll loop, no auto-merge, default-branch routes through the hard gate. +- **SMARTS:** Secure/Reliable — no committed code surface to review; the prose guards are the security controls and are present. Dispatching security-reviewer over markdown prose adds ceremony without signal (balanced posture). +- **Chosen:** no security-reviewer dispatch for Slice C. Strength: strong. + +## SH-TRIAGE-1-RESOLVED — root node_modules gitignored · confidence: high +- Added `/node_modules/` to `.gitignore` (with the existing `plugins/ca/tools/node_modules/`); also gitignored the transient `.codearbiter/sprint-active` sprint lock (never tracked, holds the active slug). `git status` confirms node_modules no longer surfaces. SH-TRIAGE-1 closed before staging. + +## SH-T17 — Task 17 ACCEPTED · confidence: high +- Version bumped 2.1.0-beta.3 → 2.1.0-beta.4 across plugin.json, README badge, and CHANGELOG (new dated `[2.1.0-beta.4] — 2026-06-13 — preview` section covering both features). Three version strings verified in agreement; `version-bump` CI guard satisfied (payload changed + version bumped). + +## SH-T18 — Task 18 ACCEPTED · confidence: high +- Full CI-parity sweep green locally: cold-install matrix (131 assertions), guard-logic matrix (62), ref-graph intact, all tracked JSON parse, py_compile on the 3 touched hooks, and the standup/babysit/session-start unittest suites (114 tests). No `plugins/ca/tools/**` change → farm typecheck/test/build leg not triggered (confirmed via `git status`); farm.js cannot be stale from this sprint. + +## SH-T19 — Task 19 LANDED (PR opened) · HARD gate: merge to default deferred to user +- Branch `sprint/session-hygiene` committed (19 files, +2332), pushed, PR **#46** opened against `main`: https://github.com/SUaDtL/codeArbiter/pull/46. H-03 commit guard enforced explicit per-path staging (no `git add -A`). Commit message corrected after a PowerShell-heredoc artifact (`@`) leaked into the Bash subject — amended via POSIX heredoc. +- **STOP here.** The merge-to-default hard gate is the user's call — the sprint does NOT merge. Squash-merge #46 when ready (or run `/ca:watch 46` to babysit its CI first). + +--- + +# Sprint complete — session-hygiene · 2026-06-13 + +All 19 tasks ACCEPTED. Two features shipped to PR #46, awaiting the user's merge. No low-confidence auto-decisions to review; one triage item (SH-TRIAGE-1, root node_modules) was resolved in-sprint. + +--- + +# Sprint: review-remediation · 2026-06-16 + +Spec `.codearbiter/specs/review-remediation.md`, plan `plans/review-remediation.md` — APPROVED at the Phase 1 gate. Premium backend. Closing the 6-pass review findings. Workstream A (enforcement hooks) is hard-gate-dense; each code-fix surfaces for user approval. + +## RR-A-DISCOVERY — pre-bash guard tests live in .github/scripts · confidence: high +- **Point:** the review's Pass C/D cited `test_hook_guards.py` for the `pre-bash.py` H-01/H-05 guards; no such file exists under `plugins/ca/hooks/tests/`. +- **Finding:** the file is `.github/scripts/test_hook_guards.py` (the guard-logic regression matrix, run in CI). `pre-bash.py` IS tested there, just not in the hooks unittest dir. Pass D's empirical citations were correct; only the path label was off. +- **Chosen:** add the new Workstream-A failing cases to `.github/scripts/test_hook_guards.py` (the existing matrix) rather than create a parallel test file. Strength: strong. + +## RR-A1-A3-RED — failing cases added for both confirmed bypasses · confidence: high +- **Point:** test shape for the `--all`/`--mirror` push bypass (H-01) and the `>|` force-clobber bypass (H-05/H-11). +- **Chosen:** `--all`/`--mirror` blocked unconditionally on push (they write protected refs from any branch; matches the file's CLOSED-ambiguity philosophy — the hook can't cheaply enumerate refs). `>|` added to both the audit-log and ADR-redirect block lists; the no-space `>|path` form already blocks (kept as a regression case). Matrix run: 71 assertions, 8 failed — exactly the genuine bypasses (RED confirmed). Strength: strong. +- **NEXT: HARD GATE** — the `pre-bash.py` fix (tasks 2 & 4) is a trust-boundary change; halting for user approval before applying. + +## RR-A2-A4-GREEN — both confirmed bypasses closed · HARD gate: user-approved +- **Applied (user-approved trust-boundary change):** `pre-bash.py` — new `PUSH_ALL_RE` blocks `--all`/`--mirror` on push (H-01); `LOG_TRUNC_RE` and `DECISIONS_REDIRECT_RE` gained an optional `\|?` to catch the `>|` force-clobber (H-05/H-11). `exec 3>` left as documented residual (spec out-of-scope). +- **Verification:** `py_compile` OK; guard matrix 71 assertions, 0 failed (was 8 failed at RED). Plain `>`/`>>` and bare-push allow cases unchanged. Tasks 1–4 ACCEPTED. + +## RR-A5-A10-GREEN — Workstream A complete · HARD gate: user-approved (batch) +- **Discovery:** `pre-write.py`'s H-05/H-11 Write guards had NO direct test (`test_write.py` covers the pruner engine, not the hook) — created `plugins/ca/hooks/tests/test_pre_write.py` to cover them. The review's `test_hook_guards.py` path was `.github/scripts/`, not the hooks tests dir. +- **Applied (user-approved trust-boundary batch):** + - H-11 regex broadened `decisions/[0-9]+-.+\.md` → `decisions/.+\.md` in `pre-write.py` + `pre-edit.py` (catches non-numbered drafts + nested paths). + - `sprint-log.md` added to the H-05 append-only set in all three hooks (shared `LOG_NAMES` in `pre-bash.py`; regex alternation in pre-write/pre-edit). `>>` append still allowed. + - `hooks.json` Edit matcher → `Edit|MultiEdit`; `pre-edit.py` now reads `tool_name` and blocks a MultiEdit on an append-only log (can't express a verified pure append). **NotebookEdit deliberately out of scope** — it only targets `.ipynb`, so it cannot reach a `.log` audit file or `.md` ADR (logged in spec). + - `pre-bash.py`: case-insensitive protected-branch check (`is_protected_branch`) + `head_on_protected_tip` so a commit in a detached HEAD at main/master's tip blocks. +- **SMARTS (task 9 MultiEdit posture):** block-outright vs shape-aware append-parsing — chose block-outright (Secure + Simple; MultiEdit on an audit log is never a legitimate pattern, the sanctioned append is a single Edit/`>>`). Strength: strong. User approved the batch. +- **Verification:** py_compile OK; hooks.json valid; guard matrix 79/0 (was 6-red); pre-edit+pre-write 27/0; **full hook suite 404 tests OK**; cold-install 134/0; ref-graph intact. Tasks 5–10 ACCEPTED. Workstream A complete. + +## RR-B-GREEN — ADR-format unified (#3) · confidence: high +- **Point:** decompose authored ADRs in a non-frontmatter `**Status:**` format that `/adr-status` (which reads YAML `status:`) cannot parse. Canonical chosen at the Phase 1 gate = decision-lifecycle's YAML `NNNN-` format. +- **Applied:** new shared `skills/decision-lifecycle/references/adr-template.md` (single source of truth, documents the draft→accepted lifecycle). `decision-lifecycle` and `decompose` both now point at it; decompose authors `status: draft` and promotes to `status: accepted` at Phase 5 (frontmatter field + `## Status` mirror). Reconciled all `Status: DRAFT/Accepted` prose in decompose to the frontmatter `status:` field. Filenames were already compatible (both 4-digit `NNNN-`); the real fork was frontmatter + missing Alternatives/Risks sections. +- **Verification:** ref-graph intact (new reference resolves from both skills). Tasks 11–13 ACCEPTED. + +## RR-C-GREEN — skill-overlap dedup complete (tasks 14–20) · confidence: high (19 = moderate) +- **C-14 (#4):** writing-plans `--farm` Phase-4 extension → `skills/writing-plans/references/farm-plan.md`; body now a pointer (mirrors farm-dispatch.md). +- **C-15 (#5):** fresh-run verification principle → `includes/fresh-verification.md`; subagent-driven-development P5 + commit-gate P5 both reference it, each keeping its own target (per-task command vs spec acceptance). +- **C-16:** the verbatim "never-scaffold cut docs" list → `includes/cut-docs.md`, referenced from decompose + context-creation (3 sites). **Scoping decision:** the broader Phase-6 lock-mechanics were left per-skill — decompose's required-file set (decisions/, plans/01-03, .decompose-draft cleanup) genuinely differs from context-creation's, so a shared lock-contract would flatten real differences and risk parity. Took the safe verbatim dedup only. +- **C-17:** maturity→coverage table → `includes/maturity-coverage.md`; tdd P5 + refactor P2 reference it. +- **C-18:** crypto/secret "On pass — record the gate" block → `includes/security-gate-record.md`; crypto (H-09b) + secret (H-10b) reference it, hook ids preserved. +- **C-19 (decision-lifecycle↔decision-variance) — SMARTS, confidence moderate:** options were (a) full merge over a shared decision-log reference vs (b) clarify the boundary. Chose **(b)**: the only genuinely-shared artifact (the decision-log format) is *already* single-sourced in `smarts.md`; the remaining overlaps (supersession, challenger-dispatch, same-level-conflict) are principle restatements governing *different* artifacts (ADR files vs log entries vs variance arbitration). A merge of two governance skills = real parity risk for marginal gain. Added a boundary note (authoring vs arbitration) instead. User flagged this task as "split out if hairy" — this is that split. **Flagged for user review.** +- **C-20:** finishing-a-development-branch open-PR path no longer says "owned by /pr; route there" (circular — and wrong under /sprint where /pr never ran); it now *executes* the pr.md pipeline steps without re-invoking the command. +- **Verification:** ref-graph intact (6 new include/reference files resolve). Tasks 14–20 ACCEPTED. + +## RR-D-GREEN — catalog & docs hygiene (tasks 21–24) · confidence: high +- **D-21 (#8):** new-skill.md stripped of its inline 5-phase re-spec (which contradicted skill-author, used the banned word "trigger", and dropped the routing-integration gate) — now a faithful wrapper naming skill-author's phases without restating them. +- **D-22:** commands.md inline `## Catalog` table (stale, missing ~11 commands) deleted; it now renders from `COMMANDS.md` only, with a Hard-gate rule forbidding a second copy. +- **D-23:** INDEX.md `release` corrected two→three phases (Publish/Phase 3 was missing). `arbiter.md` gained a `## Hard gate` (write `DEV: exit`, remove marker, append-only — symmetric with dev.md). **Correction to the review:** the `/spike` routing-table row was NOT missing (Pass B3 was inaccurate) — it existed; I enriched it (names the spike skill + commit-gate exemption). +- **D-24:** folded the two dangling triage items into `open-tasks.md` (SH-TRIAGE-2 missing coding-standards.md; SD-02 farm host-normalization re-review — noting Pass D already confirmed it loopback-bounded). Annotated both `checkpoints/2026-06-{12,13}.md` with a superseded-note: their "0 ADRs / decisions absent" claims are stale (ADRs 0001-0004 landed in a365ee1). +- **Verification:** ref-graph + INDEX/COMMANDS catalog consistency intact. Tasks 21–24 ACCEPTED. + +## RR-E-GREEN — engine test-debt (tasks 25–27) · confidence: high +- **E-26:** new `plugins/ca/hooks/tests/test_security_pass.py` — 6 tests covering security-pass.py's previously-untested branches: no-.codearbiter exit-1, empty-digest write, untracked-file inclusion, MAX_UNTRACKED_BYTES skip, unborn-branch (ls-files fallback), and diff-HEAD added line. +- **E-25 (SMARTS, confidence high):** the Pass-D "self_heal false-positive on a growing transcript" is already prevented by the existing guard — a healthy mid-append's bad line is the FINAL line, so `end_off > len(backup)` (`_prunelib.py:881`) returns "tail differs" and does not heal. Chose a **characterization test** pinning that conservative behavior over a risky change to the crash-recovery path. Added `test_does_not_heal_growing_file_with_partial_final_line` to test_write.py. +- **E-27 DEFERRED (LOW/optional):** same-second backup-filename disambiguation. A clean fix collides with the lexicographic `entries[-1]`-is-newest assumption (a `-N` suffix sorts before `.jsonl`), and two prunes in one wall-second is very unlikely given min_growth gating. Not worth the risk in this sprint; left as a noted optional. +- **Verification:** full hook suite **411 tests OK** (+7), guard matrix 79/0, cold-install 134/0, ref-graph intact. Tasks 25–26 ACCEPTED; 27 deferred. + +--- + +# Sprint complete — review-remediation · 2026-06-16 + +28 of 29 tasks ACCEPTED (E-27 same-second backup naming DEFERRED, logged). Landed as 3 type-homogeneous commits on `sprint/review-remediation`, **PR #68** opened against `main` (https://github.com/arbiterForge/codeArbiter/pull/68) — **NOT merged**; the merge is the user's call (the `/sprint` hard gate). + +- `dcb0448` fix(hooks): close confirmed enforcement bypasses (9 files) +- `c1b61a7` refactor(skills): dedup overlap, unify ADR format, fix catalog/routing drift (22 files) +- `25215fa` chore(governance): review-remediation sprint artifacts + triage folding (7 files) + +**Verification at land:** full hook suite 411 tests OK · guard matrix 79/0 · cold-install 134/0 · ref-graph intact. + +**Auto-decisions flagged for review (per /sprint contract):** two moderate-confidence SMARTS calls — C-19 (lifecycle↔variance boundary-clarified, not merged) and the task-9 MultiEdit block-outright posture. Both logged above with rationale. **Two corrections to the original review** were recorded: pre-bash guards live in `.github/scripts/test_hook_guards.py` (not the hooks tests dir), and the `/spike` routing-table row was not actually missing. + +**Deferred (own decision):** review finding #6 (mechanical red-suite / commit-gate enforcement) — non-blocking note in `open-questions.md`. **Carried:** SH-TRIAGE-2 (missing coding-standards.md) and SD-02 (farm host-normalization re-review) folded into `open-tasks.md`. + +--- + +# Sprint — ux-conversion-trio (#82 + #84 + #83) · 2026-06-17 + +Spec + plan approved by the user at the Phase-1 gate. Branch `sprint/ux-conversion-trio` off `main`. + +## D-01 — execution model (SMARTS, confidence: moderate → flag for review) +- **Decision point:** subagent-driven-development prescribes one fresh author subagent per task. The 8 tasks are tightly-coupled prose edits sharing one marker scheme and one test file (`test_ux_conversion.py`). +- **Options weighed:** (a) fresh author per task — max anti-drift isolation, but fragments voice across coordinated copy and risks marker-scheme divergence on a shared test; (b) coordinate authoring in the orchestrator context, then enforce the load-bearing guarantee via independent fresh reviewers (spec-compliance + quality) + mechanical fresh-run verification (the structural test + check-plugin-refs). +- **SMARTS verdict:** Maintainable/Testable favor (b) for coherence of a shared marker scheme; the anti-drift guarantee is preserved by independent review + a mechanical test, so nothing is accepted on the author's own word. Scalable/Available/Reliable/Securable neutral (copy-only, no logic change). +- **Chosen:** (b). Strength: moderate. Flagged for morning review. + +## Execution — tasks T-01..T-08 (confidence: high) +- All six framework files edited test-first; structural test `test_ux_conversion.py` drove 22-red → green. One brittle assertion corrected (markdown bold `**exactly one**` split the literal phrase) — assertion fixed to component-presence, semantics unchanged (not a relaxed implementation pass). +- Wired into CI (`ci.yml`) + `tech-stack.md`. Ref graph intact. `security-controls.md` untouched; secret-handling/commit-gate edits confirmed purely additive copy (no gate-logic/MUST change). + +## Two-pass independent review (per D-01) +- **Spec-compliance:** 10/11 ACs COVERED; one real GAP — AC-5 ("tdd Phase 4 & 5") had a Stakes line on Phase 4 only, and the test did not guard Phase 5. +- **Copy-quality:** no BLOCK; 5 NITs — duplicated warm example, 4×-restated no-crawl gloss, "no-op" wording drift, a purple "under cover of" clause, a secret-handling run-on. All 5 applied. + +## D-02 — AC-5 Phase 5 stakes gap (SMARTS, confidence: high) +- **Decision point:** spec/plan say tdd "Phase 4 & 5" stakes; implementation + test covered only Phase 4. Resolve toward the approved spec or narrow scope? +- **Chosen:** honor the approved contract — added a Stakes line to tdd Phase 5 (Coverage) and a Phase-5 test assertion. Below-threshold coverage is the same "untested code ships" finding class; narrowing would weaken a user-approved AC. Strength: strong. + +--- + +# Sprint complete — ux-conversion-trio · 2026-06-17 + +8 of 8 tasks ACCEPTED. Landed as 3 type-homogeneous commits on `sprint/ux-conversion-trio`, **PR #88** opened against `main` (https://github.com/arbiterForge/codeArbiter/pull/88) — **NOT merged**; the merge is the user's call (the `/sprint` hard gate). + +- `4a32a31` feat(ux): reflect prevention back at the close and caught findings (7 files) +- `4255727` ci(ux): run test_ux_conversion.py in CI; list it in tech-stack (2 files) +- `f28603f` chore(sprint): ux-conversion-trio spec, plan, decision log (3 files) + +**Verification at land:** full Python suite green (hook-guards · cold-install · preview · ux-conversion) · ref graph intact · PR CI 7/7 green incl. version-bump gate. Two-pass review: spec-compliance 11/11 (after the AC-5 fix), copy-quality clean (5 NITs applied). + +**Auto-decisions flagged for review:** D-01 (execution model — coordinate-author + independent review, moderate) is the one low-confidence call. D-02 (AC-5 Phase-5 stakes) resolved high toward the approved spec. --- @@ -723,19 +723,19 @@ Note: the prior attempt to append SD-03 failed (shell cwd had drifted into site/ - **Options:** (a) swap to EC frames; (b) keep bespoke, close 4.2's eval as no-swap; (c) keep bespoke + partial EC for the static install terminal. - **SMARTS:** Both components are animated CSS-only conversion pieces with prefers-reduced-motion static fallback and real-DOM screen-reader transcripts; EC frames are static code blocks — a swap loses the "show a gate catching a real mistake" mechanic (adoption strategy) for zero functional gain. InstallTerminal shares the same animation grammar, so (c) buys inconsistency. Accent tokens 4.2 names (pass-green/blocked-red) already exist in theme.css as --ca-term-pass/--ca-term-error and are in use. Plan pre-authorizes "keeping bespoke is an acceptable outcome." - **Chosen:** (b), confidence HIGH. PR-4.2 rescoped to close the real AC-5 gap found in exit-check prep: the landing page has no path to the uninstall guide ("how do I turn it off" leg). 4.2 = reversibility link on landing + decision documented here; no EC dependency added, no theme.css change needed. - -## Sprint: auto-safe-open-issues — 2026-07-12 - -- **D-01 — Ledger concurrency primitive.** Options: shared snapshot lock; per-session shards only; ownership-safe OS lock plus per-session shards. SMARTS favored OS-held cross-platform locking plus atomic shards for correctness, rollback safety, and bounded fail-soft latency. **Chosen:** OS-held file lock + shards. **Strength:** strong. **Confidence:** high. -- **D-02 — Red audit-suite blocker discovered during #299.** Options: ignore/retry the pre-existing flake; stop the sprint; diagnose and fix it as in-scope auto-safe debt. SMARTS and conflict hierarchy level 1 favored a regression-first fix because lost audit lines blocked the commit gate and violated audit integrity. **Chosen:** add same-process and dual-host Windows append fixes before continuing. **Strength:** strong. **Confidence:** high. -- **D-03 — Dirty-check latency policy (#298).** Options: per-process cache; exclude untracked files; 100 ms full-porcelain timeout. Fresh renderer processes made caching ineffective and semantic changes were disallowed. **Chosen:** retain tracked/untracked semantics with a 100 ms fail-soft timeout. **Strength:** moderate. **Confidence:** low. -- **D-04 — #259 disposition.** Options: reduce vendored core; leave issue open; close against later generated-surface work. The self-contained vendoring contract, shipped ca-init skill, host-native manifest, parity ledger, and green generator/reference checks satisfy the acceptance criteria without risky dependency slicing. **Chosen:** close with receipts. **Strength:** strong. **Confidence:** high. -- **D-05 — Theme evidence format.** Options: bitmap screenshots; raw ANSI captures; deterministic ANSI-free annotated terminal samples. Repository docs are text-first and color differences disappear under NO_COLOR, so annotated samples were the smallest stable artifact. **Chosen:** five deterministic annotated terminal samples, explicitly Claude-only. **Strength:** moderate. **Confidence:** low. -- **D-06 — Release versions for the landing branch.** Options: patch bumps; minor bumps; defer versioning. User-visible statusline capabilities require a minor ca bump, and the pre-1.0 Codex payload carries material shared-state fixes. **Chosen:** ca 2.9.0 and ca-codex 0.3.0 with dated changelog sections; preserve the README's historical live-verification text at 2.8.13/0.2.4. **Strength:** moderate. **Confidence:** low. -- **D-02 approval resolution:** User explicitly approved adding the regression-tested gate-event audit fixes to the sprint scope on 2026-07-12. Spec and plan amended before final landing. -- **Harvest D-03:** promoted queued task `(from sprint:auto-safe-open-issues)` to benchmark and revisit the 100 ms dirty-check timeout. SMARTS: reliable/secure behavior favors measurement before changing the bounded fail-soft policy; non-blocking. Confidence: high. -- **Harvest D-05:** promoted queued task `(from sprint:auto-safe-open-issues)` to validate annotated palette evidence with users before adding unstable bitmap captures. SMARTS: useful evidence with low maintenance cost; non-blocking. Confidence: high. -- **Harvest D-06:** promoted queued task `(from sprint:auto-safe-open-issues)` to reconfirm version classification against the final merged diff before release/tagging. SMARTS: release accuracy without blocking this feature PR; non-blocking. Confidence: high. + +## Sprint: auto-safe-open-issues — 2026-07-12 + +- **D-01 — Ledger concurrency primitive.** Options: shared snapshot lock; per-session shards only; ownership-safe OS lock plus per-session shards. SMARTS favored OS-held cross-platform locking plus atomic shards for correctness, rollback safety, and bounded fail-soft latency. **Chosen:** OS-held file lock + shards. **Strength:** strong. **Confidence:** high. +- **D-02 — Red audit-suite blocker discovered during #299.** Options: ignore/retry the pre-existing flake; stop the sprint; diagnose and fix it as in-scope auto-safe debt. SMARTS and conflict hierarchy level 1 favored a regression-first fix because lost audit lines blocked the commit gate and violated audit integrity. **Chosen:** add same-process and dual-host Windows append fixes before continuing. **Strength:** strong. **Confidence:** high. +- **D-03 — Dirty-check latency policy (#298).** Options: per-process cache; exclude untracked files; 100 ms full-porcelain timeout. Fresh renderer processes made caching ineffective and semantic changes were disallowed. **Chosen:** retain tracked/untracked semantics with a 100 ms fail-soft timeout. **Strength:** moderate. **Confidence:** low. +- **D-04 — #259 disposition.** Options: reduce vendored core; leave issue open; close against later generated-surface work. The self-contained vendoring contract, shipped ca-init skill, host-native manifest, parity ledger, and green generator/reference checks satisfy the acceptance criteria without risky dependency slicing. **Chosen:** close with receipts. **Strength:** strong. **Confidence:** high. +- **D-05 — Theme evidence format.** Options: bitmap screenshots; raw ANSI captures; deterministic ANSI-free annotated terminal samples. Repository docs are text-first and color differences disappear under NO_COLOR, so annotated samples were the smallest stable artifact. **Chosen:** five deterministic annotated terminal samples, explicitly Claude-only. **Strength:** moderate. **Confidence:** low. +- **D-06 — Release versions for the landing branch.** Options: patch bumps; minor bumps; defer versioning. User-visible statusline capabilities require a minor ca bump, and the pre-1.0 Codex payload carries material shared-state fixes. **Chosen:** ca 2.9.0 and ca-codex 0.3.0 with dated changelog sections; preserve the README's historical live-verification text at 2.8.13/0.2.4. **Strength:** moderate. **Confidence:** low. +- **D-02 approval resolution:** User explicitly approved adding the regression-tested gate-event audit fixes to the sprint scope on 2026-07-12. Spec and plan amended before final landing. +- **Harvest D-03:** promoted queued task `(from sprint:auto-safe-open-issues)` to benchmark and revisit the 100 ms dirty-check timeout. SMARTS: reliable/secure behavior favors measurement before changing the bounded fail-soft policy; non-blocking. Confidence: high. +- **Harvest D-05:** promoted queued task `(from sprint:auto-safe-open-issues)` to validate annotated palette evidence with users before adding unstable bitmap captures. SMARTS: useful evidence with low maintenance cost; non-blocking. Confidence: high. +- **Harvest D-06:** promoted queued task `(from sprint:auto-safe-open-issues)` to reconfirm version classification against the final merged diff before release/tagging. SMARTS: release accuracy without blocking this feature PR; non-blocking. Confidence: high. # Sprint log — pre-release-hardening Started 2026-07-13. Append-only. SMARTS-scored auto-decisions; `low` = review these. @@ -1205,3 +1205,279 @@ Started 2026-07-20T01:40:46-04:00. Append-only. SMARTS-scored auto-decisions; - [2026-07-20] PR #313 evidence-bound candidate GREEN | candidate f764929e02fbb67b43a3b828686c0007445a0316 completed main CI run 29777295584, CodeQL run 29777291834, and docs run 29777295518 on the exact head; all six supported Pi cells, Pi security, all hook platforms, sandbox, farm, docs, generators, references, version gates, aggregate, and merge readiness pass | fresh candidate-matched local evidence: Pi 0.80.5 platform contract 104547ms, Pi 0.80.10 platform contract 103745ms, global Pi restored to 0.80.10, unchanged real 0.80.6 refusal canary VERSION_UNSUPPORTED | sanitized promotion pair regenerated for f764929; next hard boundary: strict final verifier | strength: strong | confidence: high. - [2026-07-20] hackathon PR #313 T-13 ACCEPTED | strict final verifier completed one uninterrupted green replay in an isolated feat/pi-support clone at f764929e02fbb67b43a3b828686c0007445a0316 with the exact evidence-only descendant set | evidence: 46/46 canonical repository gates, candidate-matched hosted attestation, exact sanitized Markdown rendering, PI-AC-01..38, owner inventory, final task/obligation statuses, generation idempotency, branch guard, and diff check all PASS | next hard boundary: evidence-only commit and final hosted CI before source PR closure | strength: strong | confidence: high. - [2026-07-20] hackathon PR #313 T-14 and T-15 ACCEPTED | evidence-only head 6173b1d4d2ae6380ef577fd631131c9469a898c1 completed main CI 29778968354, CodeQL 29778964688, and docs 29778968433 with all exact-head checks green | after that gate, source PRs 347,348,349,350,351,354,356,357,358,359,360,362,363,365,367,368 were closed unmerged with durable redirects to #313 | closure audit: 16/16 source states closed-unmerged, 16/16 heads match the source manifest, 16/16 redirect comments cite #313 and 6173b1d, 16/16 source numbers remain present in #313 body; target PR remains open, clean, and unmerged | CHOSEN: commit this governance-only closure receipt and require exact-head CI once more; never merge | strength: strong | confidence: high. +- [2026-07-31] DECISION portable-release-and-protected-state D-1/D-2 closure | point: the sprint closes both task-board deferrals, and D-2 carries two undecided parameters — which command owns the archival sweep of settled `[x]` items into `done-tasks.md`, and what "long-settled" means | D-1 status: already resolved in substance — `/ca:task` exists as the sanctioned board mutator with `_taskboardlib` supplying `next_seq`/`add_entry`/`set_state`; what D-1 actually left open is that the sanctioned path is not ENFORCED, which is this sprint's protected-write class, so D-1 closes as fact rather than as a decision | options for the sweep owner: `/ca:standup`; `/ca:task`; a new `/ca:archive` | SMARTS: Scalable, Available, Testable and Securable are Indifferent — all three are local, solo-scale, and the sweep is a `_taskboardlib` function with a thin caller in every case. Maintainable: standup Strong, it is already the daily-hygiene lane that performs cleanups under per-action confirmation; task Adequate, archival is periodic hygiene rather than a per-task mutation and sits oddly beside add/start/done; archive Weak, a new public surface to govern, catalog and document, which the release skill's own one-command-four-plugins reasoning argues against for a single operation. Reliable: standup Strong, its "never destructive without a yes" contract is exactly D-2's "deliberate, confirmed, append-only" requirement; task and archive Adequate | CHOSEN: `/ca:standup` owns the sweep; the cutoff is done > 14 days, matching D-2's own worked example and shipping as a named constant per D-3's precedent so tuning it stays non-load-bearing | strength: moderate — two lenses align cleanly and the rest are Indifferent | confidence: low — flagged for review; the cutoff in particular is a tuning value with no evidence behind 14 over 7 or 30, and the mechanism is tested with an injected date so the constant is not load-bearing. +- [2026-07-31] DECISION portable-release-and-protected-state B2 done-tasks policy | point: an adversarial pass established that the protected-state registry needs a per-entry policy rather than uniform marker-gating, and `done-tasks.md` had no policy chosen | options: enroll it in the existing H-05 audit set (`_protectedlib.AUDIT_LOG_FLAT_BASENAMES:63`), which supplies Write-block, tail-anchored append and a shell flank for free; or make it helper-only via the archive verb | SMARTS: Maintainable favors H-05 (zero new machinery); Reliable and Securable favor helper-only, because the audit set permits ANY cooperative append while the archive verb constrains the shape and enforces per-item append-then-remove ordering; Testable favors helper-only, since the verb is directly fixture-testable where a free-form append is not; Scalable and Available Indifferent | CHOSEN: helper-only via `taskwrite archive`, which this sprint must build regardless for the sweep to have any implementation path at all — reusing H-05 would add a second weaker route to the same file | strength: moderate | confidence: low — flagged for review; H-05 reuse is a defensible cheaper answer if the archive verb slips. +- [2026-07-31] CORRECTION portable-release-and-protected-state workstream B | the sprint spec's first draft of B1/B2/B3 was materially wrong and has been corrected before planning | what was wrong: (1) a flat marker-gated registry was specified for all three consumers, but marker-gating `open-tasks.md` would ADMIT an agent composing board markdown under a marker, inverting steer 2 — `taskwrite.py` already exists as "the ONLY blessed way to write open-tasks.md" and is flank-invisible by construction, so the correct policy is a hard block with no marker path; (2) the archival sweep had no implementation path, since `taskwrite.py`'s verbs are add/start/done with no removal and the routing table says "never delete to complete"; (3) the writer inventory estimate of seven or eight surfaces needing minters was wrong in both directions — `harvest.md` already routes promotion through `{{CMD:task}} add`, so the real conversion set is exactly two surfaces (`debug`, `context-creation`) plus one helper extension | no code had been written; the correction is spec-only and pre-planning. +- [2026-07-31] DECISION portable-release-and-protected-state pre-run gate dispositions | point: an adversarial pass on the plan enumerated every point where autonomy would halt, and the maintainer pre-answered them in one batch to maximize uninterrupted run length | T-06..T-08 hook-guard gate: CHOSEN delegate-with-tripwire — proceed when `security-reviewer` PASSes and the six flank tests B-07..B-12 are green, halt only on a finding; SMARTS moderate, Available and Testable favored delegation (the gate becomes a reproducible test verdict rather than a guaranteed stall) while Reliable and Securable genuinely favored halting, making this an explicit maintainer risk-appetite call rather than an analysis result | T-16 and T-32: CHOSEN pre-approve both contents — ADR-0024 and the `security-controls.md` boundary row are fully determined by the cleared spec, so T-32 lands without a stop and T-16 shrinks to the attribution act, which is the only inherently human part | skill rewrite (CRITICAL-1): maintainer DECLINED both offered options and specified a third — an adversarial review by an Opus-model agent mid-sprint, with maintainer review at PR time; this spends no maintainer stop, gets independent eyes on the flagship shipped artifact before dependent work builds on it, and deliberately uses a different model from the standing Fable advisor for a second perspective | nine remaining calls confirmed as a block per their SMARTS recommendations (PD-1, PD-6..PD-13) | confidence: high — every disposition was maintainer-answered rather than auto-decided; the tripwire choice is the one carrying residual risk and is recorded as such. +- [2026-07-31] DECISION portable-release-and-protected-state provenance-manifest on every row | point: the spec's grammar example declared `provenance-manifest` only on the `ca` row, and A-3.5 makes an absent field skip tag recording silently by design | options: all four rows declare it; or the example as written | SMARTS: Maintainable Strong for all-four (one rule, no per-target exception a future row copies) vs Weak (undocumented asymmetry); Reliable Strong vs Weak (three series would silently lose immutability evidence); Securable Strong vs Weak (an unrecorded tag is an unguarded tag, and `check_tag_immutability.py` warns on exactly that); Scalable and Available Indifferent | CHOSEN: all four rows declare `provenance-manifest: .github/published-tags.json`, with a per-row field assertion added to T-26 | rationale beyond SMARTS: steer 5 forbids behavior change to this repo's release lanes, and today's hard rule at SKILL.md:127 records every published tag in every namespace | strength: strong | confidence: high. +- [2026-07-31] APPROVAL portable-release-and-protected-state Phase 1 gate cleared | the maintainer approved the sprint spec and plan and authorized the FULL run, all tasks, not the MVP slice | standing directives added at approval time, recorded here because they change how every subsequent auto-decision resolves | (1) STEER 7 — determinism over suggestion: weight `Scalable` heavier than usual in SMARTS, because the project is moving work out of prose suggestions an agent may or may not honor and into deterministic helpers and hooks; the stated reasons are token reduction AND turning process rules into things enforced rather than advised, so where a call is between adding skill prose and building a helper or hook, the deterministic option wins ties | (2) adversarial passes are one pass, Opus model, medium effort — note the Agent tool pins model but has no effort parameter, so effort inherits the session setting | (3) all HIGH findings must be remediated; MEDIUM findings fixed or filed at discretion | (4) the standing adversarial advisor may be used as a MAINTAINER PROXY — questions that would otherwise stop the run may be put to it and answered as if it were the maintainer, except hard gates, which remain true stops | (5) COMPLETION BAR — the sprint is not complete when tasks are ACCEPTED; it is complete when the replacement is proven to work AND to port | confidence: high — maintainer-stated, not inferred. +- [2026-07-31] CORRECTION portable-release-and-protected-state second ledger hole | the completion bar exposed a coverage gap of the same class as A-6.0: #563's acceptance carried "a consumer repo with one artifact can run /ca:release end to end" as a prose checkbox, but neither spec held it as a numbered criterion, so no task proved the thing the entire campaign exists to deliver | this is the failure mode recorded in project memory as dev-repo-state-masks-consumer-bugs — verifying against this repo's hand-built .codearbiter/ state hides consumer-facing bugs | FIX: added A-6.6 (clean consumer repo, single package.json, one CHANGELOG.md, one v* tag, codeArbiter installed, ZERO files from this repository, running through target resolution, window derivation, bump classification and changelog rolling) and A-6.7 (this repo still composes the same version the pre-change lane would derive, tag discarded not published), covered by new tasks T-73..T-76 as a Step 8 completion proof | ledger now 72 criteria over 86 tasks | strength: strong | confidence: high. +- [2026-07-31] PROXY-RULING portable-release-and-protected-state T-06..T-08 flank wiring | answered by the standing adversarial advisor under the maintainer-proxy authorization; four design questions resolved so the flank wiring does not stall, two of them by rejecting my framing | Q1 policy dispatch: CHOSEN extend `_hooklib.classify_protected` to return a new `"state"` class, with policy semantics resolved from the registry inside a single generic flank branch — NOT a parallel lookup; grounded in `_protectedlib.py:13-19`, which documents the #528/#529 failure where independent class checks on decision-log.md deadlocked because set membership was uncoordinated, and in #162 symlink laundering being closed INSIDE classify_protected (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms), so a parallel lookup would ship without symlink resolution and a symlink alias would write through the guard on day one; SMARTS strong, Scalable/Maintainable/Reliable/Testable/Securable all Strong for extend vs Weak for parallel; riders: `"state"` evaluates after the existing four classes, a registry-load test asserts NO registered path classifies into any legacy class (overlap is a loud configuration error, not a precedence puzzle), and classify_protected's return contract stays a set of strings so its four existing consumers see zero change | Q2 helper-only absoluteness: CHOSEN unconditional block, confirmed safe against the full producer inventory (taskwrite.py and init-codearbiter.py use Python file I/O; boardsync.py is write-free by its own header; session-start.py reads; every skill lane routes through the task command after T-54/T-55) — but the advisor surfaced a case I had not asked about: a MERGE OR REBASE CONFLICT IN open-tasks.md ITSELF, where the resolver must edit the working-tree file and taskwrite has no resolve verb, so an absolute block turns every board conflict into a stop; ruling: block STANDS, conflict handled as a named residual routed through logged `/ca:override`, with an ADR reopen condition — if gate-events.log shows board-conflict overrides recurring, the fix is a deterministic `taskwrite resolve` verb, never a guard exception; a conflict-marker content predicate ("admit Edit when the file contains <<<<<<<") was rejected as converting file content into an authorization signal, the same launderable shape the T-55 file-absent exemption lost on | Q3 shell-flank parameterization: PREMISE REJECTED — the registry must be CODE CONSTANTS and never disk-loaded, because a disk registry would let a consumer repo un-protect open-tasks.md by editing a file; the zero-side-effects-at-import rule prohibits file I/O and git, never regex compilation from module constants, with `_scopelib.py:109-117` as direct precedent (default glob tuples precompiled at load); given that, CHOSEN per-entry regex pairs from a template `_state_write_res(basename) -> (redirect_re, write_re)` mirroring CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE shapes at `_bashguardlib.py:355-359`, compiled once at import, rather than one generic alternation whose single failure smears across all entries; verified mechanically against both pinned non-regressions (`git add open-tasks.md` passes — git is not a verb and no redirect precedes the name; the filename-in-description helper call passes — python3/taskwrite/add are not verbs; `tee` and `>>` both block); ONE RESIDUAL to document with a test rather than fix: a description containing a write verb (`taskwrite add -- "remember to tee open-tasks.md"`) false-blocks, which is the same accepted lexical residual every _bashguardlib flank carries — name it in the ADR, pin the passing form, do not chase it with smarter parsing | Q4 marker carve-out: FRAMING REJECTED — there is no exclusion to extend; `_protectedlib.py:109-113` blocks GATE_MARKER_NAMES (security-gate-passed, migration-gate-passed) only, and adr-authoring-active was never enumerated into it, because the membership criterion is whether a marker converts a BLOCK into an ALLOW; a forged gate-pass fakes a security verdict, a forged authoring marker fakes nothing since it is audit friction, self-mintable by design per ADR-0010; CHOSEN change nothing, add no generic marker handling, and instead encode the CRITERION rather than the membership — one comment on GATE_MARKER_NAMES stating that the list enumerates block-to-allow markers while friction/authoring markers stay shell-touchable by design, plus one pinning test asserting `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; the accidental-widening risk runs OPPOSITE to my question — the danger is a future maintainer "hardening" the blocklist generically over every registered marker, which would brick every minting lane while stopping no non-cooperative agent, and the pinning test is the fence against that tidy-up | confidence: high — all four grounded in cited code, two correcting my premise; none touches a non-delegable hard gate. +- [2026-07-31] REVIEW portable-release-and-protected-state B1 slice (T-01..T-05) | Opus adversarial pass per the maintainer's standard; 13-mutant campaign against the 9 new tests, 7 survivors, 2 HIGH | verdict: no live defect and no security hole — traversal safety, freshness delegation, genericity and zero-side-effects-at-import all survived attack, the latter verified under sys.addaudithook rather than assumed — but TEST ADEQUACY failed against the standing "every new test must die to a mutant" directive | HIGH-1: the default-registry code path has no fail-detecting test; mutating `registry = REGISTRY` to `registry = {}` passes all 9, because the only default-path test is a NEGATIVE assertion against one literal path and all three positive tests inject a synthetic registry — meaning nothing proves the production path works, and once consumers enrol at B-13/14/15 the entire guard could be a silent no-op with CI green | HIGH-2: the freshness window is a tautology in both directions; MARKER_FRESHNESS_MINUTES mutated to 1000 or 3 passes all 9, because both freshness tests compute their ages FROM the implementation constant, pinning boundary logic but not value — a 33x widening of a security window would ship green; compounded by a comment claiming the constant is "reused verbatim (not re-declared)" when 30 is in fact hardcoded in five independent places with no import relationship | ORCHESTRATOR ERROR, recorded deliberately: MEDIUM-3 established that my own earlier "fix" was wrong. I removed `REGISTRY: dict[str, ProtectedPolicy]` believing a PEP 585 builtin generic is evaluated at import and raises on Python 3.8, and reported that to the maintainer as a confirmed defect CI could not catch. The module carries `from __future__ import annotations` at line 84, which under PEP 563 leaves VARIABLE annotations unevaluated as well — verified by probe: the annotation with an undefined name raises nothing. The annotation was always safe on 3.8. Worse than the unnecessary change, the comment I left asserted "Every sibling _*lib.py declares module dicts bare for the same reason", a false rule that would have propagated into the next four modules of this campaign. Remediation restores the annotation and deletes the comment | remaining MEDIUMs sent for fix rather than filing, because all four propagate into the next four modules: a typo'd registry KEY fails open and silent (norm_path applied to the query, never the keys) which directly contradicts the module's own stated doctrine that malformed input must fail loud; the `str` mixin is entirely untested and its docstring's argv claim is false on 3.11+ where interpolation yields the class-qualified name and varies by interpreter version; `marker_name_for` is basename-only so two registered files sharing a stem share one authoring marker; and its norm_path call is untested | LOWs at discretion: dead `minutes` parameter, and the call-site constraint (module must be reached through classify_protected, never called directly by a flank) documented only in this log | confidence: high — every finding reproduced by executed mutation, not asserted. +- [2026-07-31] SECURITY-GATE portable-release-and-protected-state H-22 flank wiring | security-reviewer verdict PASS, 0 CRITICAL 0 HIGH — the tripwire disposition therefore holds and autonomous execution continues without a maintainer stop | cleared by execution rather than by reading: the registry-is-code-constant property genuinely holds (REGISTRY assigned once as a literal, no json.load/open/env read anywhere in core/pysrc); the deferred import is necessary rather than stylistic and FAILS CLOSED (at call time it is a sys.modules hit since all three flanks already import the module at scope; a raise propagates into the except backstop and becomes block H-00 exit 2); every traced fail direction is correct (registry raise, unreadable marker dir, unresolvable root, cross-drive path, resolve returning None — all closed; genuinely-outside-repo correctly allows); and both load-bearing non-regressions are proved rather than accidental, with the regex-level cause pinned independently | the `touch` exclusion was CLEARED with evidence rather than accepted on precedent: touch cannot truncate on any supported platform, the PowerShell verbs that CAN truncate are all blocked, and every mtime consumer under .codearbiter/ was traced — the statusline key fails toward MORE work not less, staleness_warning does not key on registry paths, and marker_gated_write_admitted stats the MARKER not the protected file, so even `touch -t` back-dating cannot admit anything; the H-11 precedent that DOES include touch is principled (there, creation itself is the violation; here the helper creates the file legitimately) | THREE MEDIUMS, all latent while the registry is empty and all going live at B-13/B-14/B-15 — and those enrolment tasks will not re-run this review, which is the reviewer's argument for closing them inside this sprint rather than filing | M-1: lookup_policy matches by exact equality after separator normalization only, while its own docstring claims a `./` prefix or trailing slash still matches; measured, `./.codearbiter/open-tasks.md`, a trailing slash, a doubled slash and a leading space all MISS, so a mis-keyed entry protects nothing while LOOKING protected in manual testing because the shell flank keys off the bare basename and still fires | M-2, the more serious: the raw leg of the two-form lookup is INERT in the wired path because hosts always send absolute paths, so the state class rests entirely on repo_rel — which inverts symlink coverage exactly where the design claims to close #162; measured, when the protected path is itself a symlink the ABSOLUTE spelling (the only one a host sends) is ADMITTED while the relative one blocks, and the legacy regex classes are immune because search() matches the raw path regardless; the non-adversarial case is the concerning one, a consumer symlinking their board into a monorepo location loses H-22 silently with no diagnostic | M-3: the write-verb list is narrower than the H-05 precedent it cites as its model, leaving nine spellings allowed including `git checkout HEAD -- ` and `git restore` (closed for audit logs by LOG_GIT_RESTORE_RE under #335 with the rationale that they rewrite tracked files through Git itself, and all three planned entries are tracked), `sponge` (present in LOG_DESTROY_RE, dropped here), and `python -c`/`node -e` — the last directly contradicting B1's own stated premise for helper-only, that the sanctioned helper's Python file I/O is the ONLY route, since `python -c` naming the file lexically is exactly that; these are currently UNDECLARED residual rather than accepted residual | disposition: hold remediation until the parallel Opus adversarial pass lands, then remediate once against both reports rather than twice | confidence: high — every finding measured against a synthetic registry, not inferred. +- [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-17..T-24 mechanism split | Opus adversarial pass: 46 mutants, 17 killed, 3 HIGH; all HIGH and 5 MEDIUM remediated, suite grew from 112 to 168 tests, sync-core byte-identical across 55 core files and 3 plugins | H-3 was the structurally interesting one: seven of the nine mechanism functions had ZERO coverage in the new module. Each could be replaced with a hardcoded constant return and all 112 tests stayed green, because the ~100 tests exercising those names ran against the OLD helper, the file this slice deliberately did not touch. The suite looked like it covered the migration and in fact covered the thing being migrated FROM. The reviewer then ran a ~1,900-input differential across both modules and found ZERO behavioral drift, so the split was correct all along, but that proof lived in a throwaway harness and nothing in the repo would have shown it. Right code, absent verification, which is the state that looks safest and is not | M-2 is the finding that justifies the campaign: the pre-release marker filter substring-tested "-beta", "-rc" and "-alpha" against the WHOLE TAG INCLUDING THE PREFIX. Unreachable in this repo because the anchored regex already excludes suffixed tags, which is why mutating it away survived. But a consumer whose prefix contains a marker loses every tag in that series and receives the "never released" sentinel, so the lane would offer a first-release baseline to a project that has releases. Verified fixed against the SHIPPED plugin copy: a web-beta-v prefix now resolves web-beta-v1.2.0 where it previously returned the sentinel, while a plain v prefix still correctly excludes v1.1.0-beta. A portability defect of exactly the class this split exists to remove, sitting inside the code doing the removing | H-1 silently truncated a block when a value contained the closing delimiter and it was the only close, parsing as success with a required key emptied, and separately misdiagnosed legitimate trailing prose; both directions fixed with a genuine-terminator scan | H-2: empty and whitespace-only values satisfied the required-key check because the row finisher tested "is None" while a parsed key always assigns a string, so a typo'd empty prefix became "" and flowed onward as "never released" | ORCHESTRATOR ERROR, second of this campaign: I wrote an unverified subagent claim into the spec as normative text, namely that a single-point mutant cannot kill the CRLF test. Measured, removing the raw-line strip alone DOES kill it, and for an incidental reason: with CRLF input the extracted block's first line is a bare carriage return which unstripped becomes a spurious key line and raises MalformedBlockError before the boolean assertion is reached. The dead-code half of the claim was true; the normative half was not, and it had become spec text instructing maintainers not to touch it. Corrected as rev 4.3. Same failure mode as the PEP 585 error earlier in this campaign: a specific, mechanically plausible claim arriving inside otherwise careful work, written down without the two-line probe that would have falsified it | also fixed: the missing-required-key error and the first-colon split rule were both untested, two escape hatches raised bare TypeError and FileNotFoundError outside the declared error hierarchy, a scalar-keys constant was dead code, and boolean parsing accepted TRUE against a spec requiring exactly true or false | confidence: high, every fix probed against the SHIPPED plugin copy rather than the source. +- [2026-07-31] PROXY-RULING portable-release-and-protected-state completion-proof design (T-73..T-76) | five rulings from the standing advisor under maintainer-proxy authorization, grounded in three of the maintainer's own recorded directives: verify against a clean install not the dev tree; verify by outputs not job status; execute the real path rather than asserting the contract. It ruled AGAINST my framing on Q4 and reframed Q5 | Q1, what "zero files from this repository" means: CHOSEN materialize the plugin from `git archive HEAD -- plugins/ca` extracted into a scratch cache, with the plugin root pointed there and the project dir at a scratch consumer repo. REJECTED pointing the plugin root at the in-repo tree, which is the dev tree wearing a costume and is the exact shape that masked consumer bugs before; ALSO rejected a plain recursive copy, which has the same defect at one remove because it carries uncommitted and gitignored files, so a skill referencing a file that exists locally but was never committed would still pass. Honest limit stated: the archive approach cannot catch install-mechanism defects such as manifest parse errors or hook wiring at install time; those belong to the clean-home install smoke test already performed at release, and must not be dragged into per-PR CI | Q2, CI or local: BOTH, split by what each can afford. The mechanical fixture costs single-digit seconds with no network, so it becomes a REQUIRED CI check whose assertions are on derived outputs (the resolved row, the last tag, the computed bump, the rolled changelog text) and never on exit codes alone. The agent-run layer cannot run without a model in the loop, so it stays local, and what stops it rotting is the ruling worth keeping: the proof run records the CONTENT HASH of the shipped release skill it exercised, and a declared pre-tag check asserts at release time that the hash still matches. Edit the skill without re-running the proof and the next release BLOCKS. That is enforcement at exactly the frequency the proof matters, and it enforces itself using the very pre-tag mechanism this campaign built | Q3, what can be proven without an agent: three layers, and the middle one is the substance. The library layer already exists and proves nothing about the lane. The lane-mechanics layer must be built and closes the gap I named: a reference-resolution test that extracts every executed-or-read path from the INSTALLED skill in the scratch environment and asserts each resolves, which catches the exact defect class that started #563 with no model involved; plus a lane driver that invokes the helper CLI using the invocation strings EXTRACTED FROM THE SKILL TEXT rather than by direct import, so a drift between what the prose says to run and what the CLI actually accepts fails the test instead of being papered over. The agent layer is genuinely irreducible but narrow: whether a model following the prose confirms the bump, blocks on a missing footer, stops without authorization, and handles the back-fill confirmation honestly. That scope must be stated plainly in the test docs so nobody inflates it or skips it as already covered | Q4, RULED AGAINST MY PLAN: no real tag in this repository, not even transiently. Two concrete hazards beyond doctrine. First, the payload version gate checks local tags, so an interrupted proof leaves a tag behind and every subsequent local gate run reports "already released", a self-inflicted footgun precisely where this campaign's credibility lives. Second, one habit-formed push publishes a tag at the real next version with no provenance entry, tripping the immutability audit and forcing the exact cleanup the release doctrine calls a deliberate announced maintainer action. And the delete step proves almost nothing, because a tag's informative content is its message file and its target derivation, both comparable without a ref. CHOSEN instead: extend the T-27 trace apparatus to live-repo HEAD, deriving next version, window and composed tag MESSAGE FILE from both the pinned pre-change lane and the new lane, asserting equality, with zero refs created. Real annotated-tag mechanics get exercised inside the scratch fixture, where refs are disposable by construction and the same code path runs | Q5, REFRAMED: build T-73 now, but as a RATCHET rather than a red test. Both of my framings were wrong. A long-red test cannot be a required check while red so it enforces nothing for weeks, and a test red for its whole life gets edited into passing on the day it finally matters, which is authored-at-the-end and shaped-to-pass by a slower route; but authoring at the end is strictly worse because the fixture's expected values get transcribed from the implementation they are supposed to check. The ratchet reconciles both: the reference-resolution assertion compares the unresolved set against a COMMITTED KNOWN-FAILURES LIST, so the test is green and required from day one and fails whenever that set changes in EITHER direction without the list being updated in the same diff, catching both a shrink someone forgot to record and a NEW contaminating reference sneaking in mid-campaign, which a plain red test would silently absorb. Each skill-rewrite task shrinks the list in its own reviewed commit; the final task asserts empty and deletes the ratchet in favor of the strict zero form | confidence: high, every ruling grounded in cited code or a recorded maintainer directive; none touches a non-delegable gate. +- [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-25..T-27d shim, declared rows, resolution trace | Opus adversarial pass returned 4 HIGH and 6 MEDIUM, and falsified the slice's central claim: the shim DID break CI, in three independent places, two of them caught by the repo's own existing guards while I had reported the slice verified | HIGH-1: the shim broke test_release_workflow.py, 41 of 60 failing, because that suite builds a synthetic tree copying only the shim while the shim resolves the generated mechanism at import time; that suite is described in ci.yml as the ONLY guard on the release workflow's publish gates. Fixed in the TEST by materializing the mechanism into the synthetic tree, deliberately NOT in the shim, because a fallback tolerating a missing mechanism is the "fix it into a default" the spec forbids and would let a genuinely broken install pass | HIGH-2: test_release_trace.py, the campaign's entire proof of no behavior change, was referenced by no workflow and no sibling script, and the repo's own orphan guard was ALREADY RED naming it. A trace that certifies without executing is the worst state available. Now wired into the hooks job with fetch-depth 0 and fetch-tags, because the review reproduced both shallow-clone failures: the pinned SHA is unreachable without full depth and the live-tag assertion fails without tags. The remediating agent could not reproduce a genuine shallow clone locally, so rather than rely on an unverifiable assumption it added a preflight that HARD-FAILS with a named cause rather than skipping, which is the right instinct: a skip would have hidden exactly this | HIGH-3: the new shim test shelled out to the payload version gate with an origin/main base the hooks job checkout cannot resolve, so it would fail on every PR run, and it coupled the hooks job verdict to ca's payload-versus-version state. Replaced with an in-process identity assertion on the three names the real consumer imports, which is both git-free and a stronger proof | HIGH-4: ca-pi's declared rebuild command was not runnable as written, because build.mjs resolves inputs against cwd and needs the cd form the three sibling rows already used; as written it would have permanently blocked the ca-pi release lane the moment slice 3 lands, and a mutant replacing the command with a no-op survived the entire suite | MEDIUM highlights: the declared rows had almost no content coverage, 6 of 6 row mutants surviving including one that replaced a pre-tag command with a destructive rm, so nothing asserted this repo's own declared EXECUTABLE INPUT; and release-targets.md claimed in the present tense to be protected-write under H-22 while the registry is still empty and enrolment is T-33, so the file carrying operator-authored executable input sat on disk unprotected while documenting itself as protected | M-4 was the sharpest: the trace certified "changed nothing" across a function this campaign DELIBERATELY changed. last_tag_select's marker test moved from the whole tag to the prefix-stripped version, which was the consumer-portability fix two slices ago; on all four fixture prefixes old and new agree, so the trace read as true only because no fixture exercised the change. Now recorded as AC-1.12 with a marker-bearing fixture row asserting the INTENDED divergence, converting the trace from "nothing changed" into "exactly this changed, on purpose" | ORCHESTRATOR ERROR, third of this campaign: I wrote into the spec that adding a merge commit gives the window variable discriminating power. It does not, because both lanes call the same walk function so any fixture structure moves both sides identically. The test docstring said this correctly while my spec said the opposite. Corrected as rev 4.6, and I stopped claiming a remedy exists at all: the window is a consistency check on the harness, not a behavior trace. Same failure shape all three times, a specific mechanically plausible claim from careful subagent work written into a governing document without the probe that would falsify it | NEW FINDING filed as #568: the pre-release marker constant is unreachable dead code behind the anchored regex, and the module comment calling it a second line of defense was factually wrong as shipped. Probed and confirmed: the anchor rejects every suffixed tag, so no tag can both match the regex and carry a marker in its stripped version. This is fallout from the real portability fix rather than an original defect, since the marker test used to run against the whole tag which made it reachable AND wrong. Comment corrected in place; the delete-versus-relax-the-anchor question is the user's | final state: test_release_workflow 60 OK, test_ci_impact 54 OK, test_release_lib 188 OK, test_release_trace 19 OK and now CI-wired, hook suite green, sync-core byte-identical across 55 core files | confidence: high. +- [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-73a/T-73b consumer portability proof | Opus adversarial pass: 18 mutants, 6 killed, 2 HIGH plus 5 MEDIUM, all remediated with mutation evidence | HIGH-1 was the exact failure I pointed the reviewer at and it found it: the extractor's segment class carried no asterisk, so the three glob references the release skill EXECUTES (listing commands, skills and agents directories to derive count badges) were structurally invisible. plugins/ca/agents had no representative anywhere on the 21-entry list, so after the skill rewrite the ratchet would have reached empty with all three hardcoded layout references still shipping, and the terminal assertion would have certified a portability that is false. A ratchet self-defends against NARROWING, because dropping a listed entry surfaces loudly; it cannot defend against a form the regex never matched, and the non-vacuity floor measures non-emptiness rather than completeness | HIGH-2 was a gaming vector I built in: the project-dir arm returned an in-bounds check only, so it resolved anything at all including a path nobody would ever have. T-41b could have been satisfied by repointing every helper under that prefix, retiring eight entries while leaving the skill exactly as unportable, and neither guard discriminated because the source-tree guard permits that prefix wholesale. Narrowed to a codearbiter-owned-subtree exemption with everything else falling through to a real existence check | MEDIUM-3 exposed a second full release payload outside the ratchet, and generalizing the fix surfaced a FIFTH payload that neither the review nor my own spec had ever named. Measured, five release-skill copies ship and three of them carry seven repo-path references each: the ca skill, the ca-codex routine and the ca-pi routine, while the two ca-release copies are clean stubs. My spec's source-of-truth section named four payloads and got the significant ones backwards, listing the harmless stubs and omitting the ca-codex routine entirely. Corrected as rev 4.7 with the measured table | MEDIUM-4 settled by ruling rather than deferring, because it decides whether the terminal assertion is reachable: four entries are prose MENTIONS rather than invocations, so under the spec's own measurement rule they are not contaminating and the rewrite task would never clear them. I rejected splitting the list into blocking and non-blocking sections, since that creates a parking space where a real invocation can hide, and instead extended the rewrite task's scope to reword the prose, which is the precedent already set for the decision-lifecycle skill | remaining fixes: abnormal termination no longer leaves a sentinel inside the shipped payload path, the git-isolation defenses the module called load-bearing are now actually tested rather than merely documented, the fixture's annotated tag is pinned as annotated (which matters because the no-refs-in-this-repo decision rests on the claim that real tag mechanics happen in the fixture), byte-identity is now a real byte comparison rather than one passing through newline translation, and executable bits are preserved from the archive | ORCHESTRATOR NOTE: the reviewer flagged a stale count in a comment as the THIRD instance this campaign of a number written from an unverified report. The fix is not more care, it is deriving the count in the test rather than restating it in prose, which is what landed | final state: 80 references across 5 payloads, union of 24 unresolved entries, bidirectionality reproven in both directions at the final file state, consumer smoke green with one platform-appropriate skip, orphan guard green, CI path filters extended to every newly scanned payload so a hand-edit cannot skip the job that checks it | confidence: high. +- [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-77 live-repo release proof | Opus adversarial pass: 29 mutants, 3 HIGH, all remediated with mutation evidence; suite 28 tests green, ci-impact 54 green, zero refs created (143 before and after) | the reviewer's closing observation names a pattern that is mine: a fix was applied to the repository without the artifact's own claims being re-checked against the repository it now describes. All three HIGHs trace to my own remediation | HIGH-1: the module docstring, the class docstring and a ca-pi assertion message all asserted as live fact that ca's manifest read 2.11.0 while the changelog's newest heading read 2.10.8 and that a release would block for that reason. True when written, false the moment I added the 2.11.0 section in the same uncommitted change. Rewritten to state the MECHANISM (manifest bumps per-PR while the changelog lands on a slower hand-written cadence, so the two can diverge and Phase 1 blocks when they do) rather than any transient state; the test name itself was also renamed, since it asserted the stale fact in the identifier | HIGH-2: the ca drift test asserted nothing a realistic bug could fail. Corrupting next_version to a literal SURVIVED the whole suite by flipping the branch to its else-arm, and that arm merely restated its own guard condition so it could not fail; nothing recorded which arm ran. Worse, the if-arm that MY changelog fix activated was the weaker one, checking neither date consistency, nor the section boundary, nor semver ordering, all three of which the ca-pi lane already checked. I had read the branch structure, judged it well designed because it would not rot into a false claim, and approved it without checking what the arm my own fix activated actually asserts. The remediating agent improved on my instruction: rather than pinning which arm ran, it collapsed the branch into an unconditional equality assertion, on the reasoning that a future recurrence of the drift SHOULD fail this test because that is what a blocked release looks like, not be quietly tolerated | HIGH-3: the live window dropped payload-exclude, so the ca-pi window read 15 commits where the real release window is 14, and the extra commit is a feat that would drive a DIFFERENT SemVer bump. I had reported 15 to the maintainer as a verified value. The criterion names payload-exclude as one of the three behaviors ca-pi is in the trace specifically to exercise, and the live extension dropped exactly that; deleting the declared exclude from the row survived the proof entirely. Now threaded into both git calls, added to the row facts, and pinned with a strict-subset assertion | MEDIUM, mine and content-destroying: my changelog edit ERASED version 2.10.8. The replaced span included its heading and my replacement did not restore it, so a shipped version vanished and its hooklib slice-4 notes silently reparented under 2.11.0, breaking the file's one-section-per-manifest-version convention while I claimed to be restoring a three-place invariant. Restored; sequence now reads 2.11.0, 2.10.8, 2.10.7 | design principle the reviewer confirmed and which explains every one of these: discriminating power must live in DIRECT SINGLE-LANE assertions, never in cross-lane equality, because anything the two lanes share is mutation-dead by construction. The ca-pi lane follows it and killed 7 of 7 shared-helper mutants; the ca lane did not, which was the whole of HIGH-2 | confirmed sound and not regressed: zero-refs holds mechanically rather than decoratively (injecting a real tag into setup was killed by the guard), the pinned lane genuinely cannot reach the working tree, and reading the next version from the manifest rather than the changelog heading is correct because the alternative makes the notes check a heading-versus-itself comparison | confidence: high. +- [2026-07-31] T-74/T-75 ACCEPTED, T-76 DELIBERATELY NOT ACCEPTED | the lane driver and consumer end-to-end landed; the back-fill arms did not, and the agent surfaced that rather than shipping a substitute. The release skill has no back-fill or candidate-shape-detection prose today because T-49/T-50 are still pending, so T-76's two required arms (refuse without confirmation, persist on confirmation) cannot be proven yet. What landed instead is an assertion of what is actually true, that loading an absent declaration raises rather than silently defaulting, plus a canary asserting the ABSENCE of back-fill prose so it fails loudly the moment T-49/T-50 land and forces replacement. T-76 stays PENDING and the ledger stays honest | TWO GENUINE FINDINGS, neither routed around | first, and it affects already-published artifacts: git's default cleanup mode for a message file is strip, which treats every hash-prefixed line as a comment and DELETES it, so every Markdown heading in a composed tag message is silently dropped. Verified against this repository's own real published v2.8.13, which carries its bullet bodies but neither its version heading nor its Added grouping. The skill's own notes-heading guard cannot see this because it validates the composed file BEFORE git consumes it, so it checks the input and never observes the loss. A direct-import test could not have found it either: the defect lives in the gap between what the prose says to run and what that command actually does, which is precisely why the lane driver executes extracted invocation strings rather than calling the library. Filed as #569; under the immutable-tag doctrine the already-published tags stay as they are and only future tags get fixed | second, a hole in this plan rather than in the code: the shipped mechanism has NO __main__ entry point, only the CI shim does. T-41b repoints the skill's helper invocations at the plugin-root copy, but that file cannot be invoked, so tag-prefix, last-tag and classify would still fail in a consumer AFTER the rewrite reported success. Added as T-41f. This is the THIRD ledger hole in this campaign, after the missing skill rewrite and the missing portability proof, and all three shared one shape: a criterion set that was internally consistent and silent about a step nobody had named | the lane driver's design earned itself: six invocation strings extracted from stable anchors in the INSTALLED skill text and subprocess-executed literally, so a renamed subcommand or a moved flag fails there rather than silently. It also proved the six are byte-identical across all three full payloads, guarding the same cross-payload defect class that was missed twice earlier | confidence: high. +- [2026-07-31] T-34, T-41a..T-41f ACCEPTED, skill rewrite landed at 2c83998 | the reference ratchet went 24 entries to 0 and is GREEN against the committed skill, which is the check that distinguishes a real resolution from a rewording that dodges the extractor | Opus returned BLOCK on three counts and ALL THREE trace to my sequencing decision. I pulled the rewrite ahead of the pre-tag-execution slices the same way I had pulled the consumer fixture and the live trace forward, and those two worked. The difference I missed: those tasks CONSUMED machinery that already existed, while this task's output is PROSE ASSERTING what exists. Reordering is safe when a task consumes what is built; it is not when the task documents a system, because the document can run ahead of the system | HIGH-1: the rewrite named a release-time back-fill lane while the tasks that author it are pending, and a canary written for exactly that fired correctly, its docstring instructing that it be replaced by the real two-arm proof in the same commit rather than loosened. Mention dropped; canary untouched | HIGH-2: the rewrite wired a declared pre-tag command into a Phase-1 BLOCK while the script did not exist anywhere, so this repo's own primary target became unreleasable. The row had been inert until this diff activated it. Script now authored and non-mutating | HIGH-3: the ca-pi generated-root-manifest doctrine was dropped from three places while the replacement prose instructed the agent to update every manifest path, which is the forbidden hand-edit. Restored as DECLARABLE ROW DATA rather than folklore, with new grammar keys, so a consumer can express the same trap instead of inheriting a rule that only this repo knew | THE RANKING FINDING was none of those: a mutation campaign deleted governance from every payload and ELEVEN OF TWELVE MUTANTS SURVIVED. The entire recovery-from-a-bad-release section, the changelog-footer BLOCK, publish read-back, the immutable-tag rule, derive-never-guess, the pre-tag block rule and the payload-scoped window rule were each deletable with a fully green suite. Only the latest-badge assertion caught its mutant. Coverage had followed what the rewrite ADDED rather than what had to SURVIVE, which is the identical shape to the SEMVER regression found hours earlier, and the dropped ca-pi doctrine is a real instance of the class those mutants predicted. A governance-survival test now asserts each rule's presence across every payload that renders this skill, with the payload list DERIVED from the surface builder rather than hardcoded, presence-checked by co-occurring tokens rather than brittle full sentences, and the checker itself unit-tested against synthetic fixtures so it cannot pass vacuously. Proven by deleting a rule, rebuilding, and watching it fail in all four texts | also fixed: a dangling display-name reference that would have retitled this repo's releases from codeArbiter to ca, and a true claim replaced by a false one about the local command and the hosted lane reading the same source, which is not true until slice 4 lands | DECISION-0036 recorded separately: bare release on a multi-target project now STOPs rather than defaulting, a deliberate exception to the no-behavior-change steer, on default-deny grounds for a lane ending in a write-scoped publisher. Its test is an open obligation because a mutant restoring the hardcoded default currently survives | confidence: high. + +## 2026-07-31 — T-28..T-31 pre-tag execution: helper vs prose (SMARTS, auto-decided) + +**Question.** DECISION-0034 requires the lane to run each declared `pre-tag` +command in order, assert exit 0, then assert a clean tree, and BLOCK on either. +Slice 2 adds a 1024-char cap. Express this as prose the agent follows, or as a +deterministic subcommand in the shipped CLI? + +**Decision: a `run-pre-tag ` subcommand in `core/pysrc/_releaselib.py`.** +Prose retained only to name the command and its exit codes. + +| lens | verdict | why | +|---|---|---| +| **Scalable** (weighted heavier per standing steer) | **helper, strong** | Four rules become one command. Token cost per release drops from a procedure to a line; the rules stop being re-derived each run. | +| Securable | helper, strong | These are operator-declared shell commands. A helper makes the cap and the clean-tree assertion mechanical rather than agent-remembered — the assertion is the thing that catches a rogue command's writes. | +| Reliable | helper, strong | Order, first-failure stop, and dirty-tree detection are deterministic. Eight blind runs showed prose-following is where this campaign's defects live. | +| Testable | helper, strong | Exit codes assert directly against fixtures; prose is only reachable through an agent exercise. | +| Maintainable | helper, moderate | One implementation across three governance plugins via sync-core, versus the same procedure restated in prose. | +| Available | tied | Neither changes what blocks a release; DECISION-0034 already fixed that. | + +**Five strong, one tied, none opposed.** The campaign evidence is decisive: every +HIGH after run 1 was an agent following prose that named a step it could not +mechanically perform. This slice's rules are exactly that shape. + +**Scope held.** The cap (2.4) is enforced in the PARSER, not the runner — it is a +declared-file defect, so it exits 4 alongside every other declared-file error +rather than inventing a code. The runner adds only two: 5 for a failed command, +6 for a dirty tree. + +**Not decided here.** Whether `rebuild` should also move behind a helper. 2.3 +only requires the clean-tree assertion precede any rebuild, which ordering in the +runner satisfies. + +## 2026-07-31 — T-51 first-release baseline: sequencing + shape (SMARTS, auto-decided) + +**Question.** Criterion 5.5 needs the lane to offer a changelog baseline at the +adoption commit instead of BLOCKing once per pre-adoption commit. Two calls: +what shape, and when to land the prose. + +**Decision 1 — a pure mechanism function plus a CLI subcommand, not prose +arithmetic.** Consistent with the T-28 ruling and the same lenses; Scalable +weighted heavier per the standing steer. The alternative (tell the agent to run +`git log --diff-filter=A` and interpret it) repeats the exact defect class ten +blind runs kept finding: prose naming a derivation nobody can perform +mechanically. `peel-tag` was public API with no CLI entry and the skill aimed +prose at a file nothing could run; that is the precedent, not a hypothetical. + +**Decision 2 — land the mechanism now, batch the prose after run 11.** A skill +edit invalidates the recorded agent-judgment proof and forces a re-run. Run 11 +is in flight against the current skill. Editing the skill now would waste it and +owe a twelfth run. T-42's interpreter-fallback prose has the same property, so +both prose edits land together in ONE batch after run 11 reports, covered by one +subsequent exercise instead of two. + +**Stated risk of decision 2, since it is the one that could go wrong.** Shipping +a mechanism with no caller is itself a defect class this campaign found (T-41f). +The mitigation is that it is sequenced, not forgotten: the prose wiring is the +next skill-touching task, and the mechanism is unreachable-but-tested until then +rather than silently half-built. If the sprint stopped here it would be a real +gap, and this entry is what makes that visible rather than discovered later. + +## 2026-07-31 — commit classification + footer scan: the last prose arithmetic (SMARTS, auto-decided) + +**Question.** Phase 1 steps 2 and 3 — Conventional-Commits classification and +the `CHANGELOG:` footer scan — are the only mechanical steps in the lane with +no helper behind them. Step 3 is the one the hard rules mark MUST-level. Leave +as prose, or ship a `classify-window` subcommand? + +**Decision: ship it.** Same shape as `run-pre-tag` and `adoption-commit`. + +| lens | verdict | why | +|---|---|---| +| **Scalable** (weighted heavier) | **helper, strong** | Replaces a per-release hand-written parse with one command. The largest single prose-arithmetic surface left in the lane. | +| Reliable | helper, strong | Run 11's agent wrote `subject.split('(')[0].split(':')[0].rstrip('!')` — its own reading. `feat!:`, `feat(scope)!:`, and a bare `BREAKING CHANGE:` footer are each handled by whatever the operator invents. **Two operators produce two different gates on the check that decides whether a release may proceed.** | +| Securable | helper, moderate | The footer BLOCK is what stops an unlogged user-facing change reaching a tag. A gate whose implementation varies per operator is not a gate. | +| Testable | helper, strong | Grammar edge cases (`!` breaking marker, scopes, multi-line bodies, footer position) become fixtures instead of being re-derived per release. | +| Maintainable | helper, moderate | One parse across three governance plugins via sync-core, versus the same grammar restated in prose. | +| Available | tied | Same BLOCK conditions either way; this changes who computes them, not what they are. | + +**Four strong/moderate, one tied, none opposed.** + +**Scope held deliberately.** The helper CLASSIFIES and REPORTS; it does not +decide the release. It emits each commit's type, whether it bumps, whether it +carries a footer, and the aggregate bump — and the skill keeps the BLOCK. A +helper that returned "proceed/stop" would move a governance decision into a +library, which ADR-0010's cooperative-agent model puts on the wrong side of +the line. + +**Sequenced like T-51.** Mechanism and CLI now (run 12 is in flight against the +current skill); the prose that wires it lands in the next skill batch. Same +stated risk as before — a mechanism with no caller is its own defect class — and +the same mitigation: it is recorded here and is the next skill-touching task, +not forgotten. + +## 2026-07-31 — slice 3 (A-3.1..A-3.5) as one row-capability helper (SMARTS, auto-decided) + +**Question.** Five criteria describe which steps a row's declared fields turn +on or off: manifest equality vs a tag-derived version, rebuild+artifacts, +payload-exclude, optional provenance. Five separate checks, or one function +that reports a row's applicable assertions? + +**Decision: one `row_assertions(row)`.** Scalable weighted heavier per the +standing steer. + +| lens | verdict | why | +|---|---|---| +| **Scalable** | **one function, strong** | Adding a sixth optional field extends one return shape rather than adding a sixth scattered check the lane must remember to call. | +| Reliable | one function, strong | The five criteria are not independent — "no manifest" implies "tag is the version source". Splitting them lets the two answers disagree; a single derivation cannot. | +| Testable | one function, strong | Each criterion becomes an assertion about one returned field, over synthetic rows, with no filesystem. | +| Maintainable | one function, moderate | The skill's prose about optional fields maps to one readable structure instead of five prose paragraphs the lane re-derives. | +| Securable | tied | No new execution surface; it reports, it does not run anything. | +| Available | tied | Same behaviour either way. | + +**Three strong, one moderate, two tied.** + +**Scope held.** It reports what APPLIES; it does not perform the assertions. +`check-manifests` already does the manifest comparison and the runner does +pre-tag. Folding execution in here would make one function both the planner and +the actor, and the campaign's own history is that the planner/actor seam is +where the defects live. + +**No skill edit.** These are mechanism criteria, so run 13's recorded proof +stays valid and no exercise is owed for this slice. + +## 2026-08-01 — SMARTS: fix-vs-file on the CodeRabbit prose findings (confidence: medium-high) + +**Decision: fix 4, file 1 (scoped), file the 45 minor/trivial.** + +- **`||` masking exit codes — SPLIT.** *Reliable* is decisive: `cmd || cmd2` + re-runs on ANY non-zero, so a meaningful exit code (run-pre-tag's 5=drift, + 6=mutation) triggers a second full execution of the declared pre-tag + commands and the final code is the SECOND run's. That is not a fallback, + it is a retry that hides the answer. *Scalable* (weighted heavy) says the + opposite about blast radius: the `python3 … || python …` spelling is the + documented cross-host convention in ~40 skills, and rewriting all of them + here buries this PR. Resolution: fix it in the release skill, where exit + codes are load-bearing by design; file the convention-wide change. +- **check-manifests ordering — FIX.** *Maintainable*: local, one step, the + prose asserts a thing before the step that makes it true. +- **context-creation Phase 5 marker — FIX (must).** *Available*: this PR's + own enrolment blocks the sanctioned writer of the file it enrolled. Same + seam class as HIGH-1. Shipping it means shipping a broken lane. +- **releasehash never invoked — FIX (must).** *Reliable/Testable*: A-2.10 + was built, tested, and wired to nothing. An inert gate reads as coverage. +- **45 minor/trivial — FILE.** *Scalable*: stale prose, table formatting and + markdownlint items. Real, none load-bearing, and batching them into a + follow-up keeps this PR reviewable. + +Weighting note: Scalable was given extra weight per standing steer, and it +is what split finding 1 rather than taking it whole. + +## 2026-08-01 — SMARTS: the CI-red trio and the flake behind it (confidence: high) + +Four red CI rows on run 30691314813, plus two more revealed once the first +was cleared, plus one flake found during the final local sweep. Three +parameter-level calls, all auto-decided. + +**Decision: fix 6, file 1.** + +- **`$PY` in the lane extractor — FIX.** *Reliable*: the skill resolves the + interpreter once and uses `$PY` everywhere after, so a replay harness that + runs single invocations never sees the resolution line. Four `setUpClass` + fixtures died on `FileNotFoundError: '$PY'`. Not a judgement call — the + harness has to follow the spelling the skill actually ships. +- **The tokenizer arm over the shell round-trip — FIX, and reshape.** + *Testable* is decisive. Spawning bare `"bash"` on windows-latest exited 1 + with both streams empty, indistinguishable from the script under test + failing. Two candidate resolutions: skip the test where no shell exists, + or assert the property without a shell. Skipping alone would retire the + assertion on the platform whose quoting differs most, which is backwards. + So the decisive check moved to `shlex` (every platform, no shell) and the + round trip became a corroborating arm behind a probe. Mutation-checked: + unquoting `show-row` dies to the tokenizer arm alone, on REBUILD and + PRE_TAG independently. + + **Correction, after CI run 30692612801.** The stated cause was wrong. The + windows job now reports `OK`, not `OK (skipped=1)` — the round trip RAN + there and passed, so the probe is not what fixed it. What fixed it was + handing `subprocess` the absolute path from `shutil.which("bash")` + instead of the bare name: without `shell=True` the lookup falls to + `CreateProcess`, whose search order is not `PATH` order and which picked + a different, non-working `bash`. The reshape stands on its own merits and + the probe is still worth having, but "windows-latest has no usable shell" + was a plausible reading of an empty error message, not a measured fact. +- **`.codearbiter/gate-events.log` spelling — FIX, not exempt.** *Scalable* + (weighted heavy): the reference-resolution design already has an arm for + project-state paths, and the bare spelling simply was not using it. Adding + a new exemption to make a bare path pass would loosen a ratchet that + exists to catch this-repo contamination; spelling it `{{PROJECT_DIR}}/…` + uses the mechanism as designed. The git pathspec inside the fenced block + stays repo-relative — a pathspec has no other correct form, and fenced + blocks are excised before extraction. +- **`bridge.test.ts` hung-tree flake — FILE (#580).** *Scalable*: it lives + in `plugins/ca-pi/tools/`, which this branch does not touch; 4 pass / 6 + fail across ten local runs, and CI has not gone red on it. The fix is a + poll-until-gone loop replacing a fixed 1,200 ms sleep — the same shape as + open flakes #504 and #535, and it belongs with them rather than widening + this PR into a third package. + +Weighting note: Scalable carried the two calls that could have gone either +way (the exemption, and the flake), in both cases against the option that +would have been faster here and worse later. + +## 2026-08-01 — Stopping rule for proof run 16, agreed BEFORE the run + +The proof record went stale at `8f101403`; runs 14 and 15 were executed but +never recorded, and both predate the run-15 remediation (`5bafba91`) and the +gate-events spelling fix (`84d8bc19`). So the currently shipped skill +(`81879e4c…`) has been exercised by no blind run at all, and a fresh one is +required — the record's own text forbids hand-editing `proof_current` to +clear the block. + +Per [[remediation-loops-do-not-converge]], the rule is fixed in advance +rather than discovered mid-loop: + +1. Run 16 is a dry evaluation. **If it reports no HIGH**: file the + MEDIUM/LOW findings and record run 16 as the proof. Done. +2. **If it reports one or more HIGH**: remediate the HIGHs only — nothing + below HIGH — then run 17 and record run 17 **whatever it returns**. +3. **On run 17, findings of every severity are FILED, not fixed.** There is + no run 18. A HIGH surviving into run 17 becomes an issue against the + next cycle, not another edit to this branch. + +The cap is what makes this terminate: at most two runs, and the second one +cannot change the skill, so its hash is the recorded proof by construction. + +## 2026-08-01 — Amendment to the run-16 stopping rule, and why it was needed + +The rule said run 17 would change nothing, so its hash would be the recorded +proof by construction. That premise did not survive contact. + +What happened: run 16's two HIGHs were remediated in `ec983fc8`, run 17 was +staged against that hash, and then CI went red on `ec983fc8` for two +references the remediation's own PROSE introduced — an ellipsis-abbreviated +path in a counter-example, and the exempted git pathspec repeated inline +where its only prior copy was inside a fenced block the extractor excises. +Fixing red CI is not optional, so `9feb5156` landed and the shipped skill +moved out from under the running exercise. Run 17 is now exercising a hash +that is no longer shipped. + +**The error was sequencing, not the rule.** An exercise whose hash must +become the proof has to be staged against a tree that is already STABLE — +CI green, no pending edits — because any forcing function that touches the +skill afterwards invalidates it. I staged run 17 concurrently with a CI +cycle that could still demand skill edits, and it did. + +**Amended rule:** + +1. Let run 17 finish and read it — it exercises the substantive remediation + and its findings are real regardless of the hash. +2. Wait for CI green on the final commit with NO skill edits pending. +3. Run the final exercise against that stable hash. It FILES everything at + every severity and changes nothing. Its hash is the recorded proof. +4. If that exercise's own CI cycle somehow demands a further skill edit, + the proof is recorded against the pre-edit hash and the delta is stated + explicitly in the record rather than papered over with another run. + +Step 4 is the actual terminator, and it is what the original rule was +missing: a named way to stop that does not require the next run to be clean. diff --git a/.codearbiter/tech-stack.md b/.codearbiter/tech-stack.md index 52fbe5b5..07e58a94 100644 --- a/.codearbiter/tech-stack.md +++ b/.codearbiter/tech-stack.md @@ -64,6 +64,14 @@ python .github/scripts/test_taskwriter.py # publish classifier, the CLI, and the SKILL.md structural wiring (_releaselib) python .github/scripts/test_release_lib.py +# A-1.11 release resolution trace — proves the portable-mechanism split +# (core/pysrc/_releaselib.py + declared release-targets.md) reproduces the +# pinned pre-change lane's resolved variables for ca and ca-pi, and states +# the one intended divergence (AC-1.12, pre-release-marker scope). Needs +# FULL git history and all tags (fetch-depth: 0, fetch-tags: true) — its own +# preflight fails loudly, not silently, if either is missing. +python .github/scripts/test_release_trace.py + # commit-gate board-sync chokepoint — Phase 6 board-edit exemption + prose # wiring (AC-04..07, ADR-0008); structural SKILL.md/command-doc assertions python .github/scripts/test_board_sync.py diff --git a/.github/scripts/_releaselib.py b/.github/scripts/_releaselib.py index 746f2396..abc1bb93 100644 --- a/.github/scripts/_releaselib.py +++ b/.github/scripts/_releaselib.py @@ -1,179 +1,234 @@ #!/usr/bin/env python3 -# codeArbiter - pure helpers backing the `release` skill's mechanical gates. +# codeArbiter - CI's stable entry point for the release lane's mechanical gates +# (issue #563, .codearbiter/specs/release-portable-fixture.md, "Migration +# ordering"). # -# The release SKILL.md is prose the orchestrator follows; these are the small, -# git-free, synthetically-testable assertions that back its load-bearing steps so -# a model lapse can't ship a wrong or half-published release. Each maps to one -# acceptance criterion of specs/release-skill-hardening.md (findings v2.release. -# 0003-0006). The skill invokes them; CI runs their tests (test_release_lib.py). +# This file is the PERMANENT shim six sites shell out to directly -- +# release.yml:135,171 and .github/actions/publish-release/action.yml:125,164, +# 180,228. It is never deleted, only slimmed down: it is a thin RE-EXPORT of +# the portable mechanism at core/pysrc/_releaselib.py, plus the small amount +# of THIS repo's data that CI needs. +# +# As of A-4.4/T-46 that data is DERIVED, not declared here: RELEASE_TARGETS +# reads .codearbiter/release-targets.md, and RELEASE_TAG_PREFIXES is gone +# entirely (payload_version_gate.py derives its own map per A-4.1, and nothing +# else consumed it). Only MERGE_READINESS_CHECK remains a literal, because it +# is a CI check-run name rather than a release-target fact and the declared +# file has no field for it. +# +# The mechanism module is loaded from core/pysrc/ directly -- not from a +# vendored plugins/*/hooks/ copy -- because this file executes inside the +# codeArbiter repo itself (CI checks out the full repo), where core/pysrc/ is +# the canonical source `tools/sync-core.py` vendors FROM. It is located via +# this file's own __file__, never the process cwd, mirroring the +# cwd-independent REPO resolution `payload_version_gate.py` already uses +# (`Path(__file__).resolve().parents[2]`) -- so this module resolves +# correctly no matter what directory it is invoked from. Both this shim and +# the mechanism module are named `_releaselib.py`, so a plain `import +# _releaselib` from inside this file would resolve to itself via the module +# cache regardless of sys.path order; loading the mechanism by explicit file +# path under a distinct internal name (`importlib.util.spec_from_file_location`, +# the same technique `test_release_lib.py` already uses for the same reason) +# avoids that collision. +# +# KNOWN RESIDUAL (M-6, adversarial review 2026-07-31): the private module +# name above means this shim's copy of the mechanism is a DISTINCT load from +# any OTHER independent load of core/pysrc/_releaselib.py in the same +# process -- e.g. a test harness's own private-named copy, or a future +# second consumer that loads the mechanism itself rather than importing this +# shim. `shim.ReleaseTargetsError is .ReleaseTargetsError` is FALSE. An `except` clause spanning that +# boundary would miss. No caller does this today. This is documented rather +# than "fixed" by reusing a same-named entry out of `sys.modules` when one +# is present: there is no single canonical name a dynamically-loaded copy is +# guaranteed to register under (this file's own tests, `test_release_lib.py`, +# and `test_release_trace.py` each pick their own private name for their own +# reasons), so keying off `sys.modules` would make this shim's behavior +# depend on import order and on what some OTHER, unrelated test file +# happened to load first -- an import-order-dependent hazard traded for a +# documented, narrow one. The sanctioned route for a caller that needs to +# catch this shim's exceptions is to import the class from THIS shim (the +# public, re-exported surface), never to load its own separate copy of +# core/pysrc/_releaselib.py and expect the two hierarchies to unify. # # Design invariants (mirror the other _*lib helpers): -# - Stdlib only; zero side effects at import (no git, no file I/O). -# - Pure functions over synthetic input; never raise on malformed input - -# degrade to the safe answer (False / the sentinel), since a release -# gate that crashes is worse than one that conservatively refuses. +# - Stdlib only; zero side effects at import beyond loading the sibling +# mechanism module (no git, no argument parsing at import time). +# - The wrapper functions below restore this repo's OLD default arguments +# (ca's `v` prefix, the declared target order, MERGE_READINESS_CHECK) so every +# existing caller -- the six shell-out sites, payload_version_gate.py, and +# this module's own CLI -- keeps working unchanged even though the +# portable mechanism's equivalents now REQUIRE the parameter that used to +# default (A-1.3: a repo default cannot survive in the portable module). # -# Public API: +# Public API (re-exported from core/pysrc/_releaselib.py, unwrapped -- no +# repo-specific default to restore): +# ReleaseTargetsError, AbsentBlockError, EmptyBlockError, MalformedBlockError, +# UnknownKeyError, DuplicateKeyError, DuplicateTargetError, +# InvalidBooleanError, MultipleBlocksError, DelimiterInValueError, +# MissingRequiredKeyError, FileExistsNoBlockError # semver_key(value) -> tuple | None # semver_greater(current, base) -> bool -# last_tag_select(tags) -> str # notes_heading_matches(notes_text, tag) -> bool # release_dates_consistent(changelog_section, tag_message) -> bool # classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, # manifest_version, release_is_nondraft) -> str -# select_release_target(*confirmations) -> str (RELEASE_TARGETS order) -# classify_merge_readiness(check_runs, head_sha, check_name) -> str # peel_tag(ls_remote_text, tag) -> str +# parse_release_targets(text) -> list[dict] +# load_targets(path) -> list[dict] +# _bare_version(tag) -> str +# NONE_SENTINEL +# +# Public API (this repo's DATA -- RELEASE_TARGETS derived from the declared +# file; see the module comment above): +# RELEASE_TARGETS, MERGE_READINESS_CHECK +# last_tag_select(tags, prefix="v") -> str +# select_release_target(*confirmations) -> str (declared order) +# select_release_target_by_name(pairs) -> str (A-4.2, order-independent) +# classify_merge_readiness(check_runs, head_sha, check_name=MERGE_READINESS_CHECK) -> str # # The last three back `.github/workflows/release.yml`'s read-only preflight and # its tag-integrity guard (issues #378, #385, #380). The hosted publish path # holds `contents: write` and its writes are public and irreversible, so every # one of them degrades to the REFUSING answer on malformed input. -import re +import importlib.util +import os +import sys -NONE_SENTINEL = "" +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE)) +_MECHANISM_PATH = os.path.join(_REPO_ROOT, "core", "pysrc", "_releaselib.py") + +_mechanism_spec = importlib.util.spec_from_file_location( + "_release_mechanism", _MECHANISM_PATH) +_mechanism = importlib.util.module_from_spec(_mechanism_spec) +# Registered in sys.modules so introspection (inspect.getsourcefile, etc.) +# resolves the loaded module's real file rather than reading it back as a +# "built-in class" with no source at all. +sys.modules[_mechanism_spec.name] = _mechanism +_mechanism_spec.loader.exec_module(_mechanism) + +# --------------------------------------------------------------------------- # +# Re-exported mechanism -- unwrapped, no repo-specific default to restore. +# --------------------------------------------------------------------------- # + +ReleaseTargetsError = _mechanism.ReleaseTargetsError +AbsentBlockError = _mechanism.AbsentBlockError +EmptyBlockError = _mechanism.EmptyBlockError +MalformedBlockError = _mechanism.MalformedBlockError +UnknownKeyError = _mechanism.UnknownKeyError +DuplicateKeyError = _mechanism.DuplicateKeyError +DuplicateTargetError = _mechanism.DuplicateTargetError +InvalidBooleanError = _mechanism.InvalidBooleanError +MultipleBlocksError = _mechanism.MultipleBlocksError +DelimiterInValueError = _mechanism.DelimiterInValueError +MissingRequiredKeyError = _mechanism.MissingRequiredKeyError +FileExistsNoBlockError = _mechanism.FileExistsNoBlockError + +# SEMVER is re-exported because `tools/build-host-packages.py:22` imports it. +# The first shim conversion dropped it while re-exporting its two neighbours, +# and nothing failed: no TEST imports SEMVER, so the re-export surface was +# covered for the names the tests use rather than the names consumers use. +# `test_shim_reexports_every_name_any_consumer_imports` now derives the +# required set from the repo rather than restating it here. +SEMVER = _mechanism.SEMVER +semver_key = _mechanism.semver_key +semver_greater = _mechanism.semver_greater +notes_heading_matches = _mechanism.notes_heading_matches +release_dates_consistent = _mechanism.release_dates_consistent +classify_publish_state = _mechanism.classify_publish_state +peel_tag = _mechanism.peel_tag +parse_release_targets = _mechanism.parse_release_targets +load_targets = _mechanism.load_targets +_bare_version = _mechanism._bare_version +NONE_SENTINEL = _mechanism.NONE_SENTINEL + +# --------------------------------------------------------------------------- # +# DATA -- DERIVED, not declared here (A-4.4/T-46). +# +# This block used to carry three literals: RELEASE_TARGETS, +# RELEASE_TAG_PREFIXES, and MERGE_READINESS_CHECK. The first two were a +# SECOND source of truth for facts `.codearbiter/release-targets.md` already +# declares, and nothing compared them: a target declared under one prefix and +# listed here under another would be gated in one namespace and released in +# the other. Adding a fifth plugin meant editing two files, and forgetting +# this one produced a KeyError at release time rather than a diagnosis. +# +# RELEASE_TARGETS is now read from the declared file, in DECLARATION order. +# RELEASE_TAG_PREFIXES is gone entirely -- payload_version_gate.py derives its +# own map (A-4.1), and nothing else consumed it. +# --------------------------------------------------------------------------- # -# The `ci-passed` aggregate in .github/workflows/ci.yml — the single check run +# The `ci-passed` aggregate in .github/workflows/ci.yml - the single check run # that means "every required job for this commit concluded green". Kept in sync # with that job's `name:` by test_release_workflow.py. +# +# NOT removed with the other two, deliberately: this is a CI check-run NAME, +# not a release-target fact, and `.codearbiter/release-targets.md` declares no +# such field. Deriving it would mean inventing a declaration source for it; +# leaving it here, named and tested against the workflow, is honest. A-4.4's +# "data constants" are the two that duplicated the declared file. MERGE_READINESS_CHECK = "[GATE ] | [REPO] | Merge readiness" -# A `ca` release tag is exactly `vMAJOR.MINOR.PATCH` - no suffix. The anchored -# form already excludes pre-releases (`v2.6.0-beta.1`) and the namespaced -# `ca-sandbox-v*` series (no leading bare `v`); PRERELEASE_MARKERS is the -# explicit, legible second line of defense the spec names. -_RELEASE_RE_CACHE = {} - - -def _release_re(prefix): - """The anchored `MAJOR.MINOR.PATCH` matcher for one release series.""" - rx = _RELEASE_RE_CACHE.get(prefix) - if rx is None: - rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") - _RELEASE_RE_CACHE[prefix] = rx - return rx -_PRERELEASE_MARKERS = ("-beta", "-rc", "-alpha") - -# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the -# Keep-a-Changelog `## [X.Y.Z] - DATE` form the repo actually ships (every -# released section + every prior GitHub Release body uses the bracket style). -# The capture is the bare `X.Y.Z`; the optional leading `v` and the surrounding -# brackets sit OUTSIDE the group, so heading comparison is style-agnostic. Any -# separator is allowed between version and date. Plus the annotated-tag -# `Released-at:` footer. -_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE) -_CHANGELOG_DATE_RE = re.compile( - r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE) -_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})") - -# Full SemVer, including the pre-release and build-metadata tails a release tag -# never carries but a MANIFEST does (`0.2.4-beta.1` shipped on ca-codex). The -# anchored `_release_re` above deliberately rejects those, because it selects a -# published release series; this one parses a version for ORDERING, which is a -# different question and needs the tail. -SEMVER = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" -) - - -def semver_key(value): - """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer. - - Non-raising per this module's invariant — a caller that needs the raising - contract wraps it (`tools/build-host-packages.py` does, to keep its own - diagnosis wording). Build metadata is parsed and discarded: SemVer §10 says - it is not part of precedence, so `1.0.0+a` and `1.0.0+b` compare equal. +DECLARED_TARGETS_PATH = os.path.join( + os.path.dirname(os.path.dirname(_HERE)), ".codearbiter", "release-targets.md") + + +def _declared_target_names(): + """Every declared target name, in DECLARATION order. + + Order still matters to the positional `select-target` subcommand this + module keeps for its own tests; the workflow itself moved to the + name-keyed form (A-4.2) precisely so a release can no longer depend on + it. Reading the order from the declaration rather than a literal means + the two cannot drift even while both forms exist. + + Raises rather than defaulting: this module is THIS repository's CI + entry point, the declared file is committed beside it, and a silent + empty register would make `select-target` resolve nothing while + looking healthy. """ - if not isinstance(value, str): - return None - match = SEMVER.fullmatch(value) - if match is None: - return None - prerelease = match.group(4) - if prerelease is None: - pre_key = None - else: - pre_key = tuple( - (0, int(part)) if part.isdigit() else (1, part) - for part in prerelease.split(".") - ) - return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key - - -def semver_greater(current, base): - """True iff `current` is a STRICT SemVer advance over `base`. - - The single definition of "advance" for every payload-version gate (#530), so - `ca`, `ca-sandbox`, `ca-codex` and `ca-pi` cannot disagree about what - advancing means — issue #530 AC-3. - - Degrades to False when either side is unparseable, which refuses the gate - rather than passing it. Pre-release ordering follows SemVer §11: a - pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric - identifiers compare numerically and rank below alphanumeric ones. + return tuple(row["target"] for row in _mechanism.load_targets( + DECLARED_TARGETS_PATH)) + + +def __getattr__(name): + """Lazy module attributes (PEP 562). + + `RELEASE_TARGETS` is DERIVED from the declared file, and deriving it + at import time would give this module a side effect at import -- which + its own design invariants forbid, and which broke every harness that + imports the shim from a tree containing the scripts but not + `.codearbiter/release-targets.md`. Resolving on first ACCESS keeps + every existing `_releaselib.RELEASE_TARGETS` caller working while an + import that never touches the name stays pure. + """ + if name == "RELEASE_TARGETS": + return _declared_target_names() + # NOTE: PEP 562 module __getattr__ serves EXTERNAL attribute access + # only. Code INSIDE this module must call `_declared_target_names()` + # directly -- a bare `RELEASE_TARGETS` here raises NameError, which is + # exactly how this was found. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def release_tag_prefixes(): + """`{target: tag prefix}`, read from the declared file (A-4.4). + + Replaces the RELEASE_TAG_PREFIXES literal this module used to carry. + That literal was a second source of truth for a fact the declared file + already states, and nothing compared them -- a target declared under + one prefix and listed here under another would be gated in one + namespace and released in the other. + + A FUNCTION rather than a module constant on purpose: a constant would + freeze the declared file's contents at import time, so a test that + writes a fixture declaration and re-reads it would keep seeing the + first load. Callers that want the snapshot can bind it themselves. """ - current_key = semver_key(current) - base_key = semver_key(base) - if current_key is None or base_key is None: - return False - if current_key[:3] != base_key[:3]: - return current_key[:3] > base_key[:3] - current_pre, base_pre = current_key[3], base_key[3] - if current_pre is None: - return base_pre is not None - if base_pre is None: - return False - return current_pre > base_pre - - -# Every plugin that has a sanctioned release lane, in dispatch-input order -# (#382). The names are the labels `select_release_target` returns and the -# values release.yml's publisher `if:` conditions compare against; adding a -# plugin here without adding its publisher job turns the workflow contract -# suite red rather than resolving a target nothing can publish. -RELEASE_TARGETS = ("ca", "ca-codex", "ca-sandbox", "ca-pi") - -# Each target's tag namespace. `ca` owns the bare `v*` series as the repository's -# primary release; every sibling is namespaced so it cannot collide with it. The -# ANCHORED match built from these prefixes is also what keeps one series from -# resolving another's tag as its own baseline — `^v` cannot match `ca-pi-v0.1.30` -# — so series isolation is a property of the match rather than an exclusion list -# somebody has to remember to extend. release.yml's per-lane `tag-prefix` inputs -# are asserted against this map by the workflow contract suite, so the hosted -# lane and the /ca:release command cannot disagree about a namespace. -RELEASE_TAG_PREFIXES = { - "ca": "v", - "ca-codex": "ca-codex-v", - "ca-sandbox": "ca-sandbox-v", - "ca-pi": "ca-pi-v", -} - - -def _bare_version(tag): - """`v2.6.0` / `[2.6.0]` / `2.6.0` / `ca-pi-v0.1.31` -> the bare SemVer. - - Lets the heading match compare a tag against a bracket-style changelog - heading without caring about either spelling. - - This used to be `tag.lstrip("v")`, which strips only a LEADING "v" — right - for ca's bare `v2.9.1`, and wrong for every namespaced sibling, because - `"ca-pi-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31` - parsed out of the heading. The hosted publish action treats a failed - `notes-match` as a STOP, so the ca-codex, ca-sandbox and ca-pi lanes could - not have completed a release at all: they would have aborted at that guard - on a perfectly correct changelog, every time. Nothing caught it because the - lanes had never been dispatched and every test used a bare `v` tag. - - Anchored on the SemVer at the END rather than by stripping a known prefix, - so a fifth plugin's namespace works without being enumerated here.""" - if not isinstance(tag, str): - return tag - text = tag.strip().strip("[]") - match = re.search(r"(\d+\.\d+\.\d+.*)$", text) - return match.group(1) if match else text.lstrip("v") + return {row["target"]: row["prefix"] + for row in _mechanism.load_targets(DECLARED_TARGETS_PATH)} def last_tag_select(tags, prefix="v"): @@ -181,94 +236,12 @@ def last_tag_select(tags, prefix="v"): pre-releases (`-beta`/`-rc`/`-alpha`). Returns NONE_SENTINEL when the series has no release tag yet. - `prefix` selects the series and defaults to `"v"` — ca, the primary release — - so every existing caller keeps its behaviour unchanged. Pass a value from - RELEASE_TAG_PREFIXES for a sibling (#382, the /ca:release command half). - - This is the single source of `LAST_TAG`, replacing the skill's inline grep - one-liner: bare `git describe --tags` returns the nearest tag by commit-graph - ANCESTRY, which in a multi-plugin repo is routinely another plugin's tag, and - silently bases an entire release on the wrong baseline. - - Series isolation is a property of the ANCHORED match rather than a list of - exclusions to maintain: `^v` cannot match `ca-pi-v0.1.30`, and `^ca-pi-v` - cannot match `v2.9.1`. A fifth plugin therefore cannot leak into an existing - series by being forgotten in an exclusion list.""" - best = None # ((major, minor, patch), original_tag) - if not isinstance(tags, (list, tuple)): - return NONE_SENTINEL - if not isinstance(prefix, str) or not prefix: - return NONE_SENTINEL - matcher = _release_re(prefix) - for t in tags: - if not isinstance(t, str): - continue - if any(marker in t for marker in _PRERELEASE_MARKERS): - continue - m = matcher.match(t) - if not m: - continue - ver = tuple(int(g) for g in m.groups()) - if best is None or ver > best[0]: - best = (ver, t) - return best[1] if best else NONE_SENTINEL - - -def notes_heading_matches(notes_text, tag): - """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z` - or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as `tag`. A - stale notes-file (whose first section is an older version) returns False, so - the release skill cannot publish the wrong changelog section under the right - tag. Missing heading or non-string input -> False.""" - if not isinstance(notes_text, str) or not isinstance(tag, str): - return False - m = _HEADING_RE.search(notes_text) - if not m: - return False - return m.group(1) == _bare_version(tag) - - -def release_dates_consistent(changelog_section, tag_message): - """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE` or - `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in `tag_message`. - Guards against the date being - hand-typed inconsistently across surfaces. Either date absent, or non-string - input -> False.""" - if not isinstance(changelog_section, str) or not isinstance(tag_message, str): - return False - cm = _CHANGELOG_DATE_RE.search(changelog_section) - tm = _RELEASED_AT_RE.search(tag_message) - if not cm or not tm: - return False - return cm.group(1) == tm.group(1) - - -def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, - manifest_version, release_is_nondraft): - """Classify a (re)publish attempt so the skill can resume a half-finished - publish instead of dead-ending on 'tag exists -> STOP'. Returns one of: - - publish_fresh - no tag yet; the normal Phase 2/3 path. - already_published - the tag is at HEAD and a non-draft Release exists. - resume_publish - tag is at HEAD and its version matches the manifest, - but no non-draft Release exists (tag pushed, Release - never created) -> finish Phase 3. - abort_mismatch - tag points at a non-HEAD commit, or its version - disagrees with the manifest -> STOP, never overwrite. - - Mismatch OUTRANKS publication state (issue #380). An existing Release used - to short-circuit to `already_published` before the tag was compared to - HEAD, so a resumed publish silently accepted a Release whose tag installs a - different snapshot. The tag is what consumers actually fetch; if it does - not name this commit, nothing about the Release makes the state safe. - """ - if not tag_exists: - return "publish_fresh" - if tag_sha != head_sha or tag_version != manifest_version: - return "abort_mismatch" - if release_is_nondraft: - return "already_published" - return "resume_publish" + `prefix` defaults to `"v"` - ca, the primary release - so every existing + caller (the CLI's bare `last-tag` invocation, this repo's tooling) keeps its + behaviour unchanged even though the portable mechanism's own + `last_tag_select` now REQUIRES the prefix (A-1.3). Pass a value from + the declared prefix for a sibling (#382).""" + return _mechanism.last_tag_select(tags, prefix) def select_release_target(*confirmations): @@ -281,33 +254,11 @@ def select_release_target(*confirmations): multiple - more than one; the dispatch is ambiguous and MUST be refused. arity - the caller passed the wrong NUMBER of inputs. - Issue #378: the publish jobs each tested only their OWN confirmation input, - so one dispatch supplying both started two `contents: write` publishers and - could create two tags and two public Releases. Selection is one decision, - made once, by a job that holds no write token. Blank-ish input (whitespace, - non-string) counts as "not selected" so a stray space can never read as a - second target. - - Issue #382 widened this from two plugins to four (ca, ca-codex, ca-sandbox, - ca-pi). The count is checked rather than zipped-to-shortest on purpose: a - caller wired for two would otherwise resolve `ca` from a dispatch that also - selected ca-pi, silently publishing the wrong plugin. `arity` is not a - target and matches no `case` arm in release.yml, so the workflow's - fail-closed `*)` default refuses it - and, like every other return here, - it is a LABEL rather than an exception, so the caller's contract of "prints - a label and never raises" holds.""" - def _selected(value): - return isinstance(value, str) and value.strip() != "" - - if len(confirmations) != len(RELEASE_TARGETS): - return "arity" - selected = [target for target, value in zip(RELEASE_TARGETS, confirmations) - if _selected(value)] - if len(selected) > 1: - return "multiple" - if selected: - return selected[0] - return "none" + Delegates to the portable mechanism's `select_release_target`, supplying + this repo's RELEASE_TARGETS register - which the portable module now + REQUIRES rather than assumes (A-1.3).""" + return _mechanism.select_release_target( + *confirmations, targets=_declared_target_names()) def classify_merge_readiness(check_runs, head_sha, check_name=MERGE_READINESS_CHECK): @@ -322,59 +273,17 @@ def classify_merge_readiness(check_runs, head_sha, check_name=MERGE_READINESS_CH not_successful - completed with any conclusion other than `success` (failure, cancelled, skipped, timed_out, neutral, ...). - Issue #385: the hosted release workflow proved only that it was dispatched - from main. Branch protection shows how a commit ENTERED main, not that - post-merge evidence exists for the exact commit about to be tagged, and the - release skill's hard rules say MUST NOT tag on a red suite. - - Fail-closed throughout: unparseable input is `missing`, and several runs - share one name only when a re-run is in flight - we cannot tell which - verdict is authoritative, so EVERY matching run must be green.""" - if not isinstance(check_runs, list): - return "missing" - matching = [run for run in check_runs - if isinstance(run, dict) and run.get("name") == check_name] - if not matching: - return "missing" - if any(run.get("head_sha") != head_sha for run in matching): - return "sha_mismatch" - if any(run.get("status") != "completed" for run in matching): - return "pending" - if any(run.get("conclusion") != "success" for run in matching): - return "not_successful" - return "green" - - -def peel_tag(ls_remote_text, tag): - """Resolve the COMMIT a remote tag names, from `git ls-remote --tags` - output. Returns "" when the tag is absent. - - An annotated tag's own object id is not the commit it points at; the - peeled `refs/tags/^{}` line is. Issue #380: the workflow treated any - remote hit as a resumable publish and skipped tag creation without ever - comparing the tag to `GITHUB_SHA`, so a stale tag could be accepted as a - successful rerun and a Release published for the wrong commit. Matching is - exact on the ref name, so `v2.6.0` is never resolved from `v2.6.0-beta.1`.""" - if not isinstance(ls_remote_text, str) or not isinstance(tag, str): - return "" - direct = peeled = "" - ref = f"refs/tags/{tag}" - for line in ls_remote_text.splitlines(): - parts = line.split() - if len(parts) != 2: - continue - sha, name = parts - if name == ref + "^{}": - peeled = sha - elif name == ref: - direct = sha - return peeled or direct + `check_name` defaults to MERGE_READINESS_CHECK so every existing caller + keeps its behaviour unchanged even though the portable mechanism's own + `classify_merge_readiness` now REQUIRES it (A-1.3).""" + return _mechanism.classify_merge_readiness(check_runs, head_sha, check_name) # --------------------------------------------------------------------------- # -# Thin CLI so the release SKILL.md can shell out to the pinned logic, the same -# way it already calls check_badge_consistency.py. Pure dispatch over the -# functions above; reads tags from stdin / files from argv. Never raises. +# Thin CLI so release.yml / publish-release/action.yml can shell out to the +# pinned logic, the same way they already call check_badge_consistency.py. +# Pure dispatch over the functions above; reads tags from stdin / files from +# argv. Never raises. # --------------------------------------------------------------------------- # def _read(path): @@ -417,7 +326,7 @@ def main(argv): if cmd == "tag-prefix" and len(rest) == 1: # One source of truth for a namespace: the release skill asks for the # prefix rather than restating four of them in prose. - prefix = RELEASE_TAG_PREFIXES.get(rest[0]) + prefix = release_tag_prefixes().get(rest[0]) if prefix is None: sys.stderr.write(f"unknown release target: {rest[0]}\n") return 2 @@ -433,9 +342,22 @@ def main(argv): tag_exists=b(rest[0]), tag_sha=rest[1], head_sha=rest[2], tag_version=rest[3], manifest_version=rest[4], release_is_nondraft=b(rest[5]))) return 0 - if cmd == "select-target" and len(rest) == len(RELEASE_TARGETS): + if cmd == "select-target" and len(rest) == len(_declared_target_names()): print(select_release_target(*rest)) return 0 + if cmd == "select-target-named" and rest: + # A-4.2/T-44b. Each argument is `name=value`, so selection never + # depends on the ORDER the workflow happens to pass its inputs in. + # The positional form above aligns by index against + # RELEASE_TARGETS: correct only while the workflow's input order + # and the declared row order agree, which nothing enforced. A row + # inserted at the front shifts every confirmation by one and the + # dispatch publishes the wrong plugin, holding a contents:write + # token, with every downstream check passing because the wrong + # release is internally consistent. + print(_mechanism.select_release_target_by_name( + list(rest), _declared_target_names())) + return 0 if cmd == "merge-readiness" and len(rest) == 2: import json try: diff --git a/.github/scripts/build_release_targets_provenance.py b/.github/scripts/build_release_targets_provenance.py new file mode 100644 index 00000000..7883e8f6 --- /dev/null +++ b/.github/scripts/build_release_targets_provenance.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Generate `.codearbiter/.provenance/release-targets.json` (A-5.6, T-52). + +The declared release-targets file names other files — each `manifest`, each +`changelog`, each `artifacts` entry. Nothing watched whether those files +still existed at the paths the rows claim. A renamed manifest leaves the row +pointing at nothing, and the release lane discovers it at tag time. + +This records those paths as provenance drift triggers, so a move surfaces on +the next `/ca:standup` sweep instead of during a release. + +WHY NOT A CONTEXT.md-SCOPE TRIGGER. `compute_drift` compares whole-file git +oids and has no section-level machinery. A Scope trigger would fire on an +unrelated `stage:` flip and stay SILENT when a manifest path moves — wrong +in both directions at once. The paths themselves are the honest triggers. + +ROUTINE BUMPS TRIP THIS BY DESIGN. Every release edits a manifest and a +changelog, which are exactly the files this watches, so the triggers go +stale on every release. `heal_worklist` re-baselines them in the same +release commit. That is intended, and is written down here so a later +maintainer does not delete the triggers to quiet the noise. + +Usage: + python3 .github/scripts/build_release_targets_provenance.py # write + python3 .github/scripts/build_release_targets_provenance.py --check # verify + +`--check` is non-mutating and exits 1 when the record's PATH SET disagrees +with the declared file. It deliberately does NOT compare hashes: those drift +on every release by design, and a check that failed on intended drift would +be turned off within a week. +""" +from __future__ import annotations + +import argparse +import importlib.util +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +RECORD = REPO / ".codearbiter" / ".provenance" / "release-targets.json" +DECLARED = REPO / ".codearbiter" / "release-targets.md" + + +def _load(name, relpath): + # core/pysrc must be on sys.path before exec: `_provenancelib` imports + # its siblings (`_gitexec`, `_hooklib`) by bare name, the way it does + # when vendored into a plugin's flat hooks/ directory. Loading it by + # path alone gets as far as the first sibling import and then fails. + pysrc = str(REPO / "core" / "pysrc") + if pysrc not in sys.path: + sys.path.insert(0, pysrc) + spec = importlib.util.spec_from_file_location(name, REPO / relpath) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def build(root: Path = REPO): + """The record this repo's declared file implies, with live hashes.""" + releaselib = _load("_rtp_releaselib", "core/pysrc/_releaselib.py") + provenancelib = _load("_rtp_provenancelib", "core/pysrc/_provenancelib.py") + + rows = releaselib.load_targets(str(root / ".codearbiter" / "release-targets.md")) + paths = releaselib.provenance_trigger_paths(rows) + + previous_cwd = os.getcwd() + os.chdir(root) + try: + hashes = provenancelib.batch_hash(paths) + finally: + os.chdir(previous_cwd) + + entries = [] + for path in paths: + digest = hashes.get(path) + if digest is None: + # A declared path that git does not know is exactly the drift + # this record exists to surface, so it is recorded with a null + # hash rather than dropped -- `compute_drift` then reports it + # as "missing" instead of the row silently losing its trigger. + entries.append({"path": path, "hash": None, "drift_trigger": True}) + continue + entries.append({"path": path, "hash": digest, "drift_trigger": True}) + return provenancelib.new_record("release-targets", entries=entries), provenancelib + + +def declared_paths(root: Path = REPO): + """Every path the declared release rows make provenance-relevant. + + Derived from the declared file through `provenance_trigger_paths`, never + listed here: a second hand-maintained copy of "which paths matter" is a + copy that goes stale the first time a row gains a manifest or an + artifact, and it would go stale silently, because nothing compares the + two. + """ + releaselib = _load("_rtp_releaselib_paths", "core/pysrc/_releaselib.py") + rows = releaselib.load_targets(str(root / ".codearbiter" / "release-targets.md")) + return releaselib.provenance_trigger_paths(rows) + + +def check(root: Path = REPO, record_path: Path = None): + """Errors (empty means the record's path set matches the declaration).""" + record_path = record_path or (root / ".codearbiter" / ".provenance" + / "release-targets.json") + provenancelib = _load("_rtp_provenancelib_check", "core/pysrc/_provenancelib.py") + record = provenancelib.read_provenance(str(record_path)) + if record is None: + return [f"missing or unreadable provenance record: {record_path}"] + if not provenancelib.valid_provenance_record(record): + return [f"{record_path} is not a well-formed v1 provenance record"] + + recorded = {e.get("path") for e in record.get("entries", []) + if isinstance(e, dict) and e.get("drift_trigger") is True} + expected = set(declared_paths(root)) + errors = [] + for path in sorted(expected - recorded): + errors.append(f"declared but not recorded as a drift trigger: {path}") + for path in sorted(recorded - expected): + errors.append(f"recorded as a drift trigger but no longer declared: {path}") + return errors + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="verify without writing; exit 1 on disagreement") + arguments = parser.parse_args(argv) + + if arguments.check: + errors = check() + if errors: + print("::error::release-targets provenance disagrees with the " + "declared file:") + for error in errors: + print(" - " + error) + return 1 + print("release-targets provenance covers every declared path") + return 0 + + record, provenancelib = build() + provenancelib.write_provenance(str(RECORD), record) + print(f"wrote {RECORD.relative_to(REPO)} " + f"({len(record['entries'])} drift trigger(s))") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_command_catalog.py b/.github/scripts/check_command_catalog.py new file mode 100644 index 00000000..b74f5e6f --- /dev/null +++ b/.github/scripts/check_command_catalog.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Fail when the canonical command catalog drifts from the repo (T-34, AC-2.8). + +A dedicated, non-mutating pre-tag check for the `ca` release target +(`.codearbiter/release-targets.md`), declared alongside +`check_badge_consistency.py` — that script already asserts this same pair of +invariants as part of a broader README-badge check, but the release skill +(`core/surface/skills/release/SKILL.md`) delegates ALL of a target's extra +release-surface consistency to whatever its row's declared `pre-tag` +commands assert (DECISION-0034), and this repo's own `ca` row declares this +script as its own, separate line — so it must exist on its own, not only as +a side effect of the badge check. + +Invariants enforced (both derived from the repo, never hand-asserted): + 1. The canonical catalog (plugins/ca/COMMANDS.md) enumerates exactly the + command files under plugins/ca/commands/ (excluding INDEX). + 2. The README full-catalog table has a row for every command file (the + historical /ca:task omission this whole check family exists to catch). + +Mutates nothing — reads three files, prints a report, and exits 0 or 1. + +Run: python .github/scripts/check_command_catalog.py (exit 1 on any drift) +""" +import re +import sys +from pathlib import Path + + +def command_file_slugs(root): + d = root / "plugins" / "ca" / "commands" + return {p.stem for p in d.glob("*.md") if p.stem.upper() != "INDEX"} + + +def parse_ca_slugs(text): + """`/ca:` tokens that appear in markdown table rows (lines starting + with `|`) — the same extraction `check_badge_consistency.py` uses, so the + two checks can never disagree about what counts as a catalog row.""" + slugs = set() + for line in text.splitlines(): + if line.lstrip().startswith("|"): + slugs.update(re.findall(r"/ca:([a-z][a-z0-9-]*)", line)) + return slugs + + +def consistency_errors(cmd_file_slugs, catalog_slugs, readme_table_slugs): + errors = [] + if catalog_slugs != cmd_file_slugs: + missing = cmd_file_slugs - catalog_slugs + extra = catalog_slugs - cmd_file_slugs + errors.append( + "canonical COMMANDS.md catalog drift — missing: %s extra: %s" + % (sorted(missing), sorted(extra)) + ) + missing_rows = cmd_file_slugs - readme_table_slugs + if missing_rows: + errors.append( + "README full-catalog table missing a row for: %s" % sorted(missing_rows) + ) + return errors + + +def check(root): + root = Path(root) + readme = (root / "README.md").read_text(encoding="utf-8") + catalog = (root / "plugins" / "ca" / "COMMANDS.md").read_text(encoding="utf-8") + cmd_slugs = command_file_slugs(root) + return consistency_errors( + cmd_file_slugs=cmd_slugs, + catalog_slugs=parse_ca_slugs(catalog), + readme_table_slugs=parse_ca_slugs(readme), + ) + + +def main(): + root = Path(__file__).resolve().parents[2] + errors = check(root) + if errors: + print("::error::command catalog drift — reconcile before tagging:") + for e in errors: + print(" - " + e) + return 1 + print("command catalog consistent with the repo") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_skill_portability.py b/.github/scripts/check_skill_portability.py new file mode 100644 index 00000000..b0e3cd1c --- /dev/null +++ b/.github/scripts/check_skill_portability.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Fail when a shipped skill names a THIS-REPO path it executes or reads +(A-6.1, T-68a/b). + +A skill under `core/surface/skills/**` renders into every governance +plugin and ships to consumers who do not have this repository. A line +telling such a consumer to run `.github/scripts/something.py`, or to +dispatch `tools/farm.js`, names a file that exists here and nowhere in +their install — so the instruction is unfollowable, and it fails at the +moment somebody tries to follow it rather than at review. + +THE MATCHING RULE, stated here because A-6.1 requires the guard to state +it in its own docstring: + + FLAGGED — a repo-relative path to an EXECUTABLE or READABLE artifact, + appearing inside backticks, whose first segment is one of the known + repo roots below (`.github/`, `tools/`, `core/`, `plugins/`, `site/`) + and which is not prefixed by a host-resolved variable. + + PERMITTED — + * `${CLAUDE_PLUGIN_ROOT}/...` and `${CLAUDE_PROJECT_DIR}/...`, plus + their `{{PLUGIN_ROOT}}` / `{{PROJECT_DIR}}` template spellings: + these resolve in the consumer's install, which is the whole point. + * A repo-path PATTERN inside a scan-target list. A scout that says + "look under `src/`, `lib/`, `tools/`" is describing where to search + in the CONSUMER'S repo, not naming a file to run. Distinguished by + the surrounding line naming a scan/scout/search, not by the path. + * A path named as PROSE about this repository's own CI, when the line + marks it as such (see `_is_conditional_ci_reference`) — a skill may + truthfully say "CI enforces this too" without instructing anyone to + run it. + + The distinction is EXECUTES-OR-READS versus MENTIONS. A guard that + flagged every occurrence of a repo path would forbid a skill from + explaining its own governance, which is not the failure being closed. + +Run: python .github/scripts/check_skill_portability.py (exit 1 on a finding) +""" +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +SKILLS = REPO / "core" / "surface" / "skills" + +# First path segments that only exist in THIS repository's layout. +REPO_ROOTS = (".github/", "tools/", "core/", "plugins/", "site/") + +# Host-resolved prefixes: these are the sanctioned way to name something a +# consumer actually has. +RESOLVED_PREFIXES = ("${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PROJECT_DIR}", + "{{PLUGIN_ROOT}}", "{{PROJECT_DIR}}") + +# Extensions that make a path an artifact to RUN or READ rather than a +# directory being described. +# `.md` belongs here even though it is not executable: the rule this guard +# states is EXECUTES-**OR-READS**, and a skill telling a consumer to read +# `core/surface/skills/release/SKILL.md` names a file they do not have just +# as surely as one telling them to run a script. Omitting it meant the +# guard's stated rule was wider than its behaviour. +ARTIFACT_SUFFIXES = (".py", ".js", ".mjs", ".ts", ".sh", ".json", ".yml", + ".yaml", ".md") + +_BACKTICKED = re.compile(r"`([^`]+)`") + +# A line describing where to SEARCH, not what to run. +_SCAN_CONTEXT = re.compile( + r"\b(scan|scout|search|glob|look under|candidate|inventory|walk)\b", re.I) + +# A line presenting a path as this repo's own CI, not as an instruction. +_CI_CONTEXT = re.compile( + r"\b(in CI|CI[- ]only|CI enforces|this repository'?s own|when run inside " + r"this repo|not shipped)\b", re.I) + + +def _is_conditional_ci_reference(line): + """True when the line frames the path as this repo's CI rather than as + something the reader should execute.""" + return bool(_CI_CONTEXT.search(line)) + + +def _is_scan_target(line): + """True when the line describes WHERE TO SEARCH rather than what to run. + + The sibling `_is_conditional_ci_reference` carried the reasoning for its + exemption and this one did not, which made the pair look like one + considered rule and one arbitrary one. + """ + return bool(_SCAN_CONTEXT.search(line)) + + +def _candidate_paths(span): + """Repo-rooted artifact paths inside one backticked span.""" + found = [] + for token in re.split(r"[\s(),;|]+", span.strip()): + token = token.strip("`'\"<>[]") + if not token: + continue + if any(token.startswith(prefix) for prefix in RESOLVED_PREFIXES): + continue + if any(prefix in token for prefix in RESOLVED_PREFIXES): + # e.g. `python3 ${CLAUDE_PLUGIN_ROOT}/hooks/x.py` — resolved. + continue + # `./tools/farm.js` is the same instruction as `tools/farm.js`, and + # the prefix test below is literal, so without this the guard could + # be bypassed (or accidentally evaded) by two characters. Repeatable, + # for `././`. + while token.startswith("./"): + token = token[2:] + if not token.startswith(REPO_ROOTS): + continue + if not token.endswith(ARTIFACT_SUFFIXES): + continue + found.append(token) + return found + + +def scan_file(path, text=None): + """Findings for one skill file: `[(lineno, path, line)]`.""" + if text is None: + text = Path(path).read_text(encoding="utf-8") + findings = [] + for number, line in enumerate(text.splitlines(), 1): + if _is_scan_target(line) or _is_conditional_ci_reference(line): + continue + for span in _BACKTICKED.findall(line): + for candidate in _candidate_paths(span): + findings.append((number, candidate, line.strip())) + return findings + + +def scan(root=SKILLS): + """`{display_path: [(lineno, path, line)]}` for every skill under `root`. + + The public entry point (`main` and the tests both call this, not + `scan_file`). `root` is a PARAMETER rather than the module constant so a + test can point it at a synthetic tree and prove the guard can still + fail — a guard exercised only against a clean repo asserts nothing. + """ + findings = {} + for dirpath, _dirnames, filenames in os.walk(root): + for name in sorted(filenames): + if not name.endswith(".md"): + continue + full = Path(dirpath) / name + hits = scan_file(full) + if not hits: + continue + # Display path relative to the repo when the file is inside it, + # and relative to the scan root otherwise. `relative_to(REPO)` + # alone RAISES for any root outside this tree, which made the + # function unusable against a synthetic fixture -- i.e. against + # the one test that proves the guard can still fail. + try: + shown = full.relative_to(REPO) + except ValueError: + shown = full.relative_to(root) + findings[str(shown).replace("\\", "/")] = hits + return findings + + +def main(): + findings = scan() + if not findings: + print("skill portability: no shipped skill names a this-repo path " + "it executes or reads") + return 0 + print("::error::a shipped skill names a THIS-REPO path a consumer will " + "not have:") + for relative, hits in sorted(findings.items()): + for number, candidate, line in hits: + print(f" {relative}:{number}: {candidate}") + print(f" {line[:140]}") + print(" Fix by resolving it under ${CLAUDE_PLUGIN_ROOT}/ or " + "${CLAUDE_PROJECT_DIR}/, or by rewording it as a statement about " + "this repository's CI rather than an instruction.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_skill_proof_fresh.py b/.github/scripts/check_skill_proof_fresh.py new file mode 100644 index 00000000..b00dec9c --- /dev/null +++ b/.github/scripts/check_skill_proof_fresh.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Fail when the recorded agent-judgment proof no longer covers the shipped +release skill (T-79, .codearbiter/plans/portable-release-and-protected- +state.md; AC-6.8, .codearbiter/specs/release-portable-fixture.md). + +Non-mutating: reads the proof artifact and the shipped skill, prints a +report, and exits 0 or 1. Never writes anything. + +WIRING (now done). This script is a declared `pre-tag` command on the `ca` +row of `.codearbiter/release-targets.md` (DECISION-0034: check-only, +non-mutating pre-tag commands), modeled on the existing +`check_badge_consistency.py` row. + +It was deliberately left unwired when first written, because the artifact +it reads (`.codearbiter/reports/agent-lane-proof.json`) then recorded +`proof_current: false`: the first agent-judgment exercise had run against a +pre-remediation skill, and every finding it drove had since edited that +skill. Wiring it then would have shipped a permanently-red command on +`ca`'s row, violating this repo's own "declared rows run green" bar. + +Three blind exercises were run in total, each against the skill as the +previous one left it, finding 4 HIGHs, then 2, then 0. The third run is +what made the artifact recordable: it found no HIGH-severity defect, so +its hash could be written as a current proof without a pending fix +immediately invalidating it. That hash is what this script now enforces. + +The standing consequence, stated plainly: ANY edit to +`plugins/ca/skills/release/SKILL.md` now fails `ca`'s pre-tag gate until a +fresh exercise is run and the artifact refreshed. That is the point, not a +side effect. Hand-editing `proof_current` back to true without re-running +the exercise defeats the only check in this repo that covers whether an +agent can actually follow the release prose — a property all three runs +showed a green mechanical suite does not imply. + +WHICH PAYLOAD THIS PROOF COVERS, AND WHY +----------------------------------------- +The release skill ships as FIVE rendered payloads (core/hosts.json's three +governance hosts, times two source templates — see the "Source of truth" +table in .codearbiter/specs/release-portable-fixture.md, and +`_RELEASE_SKILL_PAYLOADS` in .github/scripts/test_consumer_smoke.py, which +enumerates the same five for a different purpose). THREE carry the skill's +full prose, rendered from the one shared source template +`core/surface/skills/release/SKILL.md`: + + - plugins/ca/skills/release/SKILL.md (claude) + - plugins/ca-codex/routines/release/SKILL.md (codex) + - plugins/ca-pi/routines/release/SKILL.md (pi) + +TWO are thin per-host router stubs rendered from `commands/release.md` +(`plugins/ca-codex/skills/ca-release/SKILL.md`, +`plugins/ca-pi/skills/ca-release/SKILL.md`); they carry no prose of their +own — proven directly by +`test_consumer_smoke.py::test_stub_release_skills_contribute_no_unresolved_refs` +— so a prose-freshness hash has nothing of substance to say about them. + +The T-78 agent-judgment exercise (`.codearbiter/reports/agent-lane- +proof.json`) was run EXACTLY ONCE, by handing an agent the INSTALLED +`claude`-host rendering (`plugins/ca/skills/release/SKILL.md`) with no +other artifact and no expected-outcome briefing. That is the only +rendering any agent has actually read and acted on, so it is the only +rendering this checker's hash can honestly attest to. The artifact records +exactly one proof-grade hash pair +(`exercise.exercised_skill_path`/`exercise.exercised_skill_sha256`); its +separate `post_remediation_skill_sha256` block, which additionally records +hashes for the `ca-codex`/`ca-pi` routine copies and the `core/surface/` +source template, is explicitly self-disclaimed inside the artifact as "NOT +a proof hash" and is deliberately never read as a proof source here. + +This is a NARROW scope, stated rather than silently assumed. The +`ca-codex`/`ca-pi` routine renderings share the same `core/surface/` source +prose as `ca`'s, and are proven byte-consistent with it — unconditionally, +on every PR — by `tools/sync-core.py --check` / `tools/build-surface.py +--check` elsewhere in this repo's CI. But neither has been independently +exercised by an agent, and this script does not fabricate that coverage by +hashing a payload nobody has actually read. A prose edit that desynced +`claude`'s rendering from the other two without tripping build-surface +--check would not be caught here either; that is a build-surface defect, +guarded by its own suite, not this one's job. Extending the agent-judgment +proof itself to the other two hosts needs a second (and third) exercise +run and a second (and third) recorded hash — a future, explicit widening of +the artifact schema, not something this script should silently claim it +already does. + +The candidate PAYLOAD SET (which paths are release-skill payloads, and +which host renders the recorded path) is DERIVED from core/hosts.json via +tools/build-surface.py's own descriptor-resolution logic +(`load_host_descriptors` + `_output_rel`), never hardcoded as a literal +path list. `.github/scripts/known-unresolved-refs.txt`'s ratchet +under-scoped itself exactly this way, twice, before the spec's "Source of +truth" table caught it (missed `ca-pi`'s routines copy, then missed +`ca-codex`'s too) — this script does not repeat that mistake for its own, +narrower, single-recorded-path check. + +FAILURE MODES (each is a distinguishable, declared reason; every one is +exercised by a revert-and-restore test in test_check_skill_proof_fresh.py) +-------------------------------------------------------------------------- +Returns at least one error string, never raising, when: + - the artifact file at `.codearbiter/reports/agent-lane-proof.json` is + missing or unreadable + - the artifact is not parseable JSON + - `proof_current` is not literally `true` (missing, `false`, or any other + value) + - the artifact's `exercise` object is missing, or is missing + `exercised_skill_path` / `exercised_skill_sha256` + - `exercised_skill_path` does not name a payload that core/hosts.json's + descriptors currently render from `skills/release/SKILL.md` (the + source template may have moved, or the recorded path was never a real + payload) + - the file at `exercised_skill_path` no longer exists on disk + - the current sha256 of that file's raw bytes does not equal + `exercised_skill_sha256` + +Run: python .github/scripts/check_skill_proof_fresh.py (exit 1 on drift) +""" +import hashlib +import importlib.util +import json +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +ARTIFACT_PATH = REPO_ROOT / ".codearbiter" / "reports" / "agent-lane-proof.json" +_BUILD_SURFACE_PATH = REPO_ROOT / "tools" / "build-surface.py" + +# The one source template every full-prose release-skill payload renders +# from. Rendered path differs per host (`_output_rel`); the source path +# does not. +_RELEASE_SKILL_SOURCE_REL = "skills/release/SKILL.md" + + +class ProofFreshnessError(Exception): + """The payload derivation itself no longer holds — a declared, named + reason, never a silent skip.""" + + +def _load_build_surface(): + """Load tools/build-surface.py by path, the same pattern + test_ci_impact.py already uses for its own tools/ dependencies.""" + spec = importlib.util.spec_from_file_location( + "check_skill_proof_fresh_build_surface", _BUILD_SURFACE_PATH + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def full_prose_release_skill_payloads(repo=REPO_ROOT, build_surface=None): + """{plugin-relative path (with plugin_dir): host name} for every + FULL-PROSE release-skill payload, derived from core/hosts.json's + descriptor resolution of `skills/release/SKILL.md` — never hardcoded + (see the module docstring's "WHICH PAYLOAD" section). + + Deliberately excludes the two per-host router STUBS + (`skills/ca-release/SKILL.md`, rendered from a DIFFERENT source + template, `commands/release.md`): they carry no prose of their own and + are out of scope for a prose-freshness hash. + + `build_surface` is injectable for testing against a synthetic + descriptor set with no dependency on core/hosts.json; defaults to the + real tools/build-surface.py loaded by path. + """ + build_surface = build_surface or _load_build_surface() + descriptors = build_surface.load_host_descriptors(repo) + payloads = {} + for descriptor in descriptors: + dst, _rule = build_surface._output_rel(_RELEASE_SKILL_SOURCE_REL, descriptor) + if dst is None: + raise ProofFreshnessError( + f"core/hosts.json's {descriptor.name!r} host descriptor no " + f"longer renders a release-skill payload from " + f"core/surface/{_RELEASE_SKILL_SOURCE_REL} — the payload " + "derivation this checker relies on no longer holds; a " + "human needs to reconcile this script with the new " + "core/hosts.json shape before it can be trusted again" + ) + payloads[f"{descriptor.plugin_dir}/{dst}"] = descriptor.name + return payloads + + +def sha256_file(path): + with open(path, "rb") as fh: + return hashlib.sha256(fh.read()).hexdigest() + + +def check(repo=REPO_ROOT, artifact_path=ARTIFACT_PATH, build_surface=None): + """Return a list of freshness errors — empty means the recorded T-78 + proof still covers the shipped release skill. Never raises and never + mutates anything on disk.""" + repo = str(repo) + artifact_path = str(artifact_path) + + if not os.path.isfile(artifact_path): + return [f"missing proof artifact: {artifact_path!r}"] + try: + with open(artifact_path, encoding="utf-8") as fh: + document = json.load(fh) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + return [f"{artifact_path!r} is not parseable JSON: {error}"] + + if document.get("proof_current") is not True: + return [ + f"{artifact_path!r} records proof_current={document.get('proof_current')!r}, " + "not true — the recorded T-78 agent-judgment exercise does not " + "currently cover the shipped release skill. Re-run the " + "exercise against the shipped skill, then set proof_current " + "to true and record the hash actually exercised." + ] + + exercise = document.get("exercise") + if not isinstance(exercise, dict): + return [f"{artifact_path!r} carries no 'exercise' object to read a proof hash from"] + + recorded_path = exercise.get("exercised_skill_path") + recorded_hash = exercise.get("exercised_skill_sha256") + if not isinstance(recorded_path, str) or not recorded_path: + return [f"{artifact_path!r}'s exercise.exercised_skill_path is missing or empty"] + if not isinstance(recorded_hash, str) or not recorded_hash: + return [f"{artifact_path!r}'s exercise.exercised_skill_sha256 is missing or empty"] + + try: + payloads = full_prose_release_skill_payloads(repo, build_surface) + except ProofFreshnessError as error: + return [str(error)] + + if recorded_path not in payloads: + return [ + f"{recorded_path!r} (recorded in {artifact_path!r}) is not a " + "full-prose release-skill payload core/hosts.json currently " + "renders — the recorded path no longer names a shipped skill" + ] + + absolute = os.path.join(repo, *recorded_path.split("/")) + if not os.path.isfile(absolute): + return [ + f"{recorded_path!r} (recorded in {artifact_path!r}) no longer " + "exists on disk" + ] + + current_hash = sha256_file(absolute) + if current_hash.lower() != recorded_hash.lower(): + return [ + f"{recorded_path!r} has changed since the T-78 agent-judgment " + f"exercise ran: recorded sha256={recorded_hash}, current " + f"sha256={current_hash}. Re-run the exercise against the " + f"shipped skill, update {artifact_path!r}, and only then set " + "proof_current back to true." + ] + return [] + + +def main(repo=None, artifact_path=None): + """CLI entry. Exit 0 when the proof still covers the shipped skill. + + `repo`/`artifact_path` are pass-throughs to `check`, present ONLY so a + test can drive the FAILING branch below against a fixture. Without them + `main` could only ever be exercised on the live repo, where the proof is + green by definition -- so the error path, the one that has to work on + the day it fires, was never run. + """ + errors = check(**{k: v for k, v in + (("repo", repo), ("artifact_path", artifact_path)) + if v is not None}) + if errors: + print("::error::the T-78 agent-judgment proof no longer covers the shipped release skill:") + for e in errors: + print(" - " + e) + return 1 + print("T-78 agent-judgment proof still covers the shipped release skill") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/fixtures/release-trace/commit-graph.json b/.github/scripts/fixtures/release-trace/commit-graph.json new file mode 100644 index 00000000..d8360cc8 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/commit-graph.json @@ -0,0 +1,39 @@ +{ + "$comment": [ + "A-1.11 (T-27a) resolution-trace fixture: a small, frozen, SYNTHETIC commit", + "graph -- never a real git repository. `head` names the commit HEAD points", + "at; `commits` is the parent adjacency a plain DFS/BFS walks to answer", + "'git log ..HEAD'; `tag_refs` maps each tag in tags.txt to the sha it", + "names, exactly like `git ls-remote --tags` would. Values are synthetic", + "sha-shaped strings (c1..c6, b1), not real object ids -- nothing here is", + "ever written to an actual .git directory.", + "", + "`b1` branches off `c4` and merges into `c6` alongside `c5` (`c6`'s second", + "parent) precisely so WINDOW_COMMITS is not a straight line: a walk that", + "only follows each commit's FIRST parent (a plausible wrong", + "implementation of 'git log ..HEAD') silently drops `b1` from every", + "window that should contain it, where an all-parents walk does not. See", + "the module docstring's WINDOW_COMMITS honest-limit paragraph and", + "WindowCommitsTest." + ], + "head": "c6", + "commits": [ + {"sha": "c1", "parents": []}, + {"sha": "c2", "parents": ["c1"]}, + {"sha": "c3", "parents": ["c2"]}, + {"sha": "c4", "parents": ["c3"]}, + {"sha": "c5", "parents": ["c4"]}, + {"sha": "b1", "parents": ["c4"]}, + {"sha": "c6", "parents": ["c5", "b1"]} + ], + "tag_refs": { + "v1.0.0": "c2", + "v1.1.0": "c4", + "v1.1.0-beta.1": "c3", + "ca-pi-v0.1.0": "c2", + "ca-pi-v0.1.1": "c5", + "ca-codex-v0.2.0": "c1", + "ca-sandbox-v0.1.0": "c1", + "web-beta-v1.0.0": "c6" + } +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json b/.github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json new file mode 100644 index 00000000..fb2453a9 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ca-codex", + "version": "0.2.0" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-pi-package.json b/.github/scripts/fixtures/release-trace/manifests/ca-pi-package.json new file mode 100644 index 00000000..167be803 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-pi-package.json @@ -0,0 +1,4 @@ +{ + "name": "ca-pi", + "version": "0.1.2" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json b/.github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json new file mode 100644 index 00000000..96e99ec9 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json @@ -0,0 +1,4 @@ +{ + "name": "codearbiter", + "version": "0.1.3" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-plugin.json b/.github/scripts/fixtures/release-trace/manifests/ca-plugin.json new file mode 100644 index 00000000..a9d8b10a --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ca", + "version": "1.2.0" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json b/.github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json new file mode 100644 index 00000000..32ea4f8b --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ca-sandbox", + "version": "0.1.0" +} diff --git a/.github/scripts/fixtures/release-trace/release-targets.md b/.github/scripts/fixtures/release-trace/release-targets.md new file mode 100644 index 00000000..d037628e --- /dev/null +++ b/.github/scripts/fixtures/release-trace/release-targets.md @@ -0,0 +1,48 @@ +# Resolution-trace fixture — frozen declared-target rows + +A-1.11 (T-27a): a FROZEN, self-contained copy in the declared-target grammar +— deliberately separate from this repository's own +`.codearbiter/release-targets.md`, so the trace stays stable even if that +file changes shape later. Paths are relative to this fixture directory, not +the repository root. + + +[ca] +prefix: v +manifest: manifests/ca-plugin.json +changelog: CHANGELOG.md +payload: ca/ +artifacts: ca/tools/farm.js +provenance-manifest: published-tags.json +latest-eligible: true + +[ca-codex] +prefix: ca-codex-v +manifest: manifests/ca-codex-plugin.json +changelog: ca-codex/CHANGELOG.md +payload: ca-codex/ +provenance-manifest: published-tags.json +latest-eligible: false + +[ca-sandbox] +prefix: ca-sandbox-v +manifest: manifests/ca-sandbox-plugin.json +changelog: ca-sandbox/CHANGELOG.md +payload: ca-sandbox/ +artifacts: ca-sandbox/tools/sandbox.js +artifacts: ca-sandbox/tools/claude-inside.js +provenance-manifest: published-tags.json +latest-eligible: false + +[ca-pi] +prefix: ca-pi-v +manifest: manifests/ca-pi-package.json +manifest: manifests/ca-pi-root-package.json +changelog: ca-pi/CHANGELOG.md +payload: ca-pi/ +payload-exclude: ca-pi/tools/ +artifacts: ca-pi/extensions/codearbiter.js +artifacts: ca-pi/extensions/codearbiter-child.js +provenance-manifest: published-tags.json +latest-eligible: false + diff --git a/.github/scripts/fixtures/release-trace/tags.txt b/.github/scripts/fixtures/release-trace/tags.txt new file mode 100644 index 00000000..6166ac0b --- /dev/null +++ b/.github/scripts/fixtures/release-trace/tags.txt @@ -0,0 +1,8 @@ +v1.0.0 +v1.1.0 +v1.1.0-beta.1 +ca-pi-v0.1.0 +ca-pi-v0.1.1 +ca-codex-v0.2.0 +ca-sandbox-v0.1.0 +web-beta-v1.0.0 diff --git a/.github/scripts/payload_version_gate.py b/.github/scripts/payload_version_gate.py index 51646a05..01beca14 100644 --- a/.github/scripts/payload_version_gate.py +++ b/.github/scripts/payload_version_gate.py @@ -50,16 +50,47 @@ from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _releaselib import RELEASE_TAG_PREFIXES, semver_greater, semver_key # noqa: E402 +from _releaselib import load_targets, semver_greater, semver_key # noqa: E402 import payload_scope # noqa: E402 REPO = Path(__file__).resolve().parents[2] +DECLARED_TARGETS = REPO / ".codearbiter" / "release-targets.md" + + +def tag_prefixes(targets_path: Path = DECLARED_TARGETS) -> dict[str, str]: + """`{payload directory basename: tag prefix}`, DERIVED from the declared + release-targets file (A-4.1) rather than from a constant. + + Before this, the map was `_releaselib.RELEASE_TAG_PREFIXES` — a literal + in the CI shim listing four plugin names and their namespaces. Two + sources of truth for the same fact: a target declared in + `.codearbiter/release-targets.md` with a prefix the constant disagreed + with would be GATED under one namespace and RELEASED under another, and + nothing compared them. Adding a fifth plugin meant editing both, and + forgetting the constant produced a `KeyError` at gate time rather than + a diagnosable message. + + Keyed on the payload's basename, not on the target NAME, because that + is what the caller has: the gate walks `plugins/*` directories and asks + "what namespace does this directory release under". A row whose payload + is not a single directory (a consumer's `payload: .`) contributes no + entry — this gate is a codeArbiter-repo check over `plugins/*`, and a + whole-repo payload has no basename to key on. + """ + prefixes: dict[str, str] = {} + for row in load_targets(str(targets_path)): + payload = (row.get("payload") or "").strip().strip("/") + if not payload or payload == ".": + continue + prefixes[payload.rsplit("/", 1)[-1]] = row["prefix"] + return prefixes # Each gated plugin's manifest, relative to the repo root. `ca-codex` is a Codex # package and keeps its manifest under `.codex-plugin/`; the other two are Claude # Code plugins under `.claude-plugin/`. The tag namespace is NOT repeated here — -# it is read from _releaselib.RELEASE_TAG_PREFIXES, so a plugin cannot be gated -# under one namespace and released under another. +# it is DERIVED from `.codearbiter/release-targets.md` by `tag_prefixes()` above +# (A-4.1), so a plugin cannot be gated under one namespace and released under +# another — the gate and the release lane now read the same declaration. # # `plugins/ca-pi` is deliberately absent: see the module docstring. Its exclusion # is asserted by test_payload_version_gate.py, so adding it here without removing @@ -161,7 +192,18 @@ def gate(base: str, plugin: str, root: Path = REPO) -> tuple[int, str]: f"version published (issue #530). Advance the version." ) - tag = f"{RELEASE_TAG_PREFIXES[Path(plugin).name]}{current}" + namespaces = tag_prefixes() + name = Path(plugin).name + if name not in namespaces: + return FAIL, ( + f"{annotate}{plugin}/** is gated here but declares no release target " + f"in .codearbiter/release-targets.md, so this gate cannot tell which " + f"tag namespace it publishes under. Declare a row for it, or remove " + f"it from GATED_MANIFESTS — a gated payload with no declared " + f"namespace is exactly the drift this derivation exists to end. " + f"(Before A-4.1 this was a KeyError against a hardcoded map.)" + ) + tag = f"{namespaces[name]}{current}" if tag_exists(tag, root): return FAIL, ( f"{annotate}{plugin}/** shipped payload changed on version {current}, which is " diff --git a/.github/scripts/test_board_sync.py b/.github/scripts/test_board_sync.py index 89c6a3d3..ba4af929 100644 --- a/.github/scripts/test_board_sync.py +++ b/.github/scripts/test_board_sync.py @@ -215,6 +215,46 @@ def test_standup_advisory_board_sweep(): ) +# ---- A-5.1 (issue #563): decompose elicits release INTENT only, not a row (T-47) -- +def test_decompose_intent_only(): + # Reads the SURFACE SOURCE (core/surface/), never a generated plugins/*/ + # copy -- the release-portable-fixture campaign's own "Source of truth" + # convention, since core/surface/skills/decompose/SKILL.md is what this + # repo's guards and structural assertions are meant to target. + t = read_repo("core/surface/skills/decompose/SKILL.md") + anchor = "Release intent is elicited here" + idx = t.find(anchor) + check(idx != -1, + "decompose SKILL.md: must carry the 'Release intent is elicited " + "here' paragraph stating the no-row-written rule (A-5.1)") + # Scoped to THIS one paragraph -- not the whole document -- so a + # deletion here cannot hide behind a restatement elsewhere (the Layer 4 + # Unlock line ALSO mentions "tag prefix"/"changelog", so a document-wide + # substring check would still pass with this paragraph gutted). + window = t[idx: idx + 700] if idx != -1 else "" + wl = window.lower() + check( + "tag prefix" in wl, + "decompose SKILL.md: the release-intent paragraph must name the tag " + "prefix preference (A-5.1)", + ) + check( + "changelog kept" in wl, + "decompose SKILL.md: the release-intent paragraph must name the " + "changelog-kept y/n answer (A-5.1)", + ) + check( + "release-targets.md` row is ever written by this skill" in window, + "decompose SKILL.md: must state it writes no release-targets.md row " + "-- it runs before any manifest or tag exists (A-5.1)", + ) + check( + "before any manifest or tag exists" in wl, + "decompose SKILL.md: must state WHY it cannot substantiate a row yet " + "(no manifest or tag exists during greenfield decomposition, A-5.1)", + ) + + # --- APPEND NEW test_* FUNCTIONS ABOVE THIS LINE -------------------------------- # Each new function must also be added to TESTS and (if it reads a new file) # to REQUIRED_FILES below. @@ -229,9 +269,136 @@ def test_standup_advisory_board_sweep(): # --- APPEND NEW REQUIRED FILES HERE ---------------------------------------- "plugins/ca/includes/harvest.md", "plugins/ca/commands/standup.md", + "core/surface/skills/decompose/SKILL.md", ] +def test_debug_uses_helper(): + """B-16/T-54: `debug` Exit (c) records through the board helper, and + no direct append to open-tasks.md remains. + + Exit (c) used to instruct appending a schema-conformant line to + `.codearbiter/open-tasks.md` by hand. That was the last real surface + writing the board directly, and under the `helper-only` protected-state + class the board is heading for, a direct write stops being possible — + so the instruction would have become one that cannot be followed. + + Asserted across every rendered copy, not just the source template: a + conversion that landed in `core/surface` but not in a host's rendered + routine would leave that host still instructing the old path. + """ + copies = [ + "core/surface/skills/debug/SKILL.md", + "plugins/ca/skills/debug/SKILL.md", + "plugins/ca-codex/routines/debug/SKILL.md", + "plugins/ca-pi/routines/debug/SKILL.md", + ] + seen = 0 + for relative in copies: + if not (ROOT / relative).exists(): + continue + seen += 1 + text = read_repo(relative) + check("taskwrite.py" in text and "--desc" in text, + f"{relative}: Exit (c) does not route through taskwrite --desc") + # The direct-append instruction, in the shape it actually had. + check("append a schema-conformant queued entry" not in text, + f"{relative}: the direct-append instruction still remains") + check("never by appending to the file" in text, + f"{relative}: nothing forbids the direct append it replaced") + check(seen >= 4, + f"expected at least 4 rendered debug copies, found {seen}") + + +def test_done_flip_retained(): + """B-19/T-56: a `/ca:task done` flip still classifies RETAINED with the + protected-state enrolment simulated live, and the helper's own write is + invisible to the lexical flanks. + + The circularity hazard: if enrolling `open-tasks.md` could block + `/ca:task`, the guard would stop the only sanctioned writer and the + board would become unmaintainable. The spec's mitigation is + STRUCTURAL, not sequencing -- `taskwrite.py` writes with Python file + I/O whose argv never names the file, so the shell flank has nothing to + match on. That construction, not "enrolment lands last", is what makes + it safe, so it is what gets pinned here. + + Both halves are asserted: the classifier keeps the flip RETAINED (so + commit-gate does not read it as scope creep), and a simulated + helper-only enrolment does not make the helper's own invocation + blockable. + """ + hooks = ROOT / "plugins" / "ca" / "hooks" + sys.path.insert(0, str(hooks)) + try: + import _taskboardlib as tb + import _protectedstatelib as ps + import _bashguardlib as bg + finally: + if str(hooks) in sys.path: + sys.path.remove(str(hooks)) + + before = "# Open tasks\n\n## In-flight\n- [~] a.b.0001 - a task (started 2026-07-01)\n" + after = "# Open tasks\n\n## In-flight\n- [x] a.b.0001 - a task (started 2026-07-01) (done 2026-07-02)\n" + check(tb.classify_board_diff(before, after) is True, + "a clean done-flip must classify RETAINED, or commit-gate reads " + "the board edit as scope creep and refuses the work commit") + + # The helper's write, as the shell flank would see it. `taskwrite.py` + # names the VERB and the task, never open-tasks.md, so a lexical rule + # keyed on the filename has nothing to match -- which is exactly why + # enrolling the board cannot lock out its own writer. + invocation = 'python3 hooks/taskwrite.py done a.b.0001' + check("open-tasks" not in invocation, + "the helper invocation must not name the board file, or the " + "shell flank could block the only sanctioned writer") + simulated = dict(ps.REGISTRY) + simulated[".codearbiter/open-tasks.md"] = ps.ProtectedPolicy.HELPER_ONLY + compiled = bg._build_state_write_res(simulated) + check(len(compiled) == len(simulated), + "the simulated enrolment did not compile into the shell flank") + for entry in compiled: + for regex in entry[2:]: + check(regex.search(invocation) is None, + f"a flank regex matches the helper's own invocation " + f"({invocation!r}) -- enrolment would block /ca:task") + + +def test_context_creation_board_route(): + """B-18/T-55: `context-creation` seeds open-tasks.md through the board + helper, one call per task, never by writing entries into the file. + + It used to POPULATE the file with the Write tool during doc-writing. + Once open-tasks.md is enrolled `helper-only`, that write is refused + outright -- so the instruction would survive as one nobody can follow. + Seeding through repeated `taskwrite add` also means the backlog cannot + drift from the schema the SessionStart hook and statusline parse. + """ + copies = [ + "core/surface/skills/context-creation/SKILL.md", + "plugins/ca/skills/context-creation/SKILL.md", + "plugins/ca-codex/routines/context-creation/SKILL.md", + "plugins/ca-pi/routines/context-creation/SKILL.md", + ] + seen = 0 + for relative in copies: + if not (ROOT / relative).exists(): + continue + seen += 1 + text = read_repo(relative) + check("taskwrite.py" in text, + f"{relative}: open-tasks seeding does not name the board helper") + check("one call per item" in text, + f"{relative}: nothing states the per-item helper contract") + check("never by writing entries into the file directly" in text, + f"{relative}: nothing forbids the direct write it replaced") + check(seen >= 4, + f"expected at least 4 rendered context-creation copies, found {seen}") + + TESTS = [ + test_done_flip_retained, + test_context_creation_board_route, + test_debug_uses_helper, test_task_doc_states_commit_colocation, test_commit_gate_phase6_board_edit_exemption, test_commit_gate_phase7_stages_board_edit_by_path, @@ -239,6 +406,7 @@ def test_standup_advisory_board_sweep(): # --- APPEND NEW TESTS HERE -------------------------------------------------- test_harvest_md_commit_gate_pre_commit, test_standup_advisory_board_sweep, + test_decompose_intent_only, ] # --------------------------------------------------------------------------- @@ -250,7 +418,28 @@ def main(): print(f"FATAL: missing file {m}") return 2 - for fn in TESTS: + # `-k SUBSTRING` selection, matching unittest's spelling. This runner + # previously IGNORED the flag, so a plan row naming + # `test_board_sync.py -k some_check` ran the entire suite and reported + # green -- passing for a reason unrelated to what the row claimed to + # verify. Selecting nothing is now a hard error rather than a vacuous + # pass, which is the failure mode a `-k` typo actually produces. + selected = TESTS + argv = sys.argv[1:] + if "-k" in argv: + index = argv.index("-k") + if index + 1 >= len(argv): + print("FATAL: -k requires a pattern") + return 2 + pattern = argv[index + 1] + selected = [fn for fn in TESTS if pattern in fn.__name__] + if not selected: + print(f"FATAL: -k {pattern!r} selected no checks of " + f"{len(TESTS)}; a selector that matches nothing would " + "otherwise report success while verifying nothing") + return 2 + + for fn in selected: before = len(_failures) try: fn() @@ -266,7 +455,7 @@ def main(): for m in _failures: print(f" - {m}") return 1 - print(f"\nOK: {len(TESTS)} check(s) green") + print(f"\nOK: {len(selected)} check(s) green") return 0 diff --git a/.github/scripts/test_check_skill_proof_fresh.py b/.github/scripts/test_check_skill_proof_fresh.py new file mode 100644 index 00000000..f77f9ada --- /dev/null +++ b/.github/scripts/test_check_skill_proof_fresh.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""Unit tests for check_skill_proof_fresh — the T-79 proof-freshness guard +(AC-6.8, .codearbiter/specs/release-portable-fixture.md). + +Run: python .github/scripts/test_check_skill_proof_fresh.py + +Covers the payload-derivation function against the live repo (it must +resolve exactly the three full-prose release-skill payloads named in the +spec's "Source of truth" table, never hardcoded here as the checker's own +truth — only as this test's independent expectation), the derivation's own +declared failure mode via a synthetic descriptor set, and every one of +`check()`'s named failure causes via synthetic artifact fixtures — most +importantly the live state of this repo's own artifact. + +The live-repo assertions are written as INVARIANTS ("the gate's verdict +matches the actual hash relationship"), not as "the artifact is currently +fresh". The second form has to be hand-inverted every time the release +skill legitimately changes, which is how a gate's own test rots into +decoration. Freshness is proven separately, in both directions, by +perturbing the real document. +""" +import hashlib +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +import check_skill_proof_fresh as G + +REPO_ROOT = Path(__file__).resolve().parents[2] +REAL_ARTIFACT = REPO_ROOT / ".codearbiter" / "reports" / "agent-lane-proof.json" +REAL_PAYLOAD = REPO_ROOT / "plugins" / "ca" / "skills" / "release" / "SKILL.md" + + +def _write_json(directory, document): + path = Path(directory) / "artifact.json" + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def _real_hash(): + with open(REAL_PAYLOAD, "rb") as fh: + return hashlib.sha256(fh.read()).hexdigest() + + +def _fresh_document(): + """A synthetic artifact that WOULD be fresh against the live repo — + proof_current true, pointing at the real ca payload with its real, + live hash. Every failure-mode test below perturbs exactly one field + away from this baseline.""" + return { + "proof_current": True, + "exercise": { + "exercised_skill_path": "plugins/ca/skills/release/SKILL.md", + "exercised_skill_sha256": _real_hash(), + }, + } + + +class PayloadDerivationTest(unittest.TestCase): + """The payload set is DERIVED from core/hosts.json, never hardcoded in + the checker — this test's own expectation IS hardcoded, deliberately, + as the independent oracle a derivation bug would disagree with.""" + + def test_derives_exactly_the_three_full_prose_payloads_on_live_repo(self): + payloads = G.full_prose_release_skill_payloads() + self.assertEqual( + payloads, + { + "plugins/ca/skills/release/SKILL.md": "claude", + "plugins/ca-codex/routines/release/SKILL.md": "codex", + "plugins/ca-pi/routines/release/SKILL.md": "pi", + }, + ) + + def test_stub_payloads_are_not_derived(self): + # The two per-host router stubs render from a DIFFERENT source + # template (commands/release.md) and carry no prose of their own + # (test_consumer_smoke.py proves that directly); this function + # only ever resolves skills/release/SKILL.md, so neither stub path + # can appear here regardless of what core/hosts.json says. + payloads = G.full_prose_release_skill_payloads() + self.assertNotIn("plugins/ca-codex/skills/ca-release/SKILL.md", payloads) + self.assertNotIn("plugins/ca-pi/skills/ca-release/SKILL.md", payloads) + + def test_a_host_that_no_longer_renders_the_source_raises(self): + # A synthetic build_surface stub whose lone descriptor's + # `_output_rel` always reports "no matching rule" for the release- + # skill source — the shape a host_descriptors change that dropped + # the `skills/` rule would produce. The derivation must fail + # LOUDLY and name the host, never silently omit it from the map. + fake_descriptor = SimpleNamespace(name="ghost", plugin_dir="plugins/ghost") + fake_build_surface = SimpleNamespace( + load_host_descriptors=lambda repo: (fake_descriptor,), + _output_rel=lambda rel, descriptor: (None, None), + ) + with self.assertRaises(G.ProofFreshnessError) as ctx: + G.full_prose_release_skill_payloads( + repo="/does/not/matter", build_surface=fake_build_surface + ) + self.assertIn("ghost", str(ctx.exception)) + + +class CheckLiveRepoTest(unittest.TestCase): + """Live-repo behaviour, asserted as invariants that hold whether or not + the recorded proof currently covers the shipped skill. + + This class has been inverted twice already — stale, then fresh — each + time the artifact's real state changed. That churn was the signal that + the assertion was wrong in shape, not merely in polarity: a gate whose + test must be rewritten on every legitimate change is one nobody can + trust. It now asserts that the gate's verdict TRACKS the artifact, and + proves the gate can still fail by perturbing the real document. + """ + + def test_the_gates_verdict_matches_the_actual_hash_relationship(self): + # Stated as an INVARIANT rather than "the artifact is currently + # fresh", because the second form has to be inverted by hand every + # time the skill legitimately changes — and a test rewritten on + # every change is one nobody trusts. + # + # This form holds in both states and still fails if the gate lies + # in either direction: reporting drift when the hashes match, or + # reporting freshness when they do not. + # + # Discovered by the gate firing on its own author: the T-28 prose + # change edited the skill, the recorded proof went stale, and the + # previous assertion ("HEAD passes") failed for a correct reason. + with open(REAL_ARTIFACT, encoding="utf-8") as fh: + document = json.load(fh) + recorded = document.get("exercise", {}).get("exercised_skill_sha256") + shipped = _real_hash() + hashes_agree = (recorded == shipped) + claims_current = document.get("proof_current") is True + errors = G.check() + self.assertEqual( + errors == [], hashes_agree and claims_current, + f"gate said {'fresh' if not errors else 'stale'} while " + f"proof_current={claims_current} and hashes " + f"{'agree' if hashes_agree else 'differ'} " + f"(recorded={recorded}, shipped={shipped}). The gate's verdict " + f"must track the artifact, not diverge from it. Errors: {errors}") + + def test_a_stale_proof_names_the_skill_drift_as_its_reason(self): + # When the artifact IS stale, the reason must be the hash — not a + # generic failure. This is what tells an operator to re-run the + # exercise rather than go hunting. + with open(REAL_ARTIFACT, encoding="utf-8") as fh: + document = json.load(fh) + if document.get("exercise", {}).get("exercised_skill_sha256") == _real_hash(): + self.skipTest("proof is current; the drift-reason path is covered " + "by the perturbation tests below") + errors = G.check() + self.assertTrue(errors) + self.assertTrue( + any("has changed since" in e or "proof_current" in e for e in errors), + f"a stale proof must name skill drift or proof_current: {errors}") + + def test_the_live_artifact_still_fails_when_the_recorded_hash_is_wrong(self): + # The real document, perturbed in exactly one field. Proves the + # guard's hash comparison is load-bearing against the REAL payload + # — not just against a synthetic fixture whose hash never matched + # anything in the first place. + with open(REAL_ARTIFACT, encoding="utf-8") as fh: + document = json.load(fh) + # proof_current is checked BEFORE the hash, so it must be true here + # or check() short-circuits and never reaches the comparison this + # test exists to exercise. (Found when the live artifact went + # legitimately stale: the test still "passed a failure", but for + # the wrong reason.) + document["proof_current"] = True + document["exercise"]["exercised_skill_sha256"] = "0" * 64 + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "artifact.json" + path.write_text(json.dumps(document), encoding="utf-8") + errors = G.check(artifact_path=path) + self.assertTrue(errors, "a wrong recorded hash must fail the guard") + self.assertTrue( + any("has changed since" in e for e in errors), + f"expected a hash-drift failure, got: {errors}", + ) + + def test_the_live_artifact_still_fails_when_proof_current_is_revoked(self): + # The other direction: the real document with proof_current flipped + # back to false must fail. This is the state the artifact was in + # before run 3 settled it, so this test also pins that the guard + # would have caught it. + with open(REAL_ARTIFACT, encoding="utf-8") as fh: + document = json.load(fh) + document["proof_current"] = False + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "artifact.json" + path.write_text(json.dumps(document), encoding="utf-8") + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("proof_current", errors[0]) + + def test_real_artifact_is_parseable_json_at_least(self): + # Guards the guard's own JSON-parse path against the real file: if + # the artifact were ever hand-edited into invalid JSON, this + # documents that check() would report THAT, not proof_current. + with open(REAL_ARTIFACT, encoding="utf-8") as fh: + document = json.load(fh) + self.assertIn("proof_current", document) + + +class CheckFailureModeTest(unittest.TestCase): + """Each of check()'s named failure causes, isolated via a synthetic + artifact that perturbs exactly one field away from a fresh baseline.""" + + def test_missing_artifact_file(self): + with tempfile.TemporaryDirectory() as tmp: + missing = Path(tmp) / "nope.json" + errors = G.check(artifact_path=missing) + self.assertTrue(errors) + self.assertIn("missing proof artifact", errors[0]) + + def test_unparseable_json(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "artifact.json" + path.write_text("{not json", encoding="utf-8") + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("not parseable JSON", errors[0]) + + def test_proof_current_false(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + document["proof_current"] = False + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("proof_current", errors[0]) + + def test_proof_current_missing_entirely(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + del document["proof_current"] + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("proof_current", errors[0]) + + def test_exercise_object_missing(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + del document["exercise"] + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("exercise", errors[0]) + + def test_exercised_skill_path_missing(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + del document["exercise"]["exercised_skill_path"] + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("exercised_skill_path", errors[0]) + + def test_exercised_skill_sha256_missing(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + del document["exercise"]["exercised_skill_sha256"] + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("exercised_skill_sha256", errors[0]) + + def test_recorded_path_is_not_a_rendered_payload(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + document["exercise"]["exercised_skill_path"] = ( + "plugins/ca/skills/does-not-exist/SKILL.md" + ) + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("not a full-prose release-skill payload", errors[0]) + + def test_recorded_path_no_longer_exists_on_disk(self): + # Isolated from the "not a rendered payload" cause above via + # dependency injection: a synthetic build_surface whose descriptor + # legitimately resolves the recorded path, but the file is simply + # absent under the scratch `repo` this test supplies. + fake_descriptor = SimpleNamespace(name="claude", plugin_dir="plugins/ca") + fake_build_surface = SimpleNamespace( + load_host_descriptors=lambda repo: (fake_descriptor,), + _output_rel=lambda rel, descriptor: ("skills/release/SKILL.md", None), + ) + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + document["exercise"]["exercised_skill_path"] = "plugins/ca/skills/release/SKILL.md" + document["exercise"]["exercised_skill_sha256"] = "0" * 64 + artifact_path = _write_json(tmp, document) + errors = G.check( + repo=tmp, artifact_path=artifact_path, build_surface=fake_build_surface + ) + self.assertTrue(errors) + self.assertIn("no longer exists on disk", errors[0]) + + def test_hash_mismatch(self): + with tempfile.TemporaryDirectory() as tmp: + document = _fresh_document() + document["exercise"]["exercised_skill_sha256"] = "0" * 64 + path = _write_json(tmp, document) + errors = G.check(artifact_path=path) + self.assertTrue(errors) + self.assertIn("has changed since", errors[0]) + + def test_fresh_document_passes(self): + # The positive path: proof_current true, recorded path a real + # rendered payload, recorded hash the REAL live hash of that file. + # Proves this guard is not permanently red by construction — once + # a genuine fresh proof is recorded, it reports clean. + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(tmp, _fresh_document()) + self.assertEqual(G.check(artifact_path=path), []) + + +class MainCLITest(unittest.TestCase): + """The script actually run as a subprocess, exercising `main()` and its + exit-code contract end to end against the live repo — not merely + `check()` called in-process. + + This is the form the `ca` row's declared `pre-tag` command runs, so it + must pass on a current artifact AND still exit 1 when the proof goes + stale. Both directions are asserted; a gate that only ever exits 0 + proves nothing.""" + + def _run(self, cwd=None): + return subprocess.run( + [sys.executable, str(REPO_ROOT / ".github" / "scripts" / "check_skill_proof_fresh.py")], + cwd=str(cwd or REPO_ROOT), capture_output=True, text=True) + + def test_cli_exit_code_agrees_with_check(self): + # Same invariant as CheckLiveRepoTest's, at the process boundary: + # main()'s exit code must agree with check()'s verdict. Asserting a + # fixed exit code here would need hand-inversion on every + # legitimate skill change, which is how a gate's own test rots. + result = self._run() + expected = 0 if G.check() == [] else 1 + self.assertEqual( + result.returncode, expected, + "this is a DECLARED pre-tag command on the `ca` row, so its exit " + "code is what BLOCKS or permits a release; it must agree with " + f"check(). stdout: {result.stdout} stderr: {result.stderr}") + marker = ("still covers the shipped release skill" if expected == 0 + else "no longer covers the shipped release skill") + self.assertIn(marker, result.stdout) + + def test_cli_exits_1_when_the_shipped_skill_drifts_from_the_record(self): + # Proves the declared command can still FAIL, against the real + # script and the real artifact, by copying the repo's own inputs + # into a scratch tree and perturbing only the recorded hash. The + # live repo is never mutated — this gate guards a release lane, so + # its own test must not be able to leave the repo dirty. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".codearbiter" / "reports").mkdir(parents=True) + with open(REAL_ARTIFACT, encoding="utf-8") as fh: + document = json.load(fh) + # See the note in CheckLiveRepoTest: proof_current gates the + # hash comparison, so it must be true to reach it. + document["proof_current"] = True + document["exercise"]["exercised_skill_sha256"] = "0" * 64 + (root / ".codearbiter" / "reports" / "agent-lane-proof.json").write_text( + json.dumps(document), encoding="utf-8") + errors = G.check(repo=REPO_ROOT, + artifact_path=root / ".codearbiter" / "reports" + / "agent-lane-proof.json") + self.assertTrue(errors, "a drifted hash must fail the declared command") + self.assertTrue(any("has changed since" in e for e in errors), errors) + + def test_the_cli_failing_path_actually_runs(self): + # CodeRabbit MAJOR. Every other test here calls `check()`; nothing + # ran `main()` against a failure, because `main()` took no + # parameters and so could only be pointed at the live repo -- where + # the proof is green by construction. The reporting branch, the one + # that has to work on the day the gate fires, was never executed. + import io + from contextlib import redirect_stdout + with tempfile.TemporaryDirectory() as tmp: + document = json.loads(REAL_ARTIFACT.read_text(encoding="utf-8")) + # Pin `proof_current` rather than inheriting it. The live artifact + # legitimately carries False whenever a batch of skill edits is in + # flight, and `check()` short-circuits on that flag BEFORE it ever + # compares hashes -- so a fixture that copies the flag tests the + # boolean branch while claiming to test the hash branch, and goes + # red for a reason that is not a defect. That is the same live-state + # coupling the sibling test's comment warns about, left in the one + # field the sibling did not patch. + document["proof_current"] = True + document["exercise"]["exercised_skill_sha256"] = "0" * 64 + artifact = Path(tmp) / "agent-lane-proof.json" + artifact.write_text(json.dumps(document), encoding="utf-8") + + buffer = io.StringIO() + with redirect_stdout(buffer): + code = G.main(repo=REPO_ROOT, artifact_path=artifact) + self.assertEqual(code, 1, "the CLI must exit non-zero on a stale proof") + printed = buffer.getvalue() + self.assertIn("::error::", printed, + "the failure must be annotated for the CI log") + self.assertIn("has changed since", printed) + + def test_the_cli_passing_path_returns_zero_against_a_current_proof(self): + # The other half of the CLI, against a FIXTURE rather than the live + # repo. Asserting `G.main()` returns 0 here would couple this test + # to release state: the proof is legitimately stale during any batch + # of skill edits (it is re-recorded once, after the batch), so a + # live-repo assertion would fail for a reason that is not a defect — + # the same live-state coupling flagged elsewhere in this review. + import io + from contextlib import redirect_stdout + with tempfile.TemporaryDirectory() as tmp: + document = json.loads(REAL_ARTIFACT.read_text(encoding="utf-8")) + shipped = REPO_ROOT / "plugins" / "ca" / "skills" / "release" / "SKILL.md" + # Both halves of "current" are set here, for the reason spelled out + # in the sibling test above: patching only the hash leaves + # `proof_current` inherited from live release state, and a False + # there fails this test on a day when nothing is wrong. + document["proof_current"] = True + document["exercise"]["exercised_skill_sha256"] = hashlib.sha256( + shipped.read_bytes()).hexdigest() + artifact = Path(tmp) / "agent-lane-proof.json" + artifact.write_text(json.dumps(document), encoding="utf-8") + + buffer = io.StringIO() + with redirect_stdout(buffer): + code = G.main(repo=REPO_ROOT, artifact_path=artifact) + self.assertEqual(code, 0, buffer.getvalue()) + self.assertIn("still covers", buffer.getvalue()) + + def test_the_cli_reports_a_withdrawn_proof_distinctly_from_a_stale_hash(self): + # The two live-artifact tests above each pin `proof_current` so they + # exercise the HASH branch. That leaves the flag branch itself with no + # coverage -- and it is not hypothetical: run 18 (2026-08-01) recorded + # `proof_current: false` deliberately, because a destructive + # instruction had to be fixed AFTER the exercise that found it, so the + # recorded hash was knowingly one commit behind. An operator meeting + # that block needs to be told "the exercise was withdrawn", not "the + # skill changed" -- those have different remedies, and folding the + # first into the second is the failure this repository has a standing + # rule against. + import io + from contextlib import redirect_stdout + with tempfile.TemporaryDirectory() as tmp: + document = json.loads(REAL_ARTIFACT.read_text(encoding="utf-8")) + shipped = REPO_ROOT / "plugins" / "ca" / "skills" / "release" / "SKILL.md" + # Hash CORRECT, flag false: the only thing under test is the flag. + document["proof_current"] = False + document["exercise"]["exercised_skill_sha256"] = hashlib.sha256( + shipped.read_bytes()).hexdigest() + artifact = Path(tmp) / "agent-lane-proof.json" + artifact.write_text(json.dumps(document), encoding="utf-8") + + buffer = io.StringIO() + with redirect_stdout(buffer): + code = G.main(repo=REPO_ROOT, artifact_path=artifact) + printed = buffer.getvalue() + self.assertEqual(code, 1, "a withdrawn proof must block") + self.assertIn("::error::", printed) + self.assertIn("proof_current", printed, + "the operator must be told the flag is what blocked") + self.assertNotIn( + "has changed since", printed, + "a withdrawn proof must NOT be reported as a changed skill -- the " + "hash here is correct, and the two states have different remedies") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/scripts/test_consumer_smoke.py b/.github/scripts/test_consumer_smoke.py new file mode 100644 index 00000000..604f325b --- /dev/null +++ b/.github/scripts/test_consumer_smoke.py @@ -0,0 +1,2761 @@ +#!/usr/bin/env python3 +"""codeArbiter — T-73a/T-73b/T-79: the scratch consumer fixture and the +reference-resolution guard (issue #563, AC-6.6/AC-6.8, +.codearbiter/specs/release-portable-fixture.md, +.codearbiter/plans/portable-release-and-protected-state.md). Reference +resolution started as a T-73b ratchet (committed known-unresolved-refs.txt, +compared for equality) and was retired into a strict "the set is empty" +assertion by T-79 once the release-skill rewrite resolved every entry. + +The maintainer's completion bar for this campaign is that the portable +release lane is proven to work AND to port. Verifying against this +repository's own hand-built `.codearbiter/` state is the documented way a +consumer-facing bug stays hidden (memory: dev-repo-state-masks-consumer- +bugs), so this module never reads project state from THIS repo's checkout +to prove portability — it builds throwaway trees instead: + + ConsumerFixtureTest T-73a — the fixture itself: THREE plugin + payloads materialized by `git archive + HEAD -- ` (one per governance + host: `plugins/ca`, `plugins/ca-codex`, + `plugins/ca-pi`), and a throwaway + single-package consumer git repo. + ReferenceResolutionTest T-73b/T-79 — extracts every executed-or- + read path reference from EVERY INSTALLED + copy of the release skill (the spec's + own "Source of truth" list, plus + `ca-codex/routines/release/SKILL.md`, + which ships an identically-contaminated + copy the spec's enumeration omits — see + the adversarial-review remediation on + 2026-07-31) and asserts the UNIONED + unresolved set is EMPTY outright. T-79 + retired the T-73b ratchet (a committed + `known-unresolved-refs.txt` compared for + equality in either direction) into this + strict zero-form once the skill rewrite + (T-41a-d/T-41f) resolved all 24 entries — + see `test_reference_resolution_is_empty`. + ResolverUnitTest direct, synthetic-input coverage of the + three-armed `_resolves` classifier, so a + mutant on any one arm cannot survive on + a live skill's single real example of + that arm. + HermeticGitEnvTest proves the two concrete, load-bearing + claims `_isolated_git_env`/`_git`'s + docstrings make (issue #556) rather than + merely asserting the guard is CONFIGURED: + an ambient `GIT_CONFIG_GLOBAL` poison + must not reach a fixture-initialized + repo, and a poisoned default + `.git/hooks/pre-commit` must not fire + through the per-call `core.hooksPath` + override. + +Materialization — why `git archive`, not the two rejected alternatives: + + - Pointing `CLAUDE_PLUGIN_ROOT` at the in-repo `plugins/ca/` tree is the + dev tree wearing a costume: every file the skill reads is there, dev- + tree-only artifacts and all, so a reference that only happens to + resolve because THIS checkout has it lying around would pass here and + fail in a real install. This project has a standing directive + (dev-repo-state-masks-consumer-bugs) precisely because that shape has + hidden consumer bugs before. + - A recursive copy (`shutil.copytree`) carries whatever is on disk right + now, uncommitted edits and gitignored build artifacts included + (`__pycache__`, `node_modules`, ...). A skill referencing a file that + exists locally but was never committed would still pass. + - `git archive HEAD -- ` reads the committed tree straight out + of the object database: no working-tree state, no history walk beyond + the current commit, no network, no `.git` directory in the output. + `ConsumerFixtureTest` proves this with two REAL injected artifacts + (a gitignored `__pycache__` entry and a genuinely untracked file) that + a recursive copy would have carried and `git archive` does not. Both + probes inject their sentinel into a THROWAWAY `git clone --shared` of + this repo's own HEAD commit, never into this checkout directly: the + premise requires the sentinel to sit inside the exact tree `git + archive HEAD` reads (so it cannot simply move outside the plugin + directory — that would prove nothing about `git archive` at all), but + writing directly into this checkout would violate the fixture's own + "no writes inside this repo" contract and would survive an ABNORMAL + termination (`os._exit`, a killed process) since no `finally` ever + runs then. The clone keeps that residual entirely inside a scratch + tree — the same residual this module's overall scratch dir already + carries (declared, not newly introduced) — while a write straight into + `plugins/ca/` would leak into a path that ships. + +`git archive` preserves the full repo-relative path (`plugins/ca/...`), not +the plugin-root-relative shape `CLAUDE_PLUGIN_ROOT` actually points at for +an installed plugin, so the archived tar is extracted with the leading +`` prefix stripped — done here via `tarfile` (stdlib), member by +member, rather than shelling out to a platform `tar` binary. The tar +member's own permission bits are preserved on extraction (`tarfile` +carries them; `git archive` marks an executable blob `0o775` and an +ordinary one `0o664`), since 10 files under `plugins/ca` ship +executable and a forward task (T-74/T-75) runs a lane that depends on it. + +Each governance host spells its `{{PLUGIN_ROOT}}`/`{{PROJECT_DIR}}` template +tokens differently once rendered (`core/hosts.json` is the single source of +truth this module reads rather than re-declaring a second copy): `claude` +uses `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}`, `codex` uses +`${CLAUDE_PLUGIN_ROOT}`/``, and `pi` uses +``/``. The extractor and resolver are +host-token-parameterized for exactly this reason: scanning a `pi`-rendered +skill file with the `claude` spelling hard-coded would misclassify every +already-portable `/...` reference in it as a bare, +this-repo-relative contamination — a false positive that would pin a +harmless entry onto the ratchet FOREVER, the same "T-79 unreachable" failure +mode MEDIUM-4 names for a different reason. + +Stdlib only. No third-party imports. +""" + +from __future__ import annotations + +import datetime +import importlib.util +import io +import json +import os +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) + +GIT_TIMEOUT = 60 + +# --------------------------------------------------------------------------- # +# Hermetic git helpers — every scratch-repo git invocation is severed from the +# HOST's ambient global/system config. +# --------------------------------------------------------------------------- # + + +def _isolated_git_env(): + """Environment for every git invocation this module makes. Repoints the + global AND system config file locations at paths that cannot exist, AND + drops the env-injected config channel plus the two ambient overrides that + a file-location repoint does not reach. + + The file repoint alone is NOT sufficient, and an earlier version of this + docstring overclaimed that it was: `GIT_CONFIG_COUNT` with its numbered + `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` pairs injects config directly from + the environment and bypasses config files entirely, while + `GIT_ALTERNATE_OBJECT_DIRECTORIES` and `GIT_SSH_COMMAND` are ambient + settings no config-file path can neutralize. Those are the same class the + `init.templateDir` poison test exists to prove closed. + + Load-bearing (memory: stale-git-hook-enforcer, issue #556): a developer's + REAL global `core.hooksPath` can point at an arbitrarily old, cross-host + codeArbiter git-hooks cache, which would then run its own enforcement + logic against this module's throwaway repos and can BLOCK a commit for + reasons that have nothing to do with this fixture. A CI runner's ambient + `commit.gpgsign`/`tag.gpgsign` can likewise fail an unattended commit with + no signing key configured. Both failure modes make this fixture's outcome + depend on the machine it runs on, which is the opposite of what a + hermetic fixture is for.""" + env = dict(os.environ) + # The env-injected config channel: GIT_CONFIG_COUNT declares how many + # GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n pairs git should apply, and it + # bypasses config FILES entirely, so repointing those files does not + # neutralize it. Drop the counter and every numbered pair. + count = env.pop("GIT_CONFIG_COUNT", None) + if count is not None: + try: + n = int(count) + except ValueError: + n = 0 + for i in range(max(n, 0)): + env.pop("GIT_CONFIG_KEY_%d" % i, None) + env.pop("GIT_CONFIG_VALUE_%d" % i, None) + # Ambient overrides no config-file path can reach. + env.pop("GIT_ALTERNATE_OBJECT_DIRECTORIES", None) + env.pop("GIT_SSH_COMMAND", None) + env["GIT_CONFIG_GLOBAL"] = os.path.join( + tempfile.gettempdir(), "codearbiter-consumer-smoke-no-global-config") + env["GIT_CONFIG_SYSTEM"] = os.path.join( + tempfile.gettempdir(), "codearbiter-consumer-smoke-no-system-config") + env.pop("GIT_DIR", None) + env.pop("GIT_WORK_TREE", None) + return env + + +def _git(args, cwd, check=True): + """Run one git command against `cwd`, with the ambient host config + severed (see `_isolated_git_env`) and a per-repo, deliberately + nonexistent `core.hooksPath` — belt-and-suspenders on top of the env + redirection above, in case some other layer (e.g. a `.git/config` + inherited via `git clone --config` in a future caller) ever tries to + set one locally.""" + no_hooks = os.path.join(cwd, ".ca-fixture-no-hooks-dir") + result = subprocess.run( + ["git", + "-c", f"core.hooksPath={no_hooks}", + "-c", "commit.gpgsign=false", + "-c", "tag.gpgsign=false", + "-c", "init.defaultBranch=main", + "-c", "user.name=codeArbiter consumer-smoke fixture", + "-c", "user.email=fixture@example.invalid", + *args], + cwd=cwd, capture_output=True, encoding="utf-8", timeout=GIT_TIMEOUT, + env=_isolated_git_env()) + if check and result.returncode != 0: + raise RuntimeError( + f"git {args!r} failed (cwd={cwd!r}): {result.stderr}") + return result + + +def _force_rmtree(path): + """`shutil.rmtree` that survives read-only objects under `.git` on + Windows (git marks packed objects read-only; a plain rmtree there raises + PermissionError and would leave scratch state behind on failure, which + the fixture's hermeticity contract forbids).""" + if not os.path.exists(path): + return + + def _onerror(func, target, _exc_info): + try: + os.chmod(target, stat.S_IWRITE) + except OSError: + pass + try: + func(target) + except OSError: + pass + + shutil.rmtree(path, onerror=_onerror) + + +def _clone_head(dest): + """Clone THIS repo's checked-out HEAD commit into `dest` (a throwaway + scratch directory) via `git clone --shared --no-checkout`, then check + out that exact commit sha, detached. `--shared` links the clone's + object store to this repo's own (an `alternates` reference, not a + hardlink requirement — no same-filesystem constraint) so the clone is + cheap, and it needs no network. + + Exists so a caller that must inject a REAL artifact into a + `git`-archived tree (a gitignored file, an untracked file) can do so + WITHOUT writing into this checkout — see the module docstring's + "Materialization" section for why writing directly into + `plugins/ca/...` here would be wrong twice over. Checking out the exact + HEAD sha (rather than trusting the clone's default branch) keeps the + probe correct even if `REPO_ROOT`'s current branch pointer and its + checked-out commit have diverged (a detached HEAD, or a branch moved + since checkout).""" + head_sha = _git(["rev-parse", "HEAD"], REPO_ROOT).stdout.strip() + result = subprocess.run( + ["git", "clone", "--quiet", "--shared", "--no-checkout", + REPO_ROOT, dest], + cwd=tempfile.gettempdir(), capture_output=True, encoding="utf-8", + timeout=GIT_TIMEOUT, env=_isolated_git_env()) + if result.returncode != 0: + raise RuntimeError(f"git clone --shared HEAD failed: {result.stderr}") + _git(["checkout", "--quiet", head_sha], dest) + + +# --------------------------------------------------------------------------- # +# T-73a — plugin materialization via `git archive` +# --------------------------------------------------------------------------- # + + +def materialize_plugin(repo_root, dest, subpath="plugins/ca"): + """Materialize the COMMITTED `subpath` tree at `repo_root`'s HEAD into + `dest`, with the leading `subpath` prefix stripped so `dest` itself is + the plugin root (matching what an installed plugin's `CLAUDE_PLUGIN_ROOT` + actually points at — `dest/.claude-plugin/plugin.json`, not + `dest/plugins/ca/.claude-plugin/plugin.json`). + + Reads `git archive --format=tar HEAD -- ` and walks the tar + stream with the stdlib `tarfile` module rather than shelling out to a + platform `tar` binary, so this has no external-tool dependency beyond + git itself. Hard-fails with a named cause on any git or archive-shape + problem — never silently produces an empty or partial tree.""" + result = subprocess.run( + ["git", "archive", "--format=tar", "HEAD", "--", subpath], + cwd=repo_root, capture_output=True, timeout=GIT_TIMEOUT) + if result.returncode != 0: + raise RuntimeError( + f"'git archive HEAD -- {subpath}' failed in {repo_root!r}: " + f"{result.stderr.decode('utf-8', 'replace')}") + if not result.stdout: + raise RuntimeError( + f"'git archive HEAD -- {subpath}' produced an EMPTY archive in " + f"{repo_root!r} — {subpath!r} is not a committed path at HEAD") + + prefix = subpath.rstrip("/") + "/" + os.makedirs(dest, exist_ok=True) + extracted_any = False + with tarfile.open(fileobj=io.BytesIO(result.stdout)) as tf: + for member in tf.getmembers(): + if not member.name.startswith(prefix): + continue + rel = member.name[len(prefix):] + if not rel: + continue + if member.issym() or member.islnk(): + raise RuntimeError( + "unexpected symlink/hardlink in the archived plugin " + f"payload: {member.name!r} — the plugin payload is " + "expected to carry only regular files and directories") + target = os.path.join(dest, *rel.split("/")) + if member.isdir(): + os.makedirs(target, exist_ok=True) + elif member.isfile(): + os.makedirs(os.path.dirname(target), exist_ok=True) + fh = tf.extractfile(member) + with open(target, "wb") as out: + out.write(fh.read()) + # Preserve the tar member's permission bits (git archive + # marks an executable blob 0o775, an ordinary one 0o664). + # LOW finding (adversarial review 2026-07-31): 10 files under + # plugins/ca ship executable at HEAD and a forward task + # (T-74/T-75) runs a lane that depends on it; `open(..., + # "wb")` alone always writes 644. Best-effort on Windows, + # where there is no POSIX exec bit to set. + try: + os.chmod(target, member.mode & 0o777) + except OSError: + pass + extracted_any = True + else: + raise RuntimeError( + f"unexpected tar member type for {member.name!r}") + if not extracted_any: + raise RuntimeError( + f"'git archive HEAD -- {subpath}' extracted zero files into " + f"{dest!r} — the archive shape has changed") + + +# --------------------------------------------------------------------------- # +# T-73a — the throwaway consumer repo +# --------------------------------------------------------------------------- # + + +def _write_text(path, content): + """Write `content` verbatim, LF-only — Windows text-mode `open()` + translates every `\\n` in the string to `os.linesep` on WRITE, not only + on rewrite of an existing CRLF file, so `newline=""` is required here to + avoid introducing CRLF into a fixture meant to be platform-neutral.""" + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(content) + + +CONSUMER_TRACKED_FILES = frozenset({"CHANGELOG.md", "package.json", "src/widget.py"}) + + +def build_consumer_repo(dest): + """Build a throwaway single-package consumer repo at `dest`: one + `package.json`, one `CHANGELOG.md`, a `v1.2.3` tag, and two synthetic + Conventional-Commits (`feat`, `fix`) carrying `CHANGELOG:` footers — + exactly the shape T-73a's task description specifies, and no file from + this repository.""" + os.makedirs(dest, exist_ok=True) + _git(["init", "-q"], dest) + + _write_text( + os.path.join(dest, "package.json"), + json.dumps({"name": "acme-widgets", "version": "1.2.3", "private": True}, + indent=2) + "\n") + _write_text( + os.path.join(dest, "CHANGELOG.md"), + "# Changelog\n\n## [1.2.3] - 2026-01-01\n\n### Added\n" + "- Initial release.\n") + _git(["add", "-A"], dest) + _git(["commit", "-q", "-m", "chore: seed v1.2.3 baseline"], dest) + _git(["tag", "-a", "v1.2.3", "-m", "v1.2.3"], dest) + + _write_text( + os.path.join(dest, "src", "widget.py"), + "def make_widget():\n return {\"kind\": \"widget\"}\n") + _git(["add", "-A"], dest) + _git(["commit", "-q", "-m", + "feat: add widget export\n\n" + "CHANGELOG: Added a widget export helper."], dest) + + _write_text( + os.path.join(dest, "src", "widget.py"), + "def make_widget(count=1):\n return [{\"kind\": \"widget\"}] * count\n") + _git(["add", "-A"], dest) + _git(["commit", "-q", "-m", + "fix: correct widget off-by-one\n\n" + "CHANGELOG: Fixed an off-by-one when counting widgets."], dest) + + +# --------------------------------------------------------------------------- # +# Shared, module-level fixture — built once, torn down once. +# --------------------------------------------------------------------------- # + + +class _Fixture: + def __init__(self): + self.scratch = tempfile.mkdtemp(prefix="ca-consumer-smoke-") + self.plugin_root = os.path.join(self.scratch, "plugin-root") + self.consumer_root = os.path.join(self.scratch, "consumer-repo") + # MEDIUM-3 (adversarial review 2026-07-31): the release skill ships + # THREE plugin payloads, not one — a second, identically-contaminated + # copy (`ca-pi`'s, and — the spec's own "Source of truth" list omits + # this — `ca-codex`'s too) would otherwise ship while T-79's "the + # list is empty" certified only `ca`. Materialize all three + # governance-host payloads so the ratchet cannot go green while a + # sibling payload stays contaminated. + self.codex_plugin_root = os.path.join(self.scratch, "codex-plugin-root") + self.pi_plugin_root = os.path.join(self.scratch, "pi-plugin-root") + try: + materialize_plugin(REPO_ROOT, self.plugin_root, subpath="plugins/ca") + materialize_plugin(REPO_ROOT, self.codex_plugin_root, + subpath="plugins/ca-codex") + materialize_plugin(REPO_ROOT, self.pi_plugin_root, + subpath="plugins/ca-pi") + build_consumer_repo(self.consumer_root) + except Exception: + self.cleanup() + raise + + def cleanup(self): + _force_rmtree(self.scratch) + + +_FIXTURE = None + + +def setUpModule(): + global _FIXTURE + _FIXTURE = _Fixture() + + +def tearDownModule(): + if _FIXTURE is not None: + _FIXTURE.cleanup() + + +# --------------------------------------------------------------------------- # +# T-73a — the fixture itself +# --------------------------------------------------------------------------- # + + +class ConsumerFixtureTest(unittest.TestCase): + """T-73a: the scratch consumer fixture stands up correctly, and the + materialization mechanism is hermetic by construction — proven against + two REAL artifacts of the exact classes a recursive copy would have + carried and `git archive` does not.""" + + def test_plugin_root_carries_the_committed_release_skill_byte_identically(self): + # LOW finding (adversarial review 2026-07-31): text-mode `open()` + # and `_git`'s `encoding="utf-8"` `subprocess.run` BOTH apply + # universal-newline translation, so a prior version of this + # assertion compared two ALREADY-CRLF-NORMALIZED strings and could + # not have caught EOL drift introduced by materialization. Compare + # raw bytes on both sides instead. Verified: injecting a CRLF flip + # into the archived copy makes the OLD text-mode comparison pass + # (a blind spot) while this raw-bytes comparison catches it. + path = os.path.join(_FIXTURE.plugin_root, "skills", "release", "SKILL.md") + self.assertTrue(os.path.isfile(path)) + with open(path, "rb") as fh: + archived = fh.read() + committed = subprocess.run( + ["git", + "-c", f"core.hooksPath={os.path.join(REPO_ROOT, '.ca-fixture-no-hooks-dir')}", + "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", + "show", "HEAD:plugins/ca/skills/release/SKILL.md"], + cwd=REPO_ROOT, capture_output=True, timeout=GIT_TIMEOUT, + env=_isolated_git_env()) + self.assertEqual(committed.returncode, 0, committed.stderr) + self.assertEqual(archived, committed.stdout) + + def test_plugin_root_preserves_executable_bits(self): + # LOW finding: `materialize_plugin` used to write every extracted + # file `wb`, which is always 0o644 regardless of what git tracked. + # 10 files under plugins/ca are 100755 at HEAD; a forward task + # (T-74/T-75) runs a lane that depends on that surviving + # materialization. Windows has no POSIX exec bit to assert on. + if sys.platform == "win32": + self.skipTest("no POSIX executable bit on win32") + executable_files = ( + "hooks/_hooklib.py", "hooks/init-codearbiter.py", + "hooks/post-write-edit.py", "hooks/pre-bash.py", + "hooks/pre-edit.py", "hooks/pre-write.py", + "hooks/session-start.py", "hooks/statusline.py", + "hooks/wire-statusline.py", "tools/farm.js", + ) + for rel in executable_files: + path = os.path.join(_FIXTURE.plugin_root, *rel.split("/")) + self.assertTrue(os.path.isfile(path), path) + mode = stat.S_IMODE(os.stat(path).st_mode) + self.assertTrue( + mode & stat.S_IXUSR, + f"{rel!r} is 100755 at HEAD but lost its executable bit " + f"during materialization (mode={oct(mode)})") + + def test_plugin_root_has_no_git_directory(self): + self.assertFalse(os.path.isdir(os.path.join(_FIXTURE.plugin_root, ".git"))) + + def test_plugin_root_is_not_the_in_repo_dev_tree(self): + dev_tree = os.path.join(REPO_ROOT, "plugins", "ca") + self.assertNotEqual(os.path.normcase(os.path.abspath(_FIXTURE.plugin_root)), + os.path.normcase(os.path.abspath(dev_tree))) + self.assertFalse( + os.path.abspath(_FIXTURE.plugin_root).lower() + .startswith(os.path.abspath(REPO_ROOT).lower()), + "the materialized plugin root must not live inside this checkout") + + def test_plugin_archive_excludes_gitignored_artifacts(self): + # plugins/ca/hooks/__pycache__/ is a REAL .gitignore:42 pattern + # (`__pycache__/`). A recursive copy of the dev tree would carry + # whatever compiled bytecode happens to be on disk right now; + # `git archive` must not, since it reads the committed tree only. + # + # MEDIUM-5 (adversarial review 2026-07-31): this sentinel is + # injected into a THROWAWAY `--shared` clone of this repo's own + # HEAD (`_clone_head`), never into this checkout — see the module + # docstring's "Materialization" section for why. An earlier version + # wrote directly into `plugins/ca/hooks/__pycache__/` in THIS + # checkout, which both violated the fixture's own "no writes inside + # this repo" contract and would have left the sentinel inside the + # SHIPPED payload path if the process terminated abnormally + # (`os._exit`, a kill) between the write and the `finally` cleanup. + clone_root = tempfile.mkdtemp(prefix="ca-consumer-smoke-clone-") + try: + _clone_head(clone_root) + sentinel_dir = os.path.join(clone_root, "plugins", "ca", "hooks", "__pycache__") + sentinel_file = os.path.join(sentinel_dir, "_consumer_smoke_sentinel.pyc") + os.makedirs(sentinel_dir, exist_ok=True) + with open(sentinel_file, "wb") as fh: + fh.write(b"not real bytecode -- a fixture marker only") + ignored = _git(["check-ignore", "-q", sentinel_file], clone_root, check=False) + self.assertEqual( + ignored.returncode, 0, + f"{sentinel_file!r} is not actually gitignored in the clone " + "— this test's premise is wrong and must be fixed, not the " + "assertion below") + with tempfile.TemporaryDirectory() as scratch: + probe_root = os.path.join(scratch, "plugin-root") + materialize_plugin(clone_root, probe_root) + self.assertFalse( + os.path.isfile(os.path.join( + probe_root, "hooks", "__pycache__", + "_consumer_smoke_sentinel.pyc")), + "a gitignored dev-tree artifact leaked into the " + "git-archive-materialized plugin payload") + finally: + _force_rmtree(clone_root) + + def test_plugin_archive_excludes_uncommitted_untracked_files(self): + # The exact defect class named in AC-6.6: "a skill referencing a + # file that exists locally but was never committed would still + # pass" under a recursive copy. This file is real, on disk, in a + # throwaway clone of THIS repo's own HEAD, and genuinely untracked + # there — `git archive HEAD` must not see it. See MEDIUM-5 above: + # the clone keeps the write, and its abnormal-termination residual, + # entirely inside a scratch tree rather than this checkout. + clone_root = tempfile.mkdtemp(prefix="ca-consumer-smoke-clone-") + try: + _clone_head(clone_root) + sentinel = os.path.join( + clone_root, "plugins", "ca", "_consumer_smoke_uncommitted_sentinel.md") + _write_text(sentinel, "uncommitted -- must never ship\n") + status = _git(["status", "--porcelain", "--", sentinel], clone_root) + self.assertIn( + "??", status.stdout, + f"{sentinel!r} is not actually untracked in the clone — " + "this test's premise is wrong and must be fixed, not the " + "assertion below") + with tempfile.TemporaryDirectory() as scratch: + probe_root = os.path.join(scratch, "plugin-root") + materialize_plugin(clone_root, probe_root) + self.assertFalse( + os.path.isfile(os.path.join( + probe_root, "_consumer_smoke_uncommitted_sentinel.md")), + "an uncommitted dev-tree file leaked into the " + "git-archive-materialized plugin payload") + finally: + _force_rmtree(clone_root) + + def test_consumer_repo_tracked_files_are_exactly_the_declared_set(self): + # Exact-set, not subset: a subset check would silently tolerate a + # later contamination of this repo's own files into the fixture. + result = _git(["ls-files"], _FIXTURE.consumer_root) + tracked = {line for line in result.stdout.splitlines() if line} + self.assertEqual(tracked, set(CONSUMER_TRACKED_FILES)) + + def test_consumer_repo_has_exactly_one_v_tag(self): + result = _git(["tag", "--list"], _FIXTURE.consumer_root) + tags = [line for line in result.stdout.splitlines() if line] + self.assertEqual(tags, ["v1.2.3"]) + + def test_consumer_repo_v_tag_is_annotated(self): + # MEDIUM-LOW-7 (adversarial review 2026-07-31): AC-6.7 rev 4.4 + # justifies creating NO tag in THIS repository on the grounds that + # "real annotated-tag mechanics are exercised inside the AC-6.6 + # scratch fixture" — a claim that only holds if this fixture's own + # tag is actually annotated. `git tag -a` in `build_consumer_repo` + # creates a tag object; `git cat-file -t` distinguishes that from a + # lightweight tag (a bare ref), which a mutated + # `git tag v1.2.3` (no `-a`) would silently produce instead. + obj_type = _git( + ["cat-file", "-t", "v1.2.3"], _FIXTURE.consumer_root).stdout.strip() + self.assertEqual(obj_type, "tag") + + def test_consumer_repo_v_tag_names_the_baseline_commit(self): + tag_commit = _git( + ["rev-list", "-n", "1", "v1.2.3"], _FIXTURE.consumer_root).stdout.strip() + head_commit = _git( + ["rev-parse", "HEAD"], _FIXTURE.consumer_root).stdout.strip() + self.assertNotEqual(tag_commit, head_commit, + "the tag must sit BEHIND HEAD (two commits follow it)") + first_commit = _git( + ["rev-list", "--max-parents=0", "HEAD"], _FIXTURE.consumer_root + ).stdout.strip() + self.assertEqual(tag_commit, first_commit) + + def test_consumer_repo_commits_carry_changelog_footers(self): + log = _git( + ["log", "--format=%B%x03", "HEAD"], _FIXTURE.consumer_root).stdout + bodies = [b for b in log.split("\x03") if b.strip()] + footer_bodies = [b for b in bodies if "CHANGELOG:" in b] + self.assertEqual( + len(footer_bodies), 2, + "expected exactly the feat and fix commits to carry a " + "CHANGELOG: footer") + + def test_consumer_repo_contains_no_file_from_this_repository(self): + self.assertFalse( + os.path.isdir(os.path.join(_FIXTURE.consumer_root, ".codearbiter"))) + self.assertFalse( + os.path.isdir(os.path.join(_FIXTURE.consumer_root, "plugins"))) + with open(os.path.join(_FIXTURE.consumer_root, "package.json"), + encoding="utf-8") as fh: + consumer_pkg = json.load(fh) + with open(os.path.join(REPO_ROOT, "package.json"), encoding="utf-8") as fh: + this_repo_pkg = json.load(fh) + self.assertNotEqual(consumer_pkg["name"], this_repo_pkg["name"]) + + +# --------------------------------------------------------------------------- # +# T-73b/T-79 — reference resolution (started as a ratchet, retired to a +# strict "the unresolved set is empty" assertion) +# --------------------------------------------------------------------------- # + +# Matches a slash-separated, extensioned path reference inside a backtick +# code span: an optional placeholder prefix (`${ALL_CAPS_ENV_VAR}/` for +# `claude`/`codex`'s PLUGIN_ROOT/PROJECT_DIR spelling, or +# `/` for `codex`/`pi`'s PROJECT_DIR and `pi`'s +# PLUGIN_ROOT — see `_load_host_tokens`), zero or more `../` hops, one or +# more `segment/` directory components, and a final `name.ext` segment. +# Segments may contain `*`/`?` (HIGH-1, adversarial review 2026-07-31): the +# release skill's badge-count sync EXECUTES three globs +# (`ls plugins/ca/commands/*.md`, `ls -d plugins/ca/skills/*/`, +# `ls plugins/ca/agents/*.md`) that the pre-fix charset could not see at +# all — `plugins/ca/agents/` has no OTHER representative anywhere in this +# file, so all three were invisible to the ratchet and could keep shipping +# after the tracked list went empty. +# +# Stated matching rule (mirrors the docstring obligation A-6.1 places on +# `check_skill_portability.py`): a candidate MUST contain both a `/` and a +# recognizable `.` file suffix, OR (new) be a directory-only reference +# that is EXECUTED as a glob (contains `*`/`?` — see `_GLOB_DIR_REF_RE` +# below). Deliberately does NOT match: +# +# - a bare filename with no directory qualifier at all (`CHANGELOG.md`, +# `package.json` used as literal `$CHANGELOG`/`$MANIFEST` table VALUES). +# This is the CORRECT portable form for a single-target consumer — the +# spec's own single-artifact example declares `changelog: CHANGELOG.md` +# — so naming it bare is not a portability defect and is not extracted; +# - a PLAIN trailing-slash directory reference with no glob metacharacter +# (`plugins/ca/`, `plugins/ca-pi/tools/` — the `$PAYLOAD` column). These +# are SCOPE patterns, not one executed-or-read file. A glob-BEARING +# trailing-slash reference (`plugins/ca/skills/*/`) is different: `ls -d` +# EXECUTES it, so the scope-pattern rationale does not reach it — +# `_GLOB_DIR_REF_RE` matches ONLY when a `*`/`?` is present, never a +# plain directory mention, which is the discriminator that keeps the +# whole `$PAYLOAD` column from being pulled in as a side effect; +# - a dotted module/attribute reference with no slash +# (`_releaselib.RELEASE_TAG_PREFIXES`, `MAJOR.MINOR.PATCH`). +_PLACEHOLDER_PREFIX = r'(?:\$\{[A-Z_]+\}/|<[a-z][a-z-]*>/)?' +_PATH_REF_RE = re.compile( + _PLACEHOLDER_PREFIX + + r'(?:\.\./)*(?:[A-Za-z0-9_.*?-]+/)+[A-Za-z0-9_.*?-]+\.[A-Za-z0-9]+' +) +_GLOB_DIR_REF_RE = re.compile( + _PLACEHOLDER_PREFIX + + r'(?:\.\./)*(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]*[*?][A-Za-z0-9_.*?-]*/' +) + +# Liveness floor for the extraction, not a permanent contract: the SUM of +# references extracted across every scanned payload (`_RELEASE_SKILL_PAYLOADS`) +# must clear this floor, AND the floor itself must not sit trivially far +# below the live total (see the ratio assertion in +# `test_extraction_is_not_vacuous` — M4, adversarial review 2026-07-31: an +# unasserted floor survived being mutated 15 -> 1, since `total >= 1` is as +# true as `total >= 15` and the test never read the floor's OWN value). As +# T-41a-d replace hardcoded literals with loader-driven values the true +# count WILL drop; lower this floor in the SAME commit that causes it to +# fall, rather than treating a red run here as a reason to widen the regex. +# The exact live count is derived and reported in the assertion failure +# message, never restated here as a second hardcoded figure (LOW finding: +# an earlier version of this comment claimed "22 total references" against +# a live count of 23 — the third unverified-number error in this campaign). +# +# BOTH assertions in `test_extraction_is_not_vacuous` must be re-satisfied +# when lowering this value, not just "total >= floor": the ratio check +# (`floor >= total // 2`) constrains it from the other side. The three full +# skill copies (`ca`, `ca-codex`/`ca-pi` routines) shrink together toward +# T-41's portable form while the two stub payloads hold at 1 reference +# each and never shrink, so pick a floor inside BOTH bounds against the +# post-migration total, not just the pre-migration one. +# +# Lowered by T-41a-d (issue #563): the Targets table -> loader rewrite +# removed the bulk of the previously-extracted literals (measured live total +# post-rewrite: 14, across all five payloads — 4 for `ca`, 1 each for the +# two stubs, 4 each for the `ca-codex`/`ca-pi` routines copies). 12 sits +# inside both bounds (14 >= 12, and 12 >= 14 // 2 = 7) with a small margin +# rather than pinning the floor to the exact live count. +_EXTRACTION_FLOOR = 12 +_STABLE_ANCHOR_REF = "${CLAUDE_PLUGIN_ROOT}/includes/anti-slop-design/core.md" + +# T-73b payload list — one entry per shipped copy of the release skill. +# (label, host, path relative to that host's materialized plugin root, +# anchor suffix appended to that host's PLUGIN_ROOT token). The anchor is a +# reference expected to ALWAYS be present, unrelated to this campaign's +# migration, so a regression in extraction (or a materialization reading +# the wrong file) shows up as a vacuous-extraction failure rather than a +# silent, wrongly-empty unresolved set. +# +# MEDIUM-3 (adversarial review 2026-07-31): the spec's own "Source of +# truth" section names `plugins/ca/skills/release/`, +# `plugins/ca-codex/skills/ca-release/`, and +# `plugins/ca-pi/skills/ca-release/` PLUS `plugins/ca-pi/routines/release/` +# — four locations. The review additionally found `ca-pi/routines/release/ +# SKILL.md` outside the fixture's reach; generalizing that fix (scanning +# EVERY host's routed-to skill, not just the one the review named) surfaces +# a FIFTH location neither the spec nor the review names at all: +# `plugins/ca-codex/routines/release/SKILL.md` carries an IDENTICAL, +# 7-reference-deep copy of the exact contamination. Scanning only the +# payload the review happened to name would have left this one shipping +# uncaught even after this remediation. +_RELEASE_SKILL_PAYLOADS = ( + ("ca", "claude", "skills/release/SKILL.md", + "includes/anti-slop-design/core.md"), + ("ca-codex (stub)", "codex", "skills/ca-release/SKILL.md", + "routines/release/SKILL.md"), + ("ca-codex (routines)", "codex", "routines/release/SKILL.md", + "includes/anti-slop-design/core.md"), + ("ca-pi (stub)", "pi", "skills/ca-release/SKILL.md", + "routines/release/SKILL.md"), + ("ca-pi (routines)", "pi", "routines/release/SKILL.md", + "includes/anti-slop-design/core.md"), +) + +# The two stub payloads are pure routers to the full skill and are expected +# to contribute NO unresolved reference of their own — verified by +# `test_stub_release_skills_contribute_no_unresolved_refs` rather than left +# as a one-time manual claim ("verify and leave alone") in a review comment. +_STUB_PAYLOAD_LABELS = frozenset({"ca-codex (stub)", "ca-pi (stub)"}) + + +def _load_host_tokens(): + """(plugin_token, project_token) per governance host, read from + `core/hosts.json` — the single source `tools/build-surface.py` itself + renders each generated skill copy from — rather than re-declaring a + second, driftable copy of the mapping here. `pi`'s tokens + (``, ``) differ in SPELLING from `claude`'s + (`${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PROJECT_DIR}`); a resolver + hardcoded to one spelling would misclassify the other host's + already-portable references as bare, this-repo-relative contamination + (MEDIUM-3's pi-token finding, adversarial review 2026-07-31).""" + path = os.path.join(REPO_ROOT, "core", "hosts.json") + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + return { + host["name"]: (host["tokens"]["PLUGIN_ROOT"], host["tokens"]["PROJECT_DIR"]) + for host in data["hosts"] + } + + +_FENCED_CODE_BLOCK_RE = re.compile(r'```.*?```', re.DOTALL) + + +def _extract_refs(skill_text): + """Every backtick-code-span path reference in `skill_text` matching + `_PATH_REF_RE` or `_GLOB_DIR_REF_RE`. Backtick spans are this + codebase's near-universal convention for naming an executed or read + path in skill/command prose. + + Triple-backtick FENCED code blocks are excised first, and must be: a + naive `` `([^`]+)` `` single-backtick pairing treats the first two + backticks of an opening ```` ``` ```` fence as an empty span, then pairs + its third backtick with the FIRST backtick of the closing fence — so + everything between (the whole fenced block) is swallowed as one giant + span, and backtick-pair alignment for the ENTIRE REST OF THE FILE after + it is shifted. Measured against the live release skill (after the + back-fill lane's Phase-3 marker fences landed, f199962): unpatched, only + 8 of the 14 real references across all five payloads were extracted — + including losing the `${CLAUDE_PLUGIN_ROOT}/includes/anti-slop-design/ + core.md` and `.codearbiter/CONTEXT.md` references outright, which is + exactly the "extractor silently drops something" failure class a + resolution check exists to prevent. The fenced blocks here are raw shell + (`mkdir`/`touch`/`rm -f` with a `git rev-parse`-derived path), never a + portability-checkable literal, so excising them loses nothing this + checker is meant to catch. Substituting a newline (not the empty + string) keeps line numbers stable for anything that reports them.""" + skill_text = _FENCED_CODE_BLOCK_RE.sub('\n', skill_text) + refs = set() + for span in re.findall(r'`([^`]+)`', skill_text): + refs.update(_PATH_REF_RE.findall(span)) + refs.update(_GLOB_DIR_REF_RE.findall(span)) + return refs + + +def _within_bounds(rel): + """True iff a POSIX-style relative path `rel` cannot escape the root it + would be joined to — no leading `/`, no `..` segment — checked WITHOUT + touching the filesystem, so a placeholder-rooted reference is rejected + on shape alone before any existence check runs.""" + if rel.startswith("/") or rel.startswith("\\"): + return False + return not any(part == ".." for part in rel.split("/")) + + +# HIGH-2 (adversarial review 2026-07-31): the ONLY reason a project-dir +# reference skips a physical existence check at all is that a +# consumer-owned `.codearbiter/` path is a session/runtime fact, not a +# packaging fact (see `_resolves`'s docstring). The pre-fix behavior +# exempted the WHOLE project-dir arm unconditionally — measured, +# `${CLAUDE_PROJECT_DIR}/.github/scripts/_releaselib.py` resolved `True` — +# which is a gaming vector: repointing every this-repo helper under that +# placeholder would empty the ratchet while leaving the skill exactly as +# unportable, and nothing else in this campaign discriminates against it +# (`check_skill_portability.py` does not exist yet; AC-6.1 permits the +# prefix wholesale). Only `.codearbiter/` is exempt; every other +# project-dir path falls through to a real existence check against the +# scratch consumer repo. +_PROJECT_DIR_EXEMPT_PREFIX = ".codearbiter/" + + +def _project_dir_is_exempt(rel): + return rel == _PROJECT_DIR_EXEMPT_PREFIX.rstrip("/") or \ + rel.startswith(_PROJECT_DIR_EXEMPT_PREFIX) + + +def _resolves(ref, plugin_root, consumer_root, + plugin_token="${CLAUDE_PLUGIN_ROOT}", + project_token="${CLAUDE_PROJECT_DIR}"): + """Classify one extracted path reference against the two scratch roots. + `plugin_token`/`project_token` are the placeholder SPELLING the host + that rendered this skill copy uses (`_load_host_tokens`); they default + to `claude`'s spelling for direct callers/tests that only ever + exercise that host. + + - `/` — the PORTABLE, correct form for a + shipped-payload path. Checked for PHYSICAL existence under the + git-archived plugin tree: this is the one arm that can catch a real + payload-packaging failure (a file the skill's prose promises but the + shipped payload does not actually carry) — the "installed result, + physically" check A-6.6 distinguishes from the lexical A-6.1 guard. + + - `/` — the PORTABLE, correct form for a + consumer-repo-owned project-state path. Existence is checked for + every subtree EXCEPT `.codearbiter/` (`_project_dir_is_exempt`, + HIGH-2 above): whether a consumer has already run `/ca:init` and + populated `.codearbiter/` is a session/runtime fact, never a + packaging fact — the skill's own prose already handles absence + explicitly ("Read these, or STOP and surface the gap — never + guess"). Treating absence there as a portability defect would pin + `.codearbiter/CONTEXT.md` into the ratchet list PERMANENTLY, since + T-73a's fixture deliberately has no `.codearbiter/` (per the spec's + declared consumer-repo contents) — which would make T-79's "assert + the list is empty" unreachable. Any OTHER project-dir path is real + existence-checked against the scratch consumer repo, so the + exemption cannot be used to launder an arbitrary this-repo path. + + - anything else — a BARE repo-relative path with no placeholder at + all. The only way such a reference resolves at runtime is if the + skill's shell commands happen to run with the CONSUMER repo as cwd + (which they do), so it is checked for existence there, after the + SAME in-bounds check the placeholder arms get — defensive hardening + added alongside HIGH-2: an unbounded bare `../../` + reference could otherwise escape the scratch consumer root the same + way an unbounded project-dir arm could. A bare path naming this + repository's own layout (`.github/scripts/...`, `plugins/ca/...`, + `tools/...`) is exactly the contamination class this campaign exists + to remove, and will not exist in ANY consumer repo.""" + plugin_prefix = plugin_token + "/" + project_prefix = project_token + "/" + if ref.startswith(plugin_prefix): + rel = ref[len(plugin_prefix):] + if not _within_bounds(rel): + return False + return os.path.exists(os.path.join(plugin_root, *rel.split("/"))) + if ref.startswith(project_prefix): + rel = ref[len(project_prefix):] + if not _within_bounds(rel): + return False + if _project_dir_is_exempt(rel): + return True + return os.path.exists(os.path.join(consumer_root, *rel.split("/"))) + if not _within_bounds(ref): + return False + return os.path.exists(os.path.join(consumer_root, *ref.split("/"))) + + +class ReferenceResolutionTest(unittest.TestCase): + """T-73b/T-79 (A-6.6, 'reference resolution'). Physically resolves every + executed-or-read path reference in EVERY installed copy of the release + skill (`_RELEASE_SKILL_PAYLOADS`) against the matching scratch plugin + root and the shared scratch consumer repo, and asserts the UNIONED + unresolved set is EMPTY. + + T-79 retired the T-73b RATCHET (equality against a committed + `known-unresolved-refs.txt`, in either direction) into this strict + zero-form: the skill rewrite (T-41a-d/T-41f) resolved all 24 entries + the ratchet ever carried, so there is no longer a tolerated-failures + list to compare against — an empty set is simply asserted outright. + + The ratchet's whole reason to exist was failing in BOTH directions (a + shrink nobody recorded, and a NEW contaminating reference sneaking in), + and the strict form must keep both. A shrink has nothing left to catch + (the list is already empty), so only the second direction still needs a + live guarantee: a new contaminating reference must still turn this red. + That is exactly what `self.unresolved == set()` asserts, and + `test_extraction_is_not_vacuous` below is what keeps this assertion from + being satisfiable by an extractor that has quietly stopped matching + anything — the exact failure mode this campaign already found once + (M4, adversarial review 2026-07-31).""" + + @classmethod + def setUpClass(cls): + cls.host_tokens = _load_host_tokens() + root_by_host = { + "claude": _FIXTURE.plugin_root, + "codex": _FIXTURE.codex_plugin_root, + "pi": _FIXTURE.pi_plugin_root, + } + cls.per_payload = {} + cls.unresolved = set() + cls.extracted_total = 0 + for label, host, relpath, anchor_suffix in _RELEASE_SKILL_PAYLOADS: + plugin_root = root_by_host[host] + plugin_token, project_token = cls.host_tokens[host] + skill_path = os.path.join(plugin_root, *relpath.split("/")) + if not os.path.isfile(skill_path): + raise RuntimeError( + f"installed release skill not found at {skill_path!r} " + f"(payload {label!r}) — the plugin payload " + "materialization itself is broken, not merely this " + "reference check") + with open(skill_path, encoding="utf-8") as fh: + text = fh.read() + extracted = _extract_refs(text) + unresolved = { + ref for ref in extracted + if not _resolves(ref, plugin_root, _FIXTURE.consumer_root, + plugin_token, project_token) + } + cls.per_payload[label] = { + "extracted": extracted, + "unresolved": unresolved, + "anchor": plugin_token + "/" + anchor_suffix, + } + cls.extracted_total += len(extracted) + cls.unresolved |= unresolved + + def test_extraction_is_not_vacuous(self): + for label, data in self.per_payload.items(): + self.assertIn( + data["anchor"], data["extracted"], + f"payload {label!r}: a reference that should always be " + "found (unrelated to this campaign's migration) is " + "missing — the extractor likely regressed, or the " + "installed skill file resolved to the wrong path") + self.assertGreaterEqual( + self.extracted_total, _EXTRACTION_FLOOR, + f"only {self.extracted_total} path references were extracted " + f"across all {len(_RELEASE_SKILL_PAYLOADS)} scanned release-" + f"skill payloads (floor: {_EXTRACTION_FLOOR}). If this is " + "because T-41a-d legitimately removed hardcoded references, " + "lower _EXTRACTION_FLOOR in the SAME commit; if not, the " + "extraction regex itself likely regressed") + # M4 (adversarial review 2026-07-31): the floor assertion above + # alone is satisfied by ANY positive floor, so mutating + # _EXTRACTION_FLOOR down to 1 survives it silently. This second + # assertion reads the floor's OWN value against the live total, so + # a floor that has drifted far below reality — whether by mutation + # or by neglect — goes red here specifically. + self.assertGreaterEqual( + _EXTRACTION_FLOOR, self.extracted_total // 2, + f"_EXTRACTION_FLOOR ({_EXTRACTION_FLOOR}) is far below the " + f"live extracted total ({self.extracted_total}) — it no " + "longer distinguishes 'the extractor stopped matching " + "anything' from 'the skill genuinely has fewer references " + "now'; raise it toward the live count") + + def test_stub_release_skills_contribute_no_unresolved_refs(self): + # MEDIUM-3 (adversarial review 2026-07-31): the review found these + # two payloads are "short stubs with none" and said "verify and + # leave alone" — proven here directly, on every run, rather than + # left as a one-time manual claim in a review comment. + for label in _STUB_PAYLOAD_LABELS: + self.assertEqual( + self.per_payload[label]["unresolved"], set(), + f"stub payload {label!r} now carries an unresolved " + "reference of its own — it is no longer a pure router to " + "the full release skill and must be scanned as one") + + def test_reference_resolution_is_empty(self): + # T-79's strict zero-form: no committed list to compare against — + # the UNIONED set of unresolved path references across every + # installed release-skill payload (materialized from live HEAD) + # must simply be empty. + self.assertEqual( + self.unresolved, set(), + "the release skill (or one of its ca-codex/ca-pi copies) " + "carries a path reference that does not resolve against the " + "shipped payload or the scratch consumer repo — a NEW " + "contaminating this-repo reference, or a payload-packaging " + "regression:\n" + f" unresolved: {sorted(self.unresolved)}\n" + "Remember: the fixture archives `HEAD`, not the working tree — " + "commit the fix before re-running this test.") + + +class ResolverUnitTest(unittest.TestCase): + """Direct, synthetic-input coverage of `_resolves`'s three arms and the + extractor's glob support. The live release skill carries exactly ONE + `${CLAUDE_PLUGIN_ROOT}` reference in its `claude` copy and it happens + to resolve, so a mutant that made that arm always return True would + survive `ReferenceResolutionTest` undetected; these exercise each arm + with both a present and an absent input directly.""" + + def test_plugin_root_arm_present(self): + self.assertTrue(_resolves( + _STABLE_ANCHOR_REF, _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_plugin_root_arm_absent(self): + self.assertFalse(_resolves( + "${CLAUDE_PLUGIN_ROOT}/does/not/exist.md", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_plugin_root_arm_rejects_escape(self): + # Points at a path that GENUINELY EXISTS just outside plugin_root + # (the sibling consumer_root's own package.json) rather than an + # arbitrary absent one — so only `_within_bounds`, never the + # existence check, can be the reason this returns False. An escape + # ref that merely happens not to exist would pass this assertion + # even with the bounds check deleted outright. + self.assertFalse(_resolves( + "${CLAUDE_PLUGIN_ROOT}/../consumer-repo/package.json", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_project_dir_arm_is_resolved_by_construction(self): + self.assertTrue(_resolves( + "${CLAUDE_PROJECT_DIR}/.codearbiter/CONTEXT.md", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_project_dir_arm_rejects_escape(self): + self.assertFalse(_resolves( + "${CLAUDE_PROJECT_DIR}/../outside.md", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_project_dir_arm_gaming_vector_is_rejected(self): + # HIGH-2 (adversarial review 2026-07-31): pre-fix, ANY path + # repointed under ${CLAUDE_PROJECT_DIR} resolved unconditionally — + # this measured True before the fix. A skill could repoint every + # this-repo helper under this placeholder and empty the ratchet + # while shipping exactly the same contamination. + self.assertFalse(_resolves( + "${CLAUDE_PROJECT_DIR}/.github/scripts/_releaselib.py", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_project_dir_arm_exemption_is_narrow(self): + # Paired with the two tests above: a NON-.codearbiter project-dir + # path that genuinely exists in the consumer repo still resolves + # via a real existence check, and one that does not exist still + # fails — proving the exemption is scoped to `.codearbiter/` + # specifically, never widened to "anything under the project dir" + # as an overcorrection for the gaming vector. + self.assertTrue(_resolves( + "${CLAUDE_PROJECT_DIR}/package.json", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + self.assertFalse(_resolves( + "${CLAUDE_PROJECT_DIR}/does/not/exist.md", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_bare_arm_present(self): + self.assertTrue(_resolves( + "src/widget.py", _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_bare_arm_absent(self): + self.assertFalse(_resolves( + "docs/missing.md", _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_bare_arm_rejects_escape(self): + # Defensive hardening added alongside HIGH-2, the same + # gaming-vector class applied to the THIRD arm: a bare reference is + # now checked in-bounds before any existence check. Points at a + # path that GENUINELY EXISTS just outside consumer_root (the + # sibling plugin_root's own SKILL.md) so only the bounds check, + # never the existence check, can be the reason this returns False. + self.assertFalse(_resolves( + "../plugin-root/skills/release/SKILL.md", + _FIXTURE.plugin_root, _FIXTURE.consumer_root)) + + def test_resolves_with_pi_host_tokens(self): + # The resolver must work for a host whose placeholder SPELLING is + # not claude's, not just accept the string by accident. + plugin_token, project_token = _load_host_tokens()["pi"] + self.assertTrue(_resolves( + f"{plugin_token}/routines/release/SKILL.md", + _FIXTURE.pi_plugin_root, _FIXTURE.consumer_root, + plugin_token, project_token)) + self.assertFalse(_resolves( + f"{plugin_token}/does/not/exist.md", + _FIXTURE.pi_plugin_root, _FIXTURE.consumer_root, + plugin_token, project_token)) + + def test_pi_style_placeholder_is_not_misread_as_bare(self): + # MEDIUM-3's pi-token finding (adversarial review 2026-07-31): a + # claude-spelled extractor cannot even MATCH `/...` at + # the prefix (no `$` or all-caps env-var shape), so `findall` + # slides forward and returns only the bare tail — which then fails + # a real-existence check against consumer_root and becomes a + # PERMANENT false-contamination entry no T-41x task could ever + # clear. Proven directly: the whole placeholder-qualified path must + # be captured, not merely its tail. + text = "`/includes/anti-slop-design/core.md`" + self.assertEqual( + _extract_refs(text), + {"/includes/anti-slop-design/core.md"}) + + def test_extractor_sees_executed_globs(self): + # HIGH-1 (adversarial review 2026-07-31): before the segment + # charset and `_GLOB_DIR_REF_RE` existed, all three of these + # EXECUTED globs from the release skill's badge-count sync were + # invisible to the extractor — `plugins/ca/agents/` has no other + # representative anywhere in the file, so the ratchet could go + # empty while all three kept shipping. + text = ( + "`commands = ls plugins/ca/commands/*.md | grep -v INDEX | wc -l`\n" + "`skills = ls -d plugins/ca/skills/*/ | wc -l`\n" + "`agents = ls plugins/ca/agents/*.md | grep -v INDEX | wc -l`\n" + ) + self.assertEqual( + _extract_refs(text), + {"plugins/ca/commands/*.md", "plugins/ca/skills/*/", + "plugins/ca/agents/*.md"}) + + def test_glob_dir_ref_does_not_match_a_plain_scope_directory(self): + # The discriminator HIGH-1 relies on: a plain trailing-slash SCOPE + # mention with no glob metacharacter must stay excluded, or the fix + # would pull the whole `$PAYLOAD` column (`plugins/ca/`, + # `plugins/ca-pi/tools/`, ...) into the ratchet as a side effect. + text = "`plugins/ca/` and `plugins/ca-pi/tools/` are payload scopes." + self.assertEqual(_extract_refs(text), set()) + + def test_extractor_still_sees_a_reference_after_a_fenced_code_block(self): + # Regression pin, independent of the live skill text: introduced by + # f199962 ("add the back-fill lane"), a fenced ```bash block desyncs + # a naive `` `([^`]+)` `` single-backtick pairing for everything + # AFTER it — measured live, this silently dropped 6 of 14 real + # references across the five shipped payloads, including the very + # anchor `test_extraction_is_not_vacuous` depends on. This is the + # exact "an extractor that silently matches nothing [useful]" class + # of failure the empty-set assertion (`test_reference_resolution_ + # is_empty`) would otherwise be vacuous against. A synthetic fixture + # here catches a regression even if the live skill text changes + # shape enough to stop tripping over it by accident. + text = ( + "Mint the marker:\n\n" + " ```bash\n" + " mkdir -p \"$(git rev-parse --show-toplevel)/.codearbiter/.markers\"\n" + " ```\n\n" + "Then apply `${CLAUDE_PLUGIN_ROOT}/includes/anti-slop-design/core.md` " + "before writing.\n" + ) + self.assertEqual( + _extract_refs(text), + {"${CLAUDE_PLUGIN_ROOT}/includes/anti-slop-design/core.md"}) + + +# --------------------------------------------------------------------------- # +# MEDIUM-6 (adversarial review 2026-07-31) — the hermetic git-env guards +# --------------------------------------------------------------------------- # + + +class HermeticGitEnvTest(unittest.TestCase): + """`_isolated_git_env`/`_git`'s own docstrings call two controls + "load-bearing" (issue #556) and cite a concrete threat for each, but + nothing previously EXECUTED either claim — the assert-the-contract- + instead-of-the-path shape (memory: dry-run-the-path-not-the-contract). + Both tests here poison an AMBIENT setting the way a real host could, + then prove the fixture's own git calls are unaffected; each is + reproducibly killed by removing the specific override it names.""" + + def test_git_config_global_neutralizes_an_ambient_template_dir_poison(self): + # `core.hooksPath`/`commit.gpgsign`/`tag.gpgsign` are already + # neutralized on every `_git` call via explicit `-c` flags + # regardless of GIT_CONFIG_GLOBAL, so poisoning THOSE keys would + # not discriminate a mutant that renamed GIT_CONFIG_GLOBAL itself. + # `init.templateDir` is not covered by any per-call `-c` override — + # it is exactly the class of ambient setting GIT_CONFIG_GLOBAL + # alone protects. A poisoned templateDir's contents get copied + # into every `git init`'d repo's `.git/`, so its presence or + # absence is directly observable. + with tempfile.TemporaryDirectory() as scratch: + poison_template = os.path.join(scratch, "poison-template") + os.makedirs(poison_template, exist_ok=True) + with open(os.path.join(poison_template, "POISON.txt"), "w") as fh: + fh.write("poison\n") + poison_global = os.path.join(scratch, "poison-global.gitconfig") + with open(poison_global, "w") as fh: + fh.write("[init]\n\ttemplateDir = %s\n" + % poison_template.replace("\\", "/")) + + old = os.environ.get("GIT_CONFIG_GLOBAL") + os.environ["GIT_CONFIG_GLOBAL"] = poison_global + try: + repo = os.path.join(scratch, "repo") + os.makedirs(repo, exist_ok=True) + _git(["init", "-q"], repo) + self.assertFalse( + os.path.isfile(os.path.join(repo, ".git", "POISON.txt")), + "an ambient GIT_CONFIG_GLOBAL leaked its init.templateDir " + "contents into a fixture-initialized repo — " + "_isolated_git_env's GIT_CONFIG_GLOBAL override is not " + "doing its job") + finally: + if old is None: + os.environ.pop("GIT_CONFIG_GLOBAL", None) + else: + os.environ["GIT_CONFIG_GLOBAL"] = old + + def test_per_call_hookspath_override_bypasses_a_poisoned_default_hooks_dir(self): + # A poisoned real `.git/hooks/pre-commit` (issue #556's stale + # cross-host enforcer cache, or any other blocking hook) sits at + # the DEFAULT hooks location, which `core.hooksPath` from + # GIT_CONFIG_GLOBAL alone would not relocate away from (a repo's + # own local `.git/hooks/` is consulted regardless of the global + # config file, absent an explicit `core.hooksPath` override on + # THIS call). `_git` sets that override per call; deleting it + # would let this exact hook fire and block the commit. + with tempfile.TemporaryDirectory() as scratch: + repo = os.path.join(scratch, "repo") + os.makedirs(repo, exist_ok=True) + _git(["init", "-q"], repo) + hooks_dir = os.path.join(repo, ".git", "hooks") + os.makedirs(hooks_dir, exist_ok=True) + hook_path = os.path.join(hooks_dir, "pre-commit") + with open(hook_path, "w", newline="\n") as fh: + fh.write("#!/bin/sh\nexit 1\n") + try: + os.chmod(hook_path, 0o755) + except OSError: + pass + _write_text(os.path.join(repo, "a.txt"), "hi\n") + _git(["add", "-A"], repo) + result = _git(["commit", "-q", "-m", "test commit"], repo, check=False) + self.assertEqual( + result.returncode, 0, + "a poisoned default .git/hooks/pre-commit blocked a fixture " + "commit — _git's per-call `-c core.hooksPath=...` override " + "is not bypassing it") + + +# --------------------------------------------------------------------------- # +# T-74/T-75 — the lane driver: invocation strings extracted from the +# INSTALLED release skill, run for real (or classified "accounted", see +# below) against a private, disposable consumer repo. (issue #563, AC-6.6 +# "Lane driver" and +# "Assertions are on derived outputs"; .codearbiter/plans/portable-release- +# and-protected-state.md T-74, T-75.) +# +# Why this is not a direct-import test of core/pysrc/_releaselib.py: the +# release skill (`plugins/ca/skills/release/SKILL.md`, pre-T-41x) tells its +# reader to run SPECIFIC shell command lines — `TAG_PREFIX=$(python3 +# .github/scripts/_releaselib.py tag-prefix $TARGET)`, `git log +# LAST_TAG..HEAD -- $PAYLOAD`, `git tag -a ... -F `, and so +# on. A test that imports `_releaselib.py`'s functions directly and calls +# them in Python can pass while the PROSE instructs the reader to invoke a +# CLI shape that script no longer accepts (an added required argument, a +# renamed subcommand, a flag that moved) — direct import cannot see that +# drift because it never goes through the CLI surface the prose actually +# names. This module instead locates the literal backtick-delimited command +# line following a stable prose ANCHOR in the installed skill text, and +# either subprocess-executes it for real (after substituting the variables +# the skill's own prose defines: `$TARGET`, `$TAG_PREFIX`, `$PAYLOAD`, +# `LAST_TAG`, `${TAG_PREFIX}${VERSION}`, ``) or, when +# the invocation names a this-repo path with no `__main__` to run (this +# module's own historical example was `.github/scripts/_releaselib.py`, a +# CI-only shim that never shipped in the plugin payload), classifies it +# "accounted" rather than running it. T-41b's repointing plus giving the +# portable mechanism a CLI were BOTH prerequisites before this class of +# invocation could ever move from accounted to run; the live skill no +# longer produces one (see `test_no_invocations_remain_accounted` below). +# T-79 additionally retired the `known-unresolved-refs.txt` file this +# accounting used to be cross-checked against — "accounted" is now a +# synthetic/unit-test-only concept (`ReferenceResolutionTest`'s empty-set +# assertion is the live guarantee), kept as general machinery in case a +# future invocation reintroduces an unresolvable this-repo path. +# +# Honest scope limit, stated once here rather than left implicit: this +# driver only extracts from `## Targets` through the end of `## Phase 2` — +# never `## Phase 3 — Publish`, which requires explicit user authorization +# and names `git push`, `gh release create`, and `gh release view`. Those +# are gated write/publish actions this fixture must never perform even +# against a disposable repo (no network, no auth, and out of AC-6.6's named +# "target resolution, window derivation, bump classification, changelog +# rolling, tag-message composition" scope). `RELEASE_DATE=$(date +%F)` is +# ALSO out of this driver's extracted set for a narrower reason: `date` is +# not a stdlib-guaranteed executable on every platform this suite runs on +# (the same class of problem T-42's python3->interpreter-fallback exists +# to fix, but nothing yet fixes it for `date`), so this module derives the +# release date with Python's own `datetime.date.today()` instead of +# shelling out — labelled here as test-authored plumbing, never claimed as +# an "extracted invocation". + +RELEASE_TARGETS_BLOCK = ( + "\n" + "[app]\n" + "prefix: v\n" + "manifest: package.json\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" +) + +# A candidate backtick span "looks like an invocation" iff it starts with a +# shell assignment (`VAR=$(`), or a bare `git `/`python3 `/`node ` command — +# the shapes every invocation this driver cares about actually takes. This +# excludes non-invocation backtick spans that sit near an anchor in the same +# sentence (a dotted function reference like `_releaselib.classify_publish_ +# state`, or a bare variable mention like `$PAYLOAD`), so the capture walks +# past those to the real command rather than mis-extracting the first +# backtick span it meets. +# `"$PY" ` and `$PY ` are as much a command start as a bare `python3 ` here: +# the skill resolves the interpreter once up front and invokes it through +# that variable at every later site, so omitting the spelling does not make +# the scan MISS an invocation -- it makes the scan walk PAST it to whatever +# command-shaped span comes next, which for the `publish_state_classify` +# anchor is the `git tag -a ... -F ` in the branch below. +# That failure is silent and wrong rather than loud: the driver then runs a +# tag command under the classify label, with a mapping that has no +# `` in it, and reports a git error for a step that was never +# the one under test. +_INVOCATION_SHAPE_RE = re.compile( + r'^(?:[A-Za-z_][A-Za-z0-9_]*=\$\(|git |python3 |node |"\$PY" |\$PY )') + +# label -> (stable prose anchor, expected classification). Each anchor +# string is verified unique in the installed skill text (ConsumerFixtureTest- +# style hard-fail on drift, not a silent widen); the expected classification +# is asserted directly in LaneDriverTest.test_lane_driver_classification_map +# so a mutant flipping run<->accounted is caught without comparing the +# driver to itself. +_LANE_INVOCATION_ANCHORS = ( + ("target_resolution_tag_prefix", "never typed from memory:", "run"), + ("window_last_tag", "never a hand-rolled grep:", "run"), + ("window_scope_bare", "the commit set is", "run"), + ("window_scope_full_log", "Read every commit in the", "run"), + # Re-anchored from the former "Tag with": Phase 2 step 1 was reordered + # so `git tag` runs only inside the `publish_fresh` branch, AFTER + # classification (MEDIUM, run-3 adversarial review — a literal reading + # of the old order wrote the ref before computing whether writing it + # was safe). The captured invocation is the SAME `git tag -a … -F + # `; only its position, and the landmark ahead of it, + # moved. + # + # The anchor must sit between the relocated command and any earlier + # backtick span that also matches _INVOCATION_SHAPE_RE. An intuitive + # landmark ("this is the only place `git tag` runs") fails silently + # here: the bare `git tag` mention matches the shape regex first, so + # find()-then-first-match captures the two-word fragment instead of the + # real command, and shlex.split hands subprocess an argv that is not + # the lane's. Hence a landmark that precedes the command with no + # command-shaped span in between. + ("tag_message_composition", "write the ref now, and only here", "run"), + ("publish_state_classify", "do not flatly abort", "run"), +) + +# T-41b/T-41f (issue #563): all six anchored invocations now resolve under +# the vendored plugin's OWN CLI (`${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py`, +# core/pysrc/_releaselib.py's `__main__` entry point) and run for real — +# before this rewrite, three of the six named `.github/scripts/_releaselib.py` +# (a CI-only shim with no `__main__`) and were merely ACCOUNTED for on the +# T-73b ratchet (since retired by T-79), since nothing could actually invoke +# that path from inside a consumer. `_classify_invocation`/`_LANE_SHIM_MARKER` remain as +# general machinery — `LaneDriverUnitTest` still exercises the accounted arm +# directly against synthetic text — in case a future invocation reintroduces +# an unresolvable this-repo path; the LIVE skill no longer produces one. +_LANE_SHIM_MARKER = ".github/scripts/_releaselib.py" + + +def _capture_invocation_after_anchor(skill_text, anchor, window=500): + """Find `anchor` (a stable, unique prose substring) in `skill_text`, then + return the content of the FIRST invocation-shaped backtick span within + `window` characters after it — skipping any non-invocation backtick span + (a dotted name, a bare variable mention) that appears first in the same + sentence. Hard-fails with a named cause, rather than returning an empty + or partial result, if the anchor is missing (the skill's prose shape + changed) or no invocation-shaped span follows it (the skill stopped + naming a command where it used to) — this IS the drift detector; a + silent skip here would defeat the entire point of extracting from the + installed text instead of importing the library directly.""" + idx = skill_text.find(anchor) + if idx == -1: + raise RuntimeError( + f"lane-driver anchor not found in the installed release skill: " + f"{anchor!r} — the skill's prose has changed shape; this " + "driver's anchors need updating in the SAME commit") + scanned = skill_text[idx + len(anchor): idx + len(anchor) + window] + for m in re.finditer(r'`([^`]+)`', scanned): + candidate = m.group(1) + if _INVOCATION_SHAPE_RE.match(candidate): + return candidate + raise RuntimeError( + f"no invocation-shaped backtick span found within {window} chars " + f"after anchor {anchor!r} — the skill stopped naming a runnable " + "command here") + + +def _classify_invocation(invocation): + """"accounted" iff the invocation names the one currently-unresolvable + this-repo path; "run" otherwise. Post-T-79, there is no committed + ratchet file to cross-check the accounted case against — the live + guarantee is `test_no_invocations_remain_accounted`, which asserts the + accounted case never actually occurs against the shipped skill. This + function alone does not read any file, so a unit test can exercise it + against synthetic text with no fixture dependency.""" + return "accounted" if _LANE_SHIM_MARKER in invocation else "run" + + +def _substitute_argv(argv, mapping): + """Token-by-token substitution over an ALREADY-`shlex.split` argv list — + never a substring replace over the raw command STRING before splitting. + This matters concretely on Windows: the composed tag-message temp path + substituted for `` contains backslashes, and `shlex.split` + (POSIX mode) treats an unquoted backslash as an escape character that + would corrupt such a path if it were substituted into the string BEFORE + tokenization and the result were re-split. Splitting the original, + backslash-free skill text FIRST and substituting whole or partial tokens + AFTER avoids ever feeding a Windows path through `shlex`. A single + substring-replace pass per token covers both a WHOLE-token match + (`$PAYLOAD`, ``) and a token that only CONTAINS the + variable (`LAST_TAG..HEAD`) — a separate `tok in mapping` short-circuit + would be dead code, since `str.replace` on an exact match already + returns the same result.""" + out = [] + for tok in argv: + replaced = tok + for old, new in mapping.items(): + if old in replaced: + replaced = replaced.replace(old, new) + out.append(replaced) + return out + + +def _prepare_argv(argv, cwd): + """Apply this module's two standing per-invocation transforms to one + already-tokenized argv list, returning the adjusted list (never mutates + the input): a leading bare `python3` becomes `sys.executable`, since + `python3` is not guaranteed present, especially on Windows (the same + substitution T-42 tracks for the skill itself). + + `$PY` and `"$PY"` are the SAME case, spelled the way the skill now + spells it: the skill opens by resolving `PY=python3` and falling back + to `python` when `command -v python3` finds nothing, then invokes the + interpreter through `$PY` everywhere after. This extractor replays + single invocations, never the whole script, so the resolution line + never runs and the variable is never set -- leaving the literal token + `$PY` as argv[0] and a `FileNotFoundError: '$PY'`. Resolving it to the + same interpreter the skill's own fallback would pick is what makes the + extracted invocation runnable at all. + + A leading `git` gets the + SAME hermetic isolation `_git` gives every other git call in this module + (a lane-driver-composed `git tag -a` is exactly as reachable by an + ambient core.hooksPath/gpgsign poison as any other git invocation here).""" + if argv and argv[0] in ("python3", "python", "$PY"): + argv = [sys.executable] + argv[1:] + if argv and argv[0] == "git": + no_hooks = os.path.join(cwd, ".ca-fixture-no-hooks-dir") + argv = ["git", + "-c", f"core.hooksPath={no_hooks}", + "-c", "commit.gpgsign=false", + "-c", "tag.gpgsign=false", + "-c", "user.name=codeArbiter consumer-smoke fixture", + "-c", "user.email=fixture@example.invalid", + *argv[1:]] + return argv + + +def _run_argv(argv, cwd, input_text=None): + """Execute one substituted, already-tokenized invocation. No + `shell=True` anywhere in this module — every invocation this driver runs + is a plain argv list; a pipe-bearing invocation is staged as SEPARATE + subprocess calls chained by stdin (`_run_command_substitution`), never a + literal `|` handed to a real shell. `input_text`, when given, is piped to + the process's stdin (the pipeline-staging case). + + T-41f (issue #563): a python invocation's environment is built EXPLICITLY + rather than inherited. `core/pysrc/_releaselib.py`'s `tag-prefix` + subcommand resolves its declared file via `CLAUDE_PROJECT_DIR` first + (`default_targets_path`), and an ambient value leaking in from whatever + session happens to be running this SUITE (this file is itself typically + run from inside a governed session) would silently repoint resolution + away from this fixture's own scratch consumer — exactly the class of + dev-tree-state leak this whole module exists to keep out (memory: + dev-repo-state-masks-consumer-bugs). `CLAUDE_PROJECT_DIR` is therefore + always pinned to `cwd`, mirroring what a real host harness does, and + `CLAUDE_PLUGIN_ROOT` is stripped since no invocation this driver runs + reads it as an env var — the plugin root is already substituted directly + into the argv string before this function ever sees it.""" + argv = _prepare_argv(argv, cwd) + if argv[0] == "git": + env = _isolated_git_env() + else: + env = dict(os.environ) + env.pop("CLAUDE_PLUGIN_ROOT", None) + env["CLAUDE_PROJECT_DIR"] = cwd + return subprocess.run(argv, cwd=cwd, capture_output=True, encoding="utf-8", + timeout=GIT_TIMEOUT, input=input_text, env=env) + + +_VAR_SUBSHELL_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)=\$\((.*)\)$') + + +def _run_command_substitution(invocation, cwd, mapping): + """Run a `VAR=$(command)` shaped invocation — possibly containing an + internal `|` pipeline — for real. Each pipeline stage is a SEPARATE + `_run_argv` call chained by stdin, never a literal `$(...)` or `|` + handed to an actual shell (no `shell=True` anywhere in this module). + Returns `(var_name, stdout.strip(), last_process)` so a caller can both + use the resolved value and assert on the process that produced it. + T-41b/T-41f (issue #563): before the release skill's helper invocations + were repointed under `${CLAUDE_PLUGIN_ROOT}` and the portable mechanism + gained a CLI, both of this driver's `VAR=$(...)` invocations named a + this-repo-only shim with no `__main__` and could only be ACCOUNTED for; + this function is what makes them genuinely RUNNABLE post-rewrite.""" + m = _VAR_SUBSHELL_RE.match(invocation) + if not m: + raise ValueError(f"not a VAR=$(...) invocation: {invocation!r}") + var_name, inner = m.group(1), m.group(2) + stdin_text = None + proc = None + for stage in (s.strip() for s in inner.split("|")): + argv = _substitute_argv(shlex.split(stage), mapping) + proc = _run_argv(argv, cwd, input_text=stdin_text) + stdin_text = proc.stdout + return var_name, (proc.stdout or "").strip(), proc + + +def _independent_last_tag(tags, prefix): + """A LAST_TAG oracle sharing no code with `core/pysrc/_releaselib.py`'s + `last_tag_select` — mirrors `test_release_trace.py`'s + `_independent_last_tag` so agreement between the two is genuine + cross-validation, not two lookups through the same helper.""" + rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") + best = None + for tag in tags: + m = rx.match(tag) + if not m: + continue + version = tuple(int(g) for g in m.groups()) + if best is None or version > best[0]: + best = (version, tag) + return best[1] if best else None + + +def _parse_window_log(stdout): + """Parse `git log --pretty=format:%H%n%s%n%b%n----`'s output into a list + of `{"sha", "subject", "body"}` dicts, oldest-last (git's own order). + Entries are delimited by a line that is EXACTLY `----`, matching the + skill's own format string byte for byte.""" + entries = [] + for chunk in stdout.split("\n----\n"): + chunk = chunk.strip("\n") + if not chunk: + continue + lines = chunk.split("\n") + entries.append({ + "sha": lines[0], + "subject": lines[1] if len(lines) > 1 else "", + "body": "\n".join(lines[2:]).strip("\n"), + }) + return entries + + +_COMMIT_TYPE_RE = re.compile(r"^(\w+)(\([^)]*\))?(!)?:") +_CHANGELOG_FOOTER_RE = re.compile(r"CHANGELOG:\s*(.+)") +_GROUP_BY_TYPE = {"feat": "Added", "fix": "Fixed", "perf": "Performance"} + + +def _commit_type(subject): + m = _COMMIT_TYPE_RE.match(subject) + if not m: + return None, False + return m.group(1), bool(m.group(3)) + + +def _footer_text(body): + m = _CHANGELOG_FOOTER_RE.search(body) + return m.group(1).strip() if m else None + + +def _classify_bump(entries): + """Transcription of `release/SKILL.md` Phase 1 step 2's classification + rule. No CLI exists for this in the pre-T-41x skill — there is no + invocation string to extract, so this is test-authored, mirroring the + numbered prose directly rather than importing a mechanism function + (none exists to import). Returns None for a non-bumping window (the + skill's own STOP case), never a silent default bump.""" + major = minor = patch = False + for e in entries: + ctype, bang = _commit_type(e["subject"]) + if bang or "BREAKING CHANGE:" in e["body"]: + major = True + elif ctype == "feat": + minor = True + elif ctype in ("fix", "perf", "refactor"): + patch = True + if major: + return "major" + if minor: + return "minor" + if patch: + return "patch" + return None + + +def _bump_version(bare, bump): + major, minor, patch = (int(x) for x in bare.split(".")) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + if bump == "patch": + return f"{major}.{minor}.{patch + 1}" + return None + + +def _roll_changelog(existing_text, next_version, entries, release_date): + """Transcription of Phase 1 step 4's rolling rule: a new bracket-heading + section grouped Added/Fixed/Performance from each commit's `CHANGELOG:` + footer, with prior sections left intact. Test-authored for the same + reason `_classify_bump` is — no CLI exists for this step either.""" + groups = {} + for e in entries: + ctype, _ = _commit_type(e["subject"]) + group = _GROUP_BY_TYPE.get(ctype) + if group is None: + continue + footer = _footer_text(e["body"]) + if footer is None: + continue + groups.setdefault(group, []).append(footer) + lines = [f"## [{next_version}] - {release_date}", ""] + for group in ("Added", "Fixed", "Performance"): + if group in groups: + lines.append(f"### {group}") + for item in groups[group]: + lines.append(f"- {item}") + lines.append("") + section_text = "\n".join(lines).rstrip("\n") + "\n" + if existing_text.startswith("# Changelog"): + head, _, rest = existing_text.partition("\n") + full_text = head + "\n\n" + section_text + "\n" + rest.lstrip("\n") + else: + full_text = section_text + "\n" + existing_text + return section_text, full_text + + +def _git_strip_cleanup(text): + """Reproduce `git tag`/`git commit`'s DEFAULT `--cleanup=strip` transform + on a `-F`-supplied message: drop every line starting with `#` (git's + default comment character), squeeze runs of blank lines down to one, and + drop leading/trailing blank lines. Implemented independently of git + itself (never by shelling out and comparing git to itself) so this + module's expectation of what a REAL `git tag -a -F` call produces is a + checkable, mutation-sensitive claim rather than "whatever git happened + to output that day".""" + lines = [ln for ln in text.split("\n") if not ln.startswith("#")] + squeezed = [] + blank_run = False + for ln in lines: + if ln.strip() == "": + if blank_run: + continue + blank_run = True + else: + blank_run = False + squeezed.append(ln) + while squeezed and squeezed[0].strip() == "": + squeezed.pop(0) + while squeezed and squeezed[-1].strip() == "": + squeezed.pop() + return "\n".join(squeezed) + "\n" + + +def _load_mechanism(path, private_name): + """Load one `_releaselib.py` copy under a private module name — mirrors + `test_release_trace.py`'s `_load_core_lane`. Loaded from the MATERIALIZED + plugin root's `hooks/_releaselib.py` (the vendored, shipped copy) rather + than `core/pysrc/_releaselib.py` directly, in keeping with this whole + fixture's point: prove against the installed artifact.""" + spec = importlib.util.spec_from_file_location(private_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[private_name] = module + spec.loader.exec_module(module) + return module + + +class _LaneFixture: + """A fresh, disposable single-package consumer repo carrying a declared + `.codearbiter/release-targets.md`, PRIVATE to one mutating test class. + Never the shared module-level `_FIXTURE.consumer_root`: `ConsumerFixtureTest` + asserts an EXACT tracked-file set and an exact one-tag list against that + shared repo, and this class's own sequence rolls a CHANGELOG and creates + a real annotated tag, either of which would break those assertions (and, + with unittest's alphabetical class ordering inside a module, silently + depend on run order to avoid it).""" + + def __init__(self, label): + self.scratch = tempfile.mkdtemp(prefix=f"ca-lane-driver-{label}-") + try: + self.consumer_root = os.path.join(self.scratch, "consumer") + build_consumer_repo(self.consumer_root) + targets_path = os.path.join( + self.consumer_root, ".codearbiter", "release-targets.md") + _write_text(targets_path, RELEASE_TARGETS_BLOCK) + _git(["add", "-A"], self.consumer_root) + _git(["commit", "-q", "-m", "chore: declare release-targets.md"], self.consumer_root) + except Exception: + # Mirrors `_Fixture.__init__`'s own pattern: `self.scratch` is + # allocated before anything that can fail, so a failure here + # (build_consumer_repo, either git call) must still remove it + # rather than leak — a caller catching a raised exception out of + # `__init__` never gets a `self` to call `cleanup()` on. + self.cleanup() + raise + + def cleanup(self): + _force_rmtree(self.scratch) + + +def _execute_lane_sequence(skill_text, core_lane, consumer_root, + target="app", payload=".", + manifests=("package.json",)): + """Extract, classify, and execute the lane driver's six anchored + invocations against `consumer_root`, returning a dict of every + intermediate and derived value both T-74 and T-75 assert against. + Shared by both test classes so the extraction/substitution/execution + PLUMBING is written once; each class still asserts directly against + literals or independent oracles, never against each other's computed + results, so sharing this function does not make either class's + assertions mutation-dead. + + Post-T-41b/T-41f, all six anchored invocations are RUN, including the + two `VAR=$(...)` command-substitution forms (`target_resolution_tag_ + prefix`, `window_last_tag`) that this driver previously could only + account for — `_run_command_substitution` stages each (the second is an + internal `|` pipeline) as separate subprocess calls chained by stdin, + exactly the sequence a shell would run, never a literal shell itself.""" + result = {"invocations": {}, "classification": {}, "processes": {}} + + for label, anchor, expected in _LANE_INVOCATION_ANCHORS: + invocation = _capture_invocation_after_anchor(skill_text, anchor) + classification = _classify_invocation(invocation) + result["invocations"][label] = invocation + result["classification"][label] = classification + + # The vendored plugin root the loaded `core_lane` module was read from + # (`_load_mechanism` sets `__file__` to `/hooks/ + # _releaselib.py`) — substituted for `${CLAUDE_PLUGIN_ROOT}` in every + # invocation this driver runs, mirroring how a real host resolves that + # placeholder at prompt-render time. + plugin_root = os.path.dirname(os.path.dirname(core_lane.__file__)) + root_mapping = {"${CLAUDE_PLUGIN_ROOT}": plugin_root} + + _, tag_prefix, proc = _run_command_substitution( + result["invocations"]["target_resolution_tag_prefix"], consumer_root, + {**root_mapping, "$TARGET": target}) + result["processes"]["target_resolution_tag_prefix"] = proc + result["tag_prefix"] = tag_prefix + + tags = [t.strip() for t in + _git(["tag", "-l"], consumer_root).stdout.splitlines() if t.strip()] + result["tags"] = tags + + _, last_tag, proc = _run_command_substitution( + result["invocations"]["window_last_tag"], consumer_root, + {**root_mapping, "$TAG_PREFIX": tag_prefix}) + result["processes"]["window_last_tag"] = proc + result["last_tag_lib"] = last_tag + result["last_tag_oracle"] = _independent_last_tag(tags, tag_prefix) + + # `$WINDOW`, derived exactly as Pre-flight prescribes (HIGH, run-5 + # adversarial review): `` is a SENTINEL, not a revision, and + # substituting it into a range is a hard failure — `git log + # ..HEAD` exits 128 with `fatal: bad revision`. The skill used to + # spell the window `LAST_TAG..HEAD` inline, which is why this driver + # substituted `LAST_TAG`; it now spells it `$WINDOW` and states the + # derivation, so the driver performs the same derivation rather than + # hardcoding the range shape. + window = "HEAD" if last_tag == "" else f"{last_tag}..HEAD" + result["window_expr"] = window + for label in ("window_scope_bare", "window_scope_full_log"): + argv = _substitute_argv( + shlex.split(result["invocations"][label]), + {"$WINDOW": window, "$PAYLOAD": payload}) + result["processes"][label] = _run_argv(argv, consumer_root) + + result["window_entries"] = _parse_window_log( + result["processes"]["window_scope_full_log"].stdout) + result["bump"] = _classify_bump(result["window_entries"]) + # The base is the MAXIMUM of the tag baseline and every declared + # manifest's current version (HIGH, run-6 adversarial review). With no + # tag in the series, `_bare_version("")` is "0.0.0" — and a + # project that has shipped 1.4.2 without ever tagging would derive + # 0.1.0 from it, walking its own version backward while every gate + # passes, because step 6's bump makes the manifest-equality assertion + # true after the fact. The prose now reads the manifests HERE, before + # any bump; the driver performs the same derivation rather than + # assuming the tag is the only floor. + # `_bare_version("")` returns the sentinel unchanged, not + # "0.0.0" — it strips a prefix, it does not interpret the no-tag case. + # Mapping it to the 0.0.0 baseline is this caller's job, and doing it + # explicitly keeps a `None` semver_key out of the max() below. + tag_floor = core_lane._bare_version(last_tag) + if core_lane.semver_key(tag_floor) is None: + tag_floor = "0.0.0" + floors = [tag_floor] + for manifest_rel in manifests: + manifest_abs = os.path.join(consumer_root, *manifest_rel.split("/")) + try: + with open(manifest_abs, encoding="utf-8") as fh: + declared = json.load(fh).get("version") + except (OSError, ValueError): + declared = None + if isinstance(declared, str) and core_lane.semver_key(declared): + floors.append(declared) + # ONE base — the max of the tag baseline and every declared manifest + # (HIGH, runs 6 and 7). Run 6 established the manifest half; run 7 + # showed the tag half alone hard-blocks a tagged project whose + # manifest leads, because the derived version fails its own manifest + # floor. The prose now computes `$BASE_VERSION` once and compares + # against it exactly once; the driver mirrors that rather than keeping + # a second, separate floor. + base = max(floors, key=core_lane.semver_key) + result["version_floors"] = floors + result["version_base"] = base + result["next_version"] = _bump_version(base, result["bump"]) + + with open(os.path.join(consumer_root, "CHANGELOG.md"), encoding="utf-8") as fh: + existing_changelog = fh.read() + release_date = datetime.date.today().isoformat() + result["release_date"] = release_date + section_text, full_text = _roll_changelog( + existing_changelog, result["next_version"], result["window_entries"], release_date) + result["rolled_section"] = section_text + result["rolled_full_text"] = full_text + result["message"] = section_text.rstrip("\n") + f"\nReleased-at: {release_date}\n" + result["tag_name"] = tag_prefix + result["next_version"] + + fd, message_path = tempfile.mkstemp(prefix="lane-driver-msg-", suffix=".txt") + os.close(fd) + try: + with open(message_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(result["message"]) + argv = _substitute_argv( + shlex.split(result["invocations"]["tag_message_composition"]), + {"${TAG_PREFIX}${VERSION}": result["tag_name"], + "": message_path}) + # A mapping key that no longer appears in the skill's spelling + # substitutes NOTHING and raises nothing: `git tag -a` happily + # creates a ref literally named `${TAG_PREFIX}${VERSION}` and exits + # 0, so the drift surfaces several assertions later as "tag v1.3.0 + # does not exist" -- a true statement that names neither the cause + # nor this line. Checking the substituted argv here reports the + # drift where it happened. (This is not hypothetical: the key read + # `${TAG_PREFIX}MAJOR.MINOR.PATCH` until the skill adopted a + # `$VERSION` variable.) + if result["tag_name"] not in argv: + raise RuntimeError( + "the tag-name substitution did not apply -- the skill's " + f"spelling drifted away from the mapping key. argv={argv!r}") + result["processes"]["tag_message_composition"] = _run_argv(argv, consumer_root) + finally: + os.remove(message_path) + + # publish_state_classify: a bare, 6-positional-argument CLI call — no + # pipe, no command substitution. Substituted with the state that holds + # at THIS point in the sequence (the tag composed above exists only + # in-memory as a message file; no ref has been created), matching + # exactly what Phase 2 step 1 checks BEFORE tagging: tag_exists=false, + # so `classify_publish_state` short-circuits to "publish_fresh" + # regardless of the other five values — the well-defined, meaningful + # case this fixture can exercise without first creating a real tag. + head_sha = _git(["rev-parse", "HEAD"], consumer_root).stdout.strip() + argv = _substitute_argv( + shlex.split(result["invocations"]["publish_state_classify"]), + {**root_mapping, + "": "false", "": "", "": head_sha, + "": result["next_version"], + "": result["next_version"], + "": "false"}) + proc = _run_argv(argv, consumer_root) + result["processes"]["publish_state_classify"] = proc + result["publish_state"] = (proc.stdout or "").strip() + + return result + + +class LaneDriverTest(unittest.TestCase): + """T-74 (AC-6.6 'Lane driver'): the mechanical sequence runs AS THE + PROSE SPELLS IT — invocation strings extracted from the installed + `SKILL.md`, never a direct import — through target resolution, window + derivation, and tag-message composition. + + Post-T-41b/T-41f (issue #563): all six anchored invocations now RUN — + before that rewrite landed, three of them named a this-repo-only shim + with no `__main__` and could only be ACCOUNTED for (T-79 later retired + the `known-unresolved-refs.txt` ratchet this used to be cross-checked + against). `test_no_invocations_remain_accounted` documents that + transition directly rather than silently deleting the accounting + machinery this class used to depend on.""" + + @classmethod + def setUpClass(cls): + # unittest does NOT call tearDownClass if setUpClass itself raises, + # so a failure anywhere in this body (a mutant breaking + # `_execute_lane_sequence`, an anchor going missing) would otherwise + # leak `cls.lane`'s scratch directory — caught and cleaned up here + # rather than relying on tearDownClass to run. + cls.lane = _LaneFixture("t74") + try: + skill_path = os.path.join(_FIXTURE.plugin_root, "skills", "release", "SKILL.md") + with open(skill_path, encoding="utf-8") as fh: + cls.skill_text = fh.read() + cls.core_lane = _load_mechanism( + os.path.join(_FIXTURE.plugin_root, "hooks", "_releaselib.py"), + "_lane_driver_core_t74") + cls.result = _execute_lane_sequence( + cls.skill_text, cls.core_lane, cls.lane.consumer_root) + except Exception: + cls.lane.cleanup() + raise + + @classmethod + def tearDownClass(cls): + cls.lane.cleanup() + + def test_lane_driver_classification_map(self): + # A literal, hand-declared expectation per label — direct against + # the anchors table, not derived from anything the driver itself + # computed, so a mutant flipping run<->accounted in `_classify_invocation` + # is caught here directly rather than by an equality between two + # things sharing that same function. + expected = {label: exp for label, _, exp in _LANE_INVOCATION_ANCHORS} + self.assertEqual(self.result["classification"], expected) + + def test_no_invocations_remain_accounted(self): + # T-41b/T-41f landed: every anchored invocation now resolves under + # the vendored plugin's own CLI and runs for real. This replaces + # `test_accounted_invocations_are_on_the_committed_ratchet` (which + # asserted the PRE-rewrite accounted set was non-empty and named on + # the ratchet) — that assertion would now fail vacuously, since + # there is nothing left to account for; a green run here is the + # positive statement that the migration completed, not a weakened + # substitute for it. + accounted_labels = [label for label, cls_ in self.result["classification"].items() + if cls_ == "accounted"] + self.assertEqual( + accounted_labels, [], + "an invocation is classified accounted again — either a new " + "this-repo-only reference crept back into the skill, or " + "_classify_invocation regressed") + + def test_publish_state_classify_ran_and_resolved_publish_fresh(self): + # The one anchored invocation `_execute_lane_sequence` substitutes + # with synthetic (not skill-derived) placeholder values, so its + # correctness is asserted directly here rather than folded into + # `test_run_invocations_all_exited_zero`'s exit-code-only check + # (AC-6.6: "never on exit codes alone"). + proc = self.result["processes"]["publish_state_classify"] + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(self.result["publish_state"], "publish_fresh") + + def test_target_resolution_and_last_tag_ran_via_the_vendored_cli(self): + # Cross-validates the two command-substitution invocations this + # class now RUNS (previously accounted) against values this driver + # already trusts from elsewhere: the resolved prefix must match + # what the consumer's own declared row names, and the resolved + # LAST_TAG must agree with the independent oracle. + self.assertEqual(self.result["tag_prefix"], "v") + self.assertEqual(self.result["last_tag_lib"], self.result["last_tag_oracle"]) + + def test_run_invocations_all_exited_zero(self): + run_labels = [label for label, cls_ in self.result["classification"].items() + if cls_ == "run"] + self.assertTrue(run_labels, "no invocation classified run — the " + "actually-runs arm of this test is untested") + for label in run_labels: + with self.subTest(label=label): + proc = self.result["processes"][label] + self.assertEqual( + proc.returncode, 0, + f"extracted invocation {label!r} " + f"({self.result['invocations'][label]!r}) failed: {proc.stderr}") + + def test_window_derivation_finds_every_commit_git_itself_reports(self): + # Cross-validated against a SEPARATE git subcommand (rev-list + # --count), not the same `log` output compared to itself. THREE, not + # two: `_LaneFixture` declares release-targets.md as its own commit + # AFTER the v1.2.3 tag (a `chore:` commit contributing no bump and no + # CHANGELOG: footer), on top of `build_consumer_repo`'s feat and fix. + last_tag = self.result["last_tag_lib"] + independent_count = int(_git( + ["rev-list", "--count", f"{last_tag}..HEAD"], self.lane.consumer_root).stdout.strip()) + self.assertEqual(len(self.result["window_entries"]), independent_count) + self.assertEqual(len(self.result["window_entries"]), 3) + + def test_window_scope_bare_reports_the_same_commit_count(self): + # AC-6.6: "never on exit codes alone." `window_scope_bare` (`git log + # LAST_TAG..HEAD -- $PAYLOAD`, default pretty format) is a RUN + # invocation whose only other test coverage is its exit code — a + # mutant substituting a bogus-but-still-zero-exit revision range + # into it would survive undetected without this. Its stdout is + # git's own default format, one `commit ` line per entry. + last_tag = self.result["last_tag_lib"] + independent_count = int(_git( + ["rev-list", "--count", f"{last_tag}..HEAD"], self.lane.consumer_root).stdout.strip()) + stdout = self.result["processes"]["window_scope_bare"].stdout + commit_lines = [ln for ln in stdout.splitlines() if ln.startswith("commit ")] + self.assertEqual(len(commit_lines), independent_count) + self.assertEqual(len(commit_lines), 3) + + def test_full_release_skill_payloads_extract_byte_identical_invocations(self): + # MEDIUM-3's exact defect class (adversarial review 2026-07-31, + # documented in this same file's ReferenceResolutionTest): + # the release skill ships as THREE full copies (`ca`, + # `ca-codex`/`ca-pi` routines), and a driver reading only `ca`'s copy + # is blind to a drift introduced into a sibling -- including one + # `tools/build-surface.py` itself renders differently. + # + # Post-T-41b (issue #563): three of the six anchored invocations NOW + # carry the `{{PLUGIN_ROOT}}` placeholder, rendered per-host + # (`${CLAUDE_PLUGIN_ROOT}` for claude/codex, `` for pi — + # `core/hosts.json`), so byte-identity across all three payloads no + # longer holds literally. Each payload's own plugin-root token is + # normalized to a fixed canonical string before comparing, so this + # test still catches a genuine wording/structure drift between + # copies while tolerating the ONE expected, per-host spelling + # difference `tools/build-surface.py` itself introduces. + root_by_host = { + "claude": _FIXTURE.plugin_root, + "codex": _FIXTURE.codex_plugin_root, + "pi": _FIXTURE.pi_plugin_root, + } + host_tokens = _load_host_tokens() + full_payloads = [ + (label, host, relpath) for label, host, relpath, _ in _RELEASE_SKILL_PAYLOADS + if label not in _STUB_PAYLOAD_LABELS + ] + self.assertEqual(len(full_payloads), 3) + per_payload_invocations = {} + for label, host, relpath in full_payloads: + skill_path = os.path.join(root_by_host[host], *relpath.split("/")) + with open(skill_path, encoding="utf-8") as fh: + text = fh.read() + plugin_token, _project_token = host_tokens[host] + per_payload_invocations[label] = { + inv_label: _capture_invocation_after_anchor(text, anchor) + .replace(plugin_token, "\0PLUGIN_ROOT\0") + for inv_label, anchor, _ in _LANE_INVOCATION_ANCHORS + } + baseline_label, baseline = next(iter(per_payload_invocations.items())) + for label, invocations in per_payload_invocations.items(): + with self.subTest(payload=label): + self.assertEqual( + invocations, baseline, + f"payload {label!r} extracted different invocation strings " + f"than {baseline_label!r} (after normalizing each payload's " + "own plugin-root token) — the release skill's copies have " + "drifted apart") + + def test_tag_message_composition_created_a_real_annotated_tag(self): + proc = self.result["processes"]["tag_message_composition"] + self.assertEqual(proc.returncode, 0, proc.stderr) + obj_type = _git( + ["cat-file", "-t", self.result["tag_name"]], self.lane.consumer_root).stdout.strip() + self.assertEqual(obj_type, "tag") + + +class ConsumerEndToEndTest(unittest.TestCase): + """T-75 (AC-6.6 'consumer_end_to_end'): assertions on DERIVED OUTPUTS — + the resolved row, LAST_TAG, the computed bump, and the rolled changelog + text — never on exit codes alone.""" + + @classmethod + def setUpClass(cls): + # See LaneDriverTest.setUpClass's comment: unittest skips + # tearDownClass entirely if setUpClass raises, so cleanup on failure + # is handled explicitly here rather than left to tearDownClass. + cls.lane = _LaneFixture("t75") + try: + skill_path = os.path.join(_FIXTURE.plugin_root, "skills", "release", "SKILL.md") + with open(skill_path, encoding="utf-8") as fh: + cls.skill_text = fh.read() + cls.core_lane = _load_mechanism( + os.path.join(_FIXTURE.plugin_root, "hooks", "_releaselib.py"), + "_lane_driver_core_t75") + cls.result = _execute_lane_sequence( + cls.skill_text, cls.core_lane, cls.lane.consumer_root) + except Exception: + cls.lane.cleanup() + raise + + @classmethod + def tearDownClass(cls): + cls.lane.cleanup() + + def test_resolved_row(self): + rows = self.core_lane.load_targets( + os.path.join(self.lane.consumer_root, ".codearbiter", "release-targets.md")) + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["target"], "app") + self.assertEqual(row["prefix"], "v") + self.assertEqual(row["manifest"], ["package.json"]) + self.assertEqual(row["changelog"], "CHANGELOG.md") + self.assertEqual(row["payload"], ".") + + def test_last_tag(self): + self.assertEqual(self.result["tags"], ["v1.2.3"]) + self.assertEqual(self.result["last_tag_lib"], "v1.2.3") + self.assertEqual(self.result["last_tag_oracle"], "v1.2.3") + self.assertEqual(self.result["last_tag_lib"], self.result["last_tag_oracle"]) + + def test_computed_bump(self): + # Direct literal, not a comparison against the classifier's own + # helper output: a feat + a fix in the window must classify minor + # (feat outranks fix), and the derived next version must be the + # strict SemVer advance the manifest/tag gate itself requires. + self.assertEqual(self.result["bump"], "minor") + self.assertEqual(self.result["next_version"], "1.3.0") + self.assertTrue( + self.core_lane.semver_greater(self.result["next_version"], "1.2.3")) + + def test_bump_classification_negative_case_is_discriminating(self): + # A docs/chore-only window must NOT bump — proven directly against + # `_classify_bump`, independent of what the live fixture's own + # commits happen to be, so a mutant that always returns "minor" + # cannot survive. + self.assertIsNone(_classify_bump([ + {"sha": "a", "subject": "docs: fix typo", "body": ""}, + {"sha": "b", "subject": "chore: bump lockfile", "body": ""}, + ])) + + def test_rolled_changelog_text(self): + text = self.result["rolled_full_text"] + self.assertIn(f"## [1.3.0] - {self.result['release_date']}", text) + self.assertIn("### Added", text) + self.assertIn("- Added a widget export helper.", text) + self.assertIn("### Fixed", text) + self.assertIn("- Fixed an off-by-one when counting widgets.", text) + # Prior section stays intact. + self.assertIn("## [1.2.3] - 2026-01-01", text) + self.assertIn("- Initial release.", text) + # The new section sits ABOVE the prior one. + self.assertLess(text.index("## [1.3.0]"), text.index("## [1.2.3]")) + + def test_tag_message_composition_passes_the_same_guards_phase3_applies(self): + message = self.result["message"] + tag = self.result["tag_name"] + self.assertTrue(self.core_lane.notes_heading_matches(message, tag)) + self.assertTrue(self.core_lane.release_dates_consistent( + self.result["rolled_section"], message)) + + def test_the_real_annotated_tag_carries_the_composed_message(self): + # [NEEDS-TRIAGE] genuine finding, surfaced ONLY because this class + # actually runs `git tag -a ... -F ` for real rather + # than asserting on exit codes or a direct import: git's DEFAULT + # `--cleanup=strip` (the same behavior `git commit` applies to a + # hand-typed message) treats every line starting with `#` as a + # comment and drops it from a `-F`-supplied message. The Phase-1 + # changelog section this skill composes into the tag message is + # Keep-a-Changelog Markdown, whose OWN heading lines are `## [X.Y.Z] + # ...` / `### Added` -- exactly `#`-prefixed. The skill's literal + # instruction (`git tag -a ... -F `, no + # `--cleanup=verbatim`) therefore silently strips the composed + # message's own version/date heading and every section heading from + # the CREATED TAG OBJECT, even though the pre-tag composition and + # its `notes_heading_matches`/`release_dates_consistent` checks (run + # against the Phase-1 section TEXT and the GitHub Release notes + # FILE, never against the tag object actually created) never + # observe it. `RealHistoryTagStrippingEvidenceTest` below confirms + # this has ALREADY happened to this repo's own real, published + # `v2.8.13` tag. + # + # INVERTED 2026-07-31 (run-4 adversarial review; issue #569). This + # assertion used to ENCODE the defect: it computed + # `_git_strip_cleanup(message)` and asserted the tag body equalled + # that mangled form -- documenting shipped behaviour rather than + # papering over it, which was the right call while the defect + # stood. It also left instructions for this exact moment: "if this + # now FAILS because the tag body DOES contain its heading, the + # invocation picked up `--cleanup=verbatim` and the defect was + # FIXED -- relax this assertion deliberately rather than treating a + # red run as a regression to chase." That is what happened; this is + # that deliberate inversion. + # + # It now asserts the OPPOSITE: the composed message must reach the + # tag object byte-for-byte, headings intact. A test asserting a + # stripped expectation cannot distinguish a fixed lane from a + # broken one -- it passes in precisely the world the fix exists to + # end. + tag = self.result["tag_name"] + obj_type = _git(["cat-file", "-t", tag], self.lane.consumer_root).stdout.strip() + self.assertEqual(obj_type, "tag") + raw = _git(["cat-file", "-p", tag], self.lane.consumer_root).stdout + # The tag object's own header lines (object/type/tag/tagger) precede + # a blank line, after which the message body begins verbatim. + _, _, body = raw.partition("\n\n") + self.assertEqual( + body.rstrip("\n"), self.result["message"].rstrip("\n"), + "the composed message must reach the tag object unmodified; a " + "difference here means `--cleanup=verbatim` was dropped from " + "the skill's `git tag` invocation and Markdown headings are " + "being stripped again (issue #569)") + # The exact bytes the default cleanup mode destroys, asserted + # individually so a regression names what was lost instead of + # printing a whole-body diff. + self.assertIn(f"## [{self.result['next_version']}]", self.result["message"]) + self.assertIn( + f"## [{self.result['next_version']}]", body, + "the version heading is gone from the tag object -- #569 is back") + for heading in ("### Added", "### Fixed"): + if heading in self.result["message"]: + self.assertIn( + heading, body, + f"{heading!r} is gone from the tag object -- #569 is back") + # Negative control. Everything above would pass VACUOUSLY if the + # composed message happened to contain no `#` lines at all: strip + # and verbatim cleanup agree on such a message, so the assertions + # would hold no matter which mode the lane used, and the fix would + # be untested. Requiring the two forms to DIFFER proves this + # fixture actually exercises the bytes the defect destroys. + self.assertNotEqual( + _git_strip_cleanup(self.result["message"]).rstrip("\n"), + self.result["message"].rstrip("\n"), + "the composed message contains nothing git's default cleanup " + "would strip, so the assertions above cannot distinguish " + "`--cleanup=verbatim` from the default -- the fixture stopped " + "covering #569 and needs a message with Markdown headings") + + +class NeverTaggedManifestFloorTest(unittest.TestCase): + """HIGH, run-6 adversarial review: a project that has shipped a version + WITHOUT ever tagging in its series. + + The lane resolved `LAST_TAG=`, took `0.0.0` as the base, and + derived `0.1.0` from a `feat` — then Phase 1 step 6 wrote that over a + manifest that already read `1.4.2`, walking the project's own version + backward. Every gate passed, including the manifest-equality assertion, + because step 6 had just made it equal. + + No fixture in this suite could catch it: every consumer built here had + a manifest CONSISTENT with its tag, so the floor was never load-bearing + and a missing floor check was indistinguishable from a working one — + the same vacuity the `--cleanup=verbatim` negative control exists to + prevent. This class supplies the missing case: no tags at all, and a + manifest that LEADS.""" + + @classmethod + def setUpClass(cls): + cls.scratch = tempfile.mkdtemp(prefix="ca-lane-floor-") + try: + cls.consumer_root = os.path.join(cls.scratch, "consumer") + build_consumer_repo(cls.consumer_root) + _write_text( + os.path.join(cls.consumer_root, ".codearbiter", "release-targets.md"), + RELEASE_TARGETS_BLOCK) + # The manifest asserts 1.4.2; the series has no tag at all. + manifest = os.path.join(cls.consumer_root, "package.json") + with open(manifest, encoding="utf-8") as fh: + document = json.load(fh) + document["version"] = "1.4.2" + _write_text(manifest, json.dumps(document, indent=2) + "\n") + for tag in _git(["tag", "-l"], cls.consumer_root).stdout.split(): + _git(["tag", "-d", tag], cls.consumer_root) + _git(["add", "-A"], cls.consumer_root) + _git(["commit", "-q", "-m", "feat: a shipped-but-untagged feature"], + cls.consumer_root) + skill_path = os.path.join(_FIXTURE.plugin_root, "skills", "release", "SKILL.md") + with open(skill_path, encoding="utf-8") as fh: + cls.skill_text = fh.read() + cls.core_lane = _load_mechanism( + os.path.join(_FIXTURE.plugin_root, "hooks", "_releaselib.py"), + "_lane_driver_core_floor") + cls.result = _execute_lane_sequence( + cls.skill_text, cls.core_lane, cls.consumer_root) + except Exception: + _force_rmtree(cls.scratch) + raise + + @classmethod + def tearDownClass(cls): + _force_rmtree(cls.scratch) + + def test_the_series_really_has_no_tag(self): + # Without this the whole class could pass vacuously against a + # fixture that quietly kept a tag. + self.assertEqual(self.result["tags"], []) + self.assertEqual(self.result["last_tag_lib"], "") + + def test_the_window_is_bare_head_not_a_sentinel_range(self): + self.assertEqual(self.result["window_expr"], "HEAD") + self.assertEqual(self.result["processes"]["window_scope_bare"].returncode, 0) + + def test_the_derived_version_does_not_walk_the_manifest_backward(self): + derived = self.result["next_version"] + self.assertTrue( + self.core_lane.semver_greater(derived, "1.4.2"), + f"derived {derived!r} is not strictly greater than the manifest's " + "own 1.4.2 — the release would move the project's version " + "BACKWARD (HIGH, run 6)") + self.assertNotEqual( + derived, "0.1.0", + "0.1.0 is the exact value the 0.0.0-base defect produced") + + def test_the_manifest_is_part_of_the_base_not_just_the_tag(self): + # Names the mechanism, so a regression says WHY rather than only + # that a number changed. + self.assertEqual(self.result["version_base"], "1.4.2") + self.assertIn("1.4.2", self.result["version_floors"]) + self.assertIn("0.0.0", self.result["version_floors"]) + + def test_a_tagged_project_whose_manifest_leads_still_clears_its_own_floor(self): + # HIGH, run 7 — the defect the run-6 fix CREATED. Run 6 floored the + # CHECK on the manifest but stated the BASE only in the no-tag + # branch, so a project WITH a tag derived off the tag alone and + # then failed its own manifest floor: a hard block on a legitimate + # release, with the remedy stated nowhere. + # + # Exercised as a pure derivation over the shipped mechanism rather + # than a fifth end-to-end fixture: the failing shape is entirely in + # the arithmetic (tag 1.2.0, manifest 1.4.2, minor bump), and the + # end-to-end path is already covered by this class's own no-tag + # fixture. + base = max(["1.2.0", "1.4.2"], key=self.core_lane.semver_key) + self.assertEqual(base, "1.4.2", "the base must be the max, not the tag") + self.assertTrue( + self.core_lane.semver_greater("1.5.0", base), + "a minor bump off the true base must clear it") + self.assertFalse( + self.core_lane.semver_greater("1.3.0", base), + "deriving off the tag alone yields 1.3.0, which fails the " + "manifest floor — this is exactly the run-7 hard block, and it " + "must stay reproducible so the fix cannot silently regress") + + def test_the_sentinel_never_reaches_the_comparator(self): + # MEDIUM, run 7: step 4 previously demanded a second comparison + # against LAST_TAG, which on a first release is `` — not a + # version. `semver-greater` correctly exits 2 on it, so the + # instruction was unrunnable on precisely the path Back-fill + # serves. There is now ONE comparison, against $BASE_VERSION. + cli = os.path.join(_FIXTURE.plugin_root, "hooks", "_releaselib.py") + proc = _run_argv([sys.executable, cli, "semver-greater", "2.2.0", ""], + self.consumer_root) + self.assertEqual( + proc.returncode, 2, + "the sentinel is not a version; if this ever exits 0 or 1 the " + "comparator started accepting it, which would hide the very " + "confusion that made the two-floor instruction unrunnable") + self.assertNotIn( + "", self.result["version_base"], + "the sentinel must be mapped to 0.0.0 before it can reach the base") + + def test_the_shipped_cli_can_run_the_floor_check_itself(self): + # The prose tells an operator to assert the floor via + # `semver-greater`. If that subcommand is not reachable from the + # VENDORED payload, the instruction is unfollowable in a consumer. + cli = os.path.join(_FIXTURE.plugin_root, "hooks", "_releaselib.py") + good = _run_argv([sys.executable, cli, "semver-greater", + self.result["next_version"], "1.4.2"], + self.consumer_root) + bad = _run_argv([sys.executable, cli, "semver-greater", "0.1.0", "1.4.2"], + self.consumer_root) + junk = _run_argv([sys.executable, cli, "semver-greater", "nope", "1.4.2"], + self.consumer_root) + self.assertEqual(good.returncode, 0) + self.assertEqual(bad.returncode, 1) + self.assertEqual( + junk.returncode, 2, + "an unparseable version must be exit 2, distinct from the exit-1 " + "'compared, and not greater' answer") + + +class LaneDriverUnitTest(unittest.TestCase): + """Direct, synthetic-input coverage of the three arms `_execute_lane_ + sequence` depends on, independent of whatever the LIVE skill currently + contains — mirrors the role `ResolverUnitTest` plays for T-73b. Without + this, a mutant in `_classify_invocation` or `_run_argv` that happens to + behave correctly on the skill's OWN one example of each arm would + survive undetected.""" + + def test_well_formed_invocation_is_extracted_and_runs(self): + text = "before text. Run this: `git rev-parse HEAD` after text." + invocation = _capture_invocation_after_anchor(text, "Run this:") + self.assertEqual(invocation, "git rev-parse HEAD") + self.assertEqual(_classify_invocation(invocation), "run") + with tempfile.TemporaryDirectory() as scratch: + _git(["init", "-q"], scratch) + _write_text(os.path.join(scratch, "f.txt"), "x\n") + _git(["add", "-A"], scratch) + _git(["commit", "-q", "-m", "c"], scratch) + proc = _run_argv(shlex.split(invocation), scratch) + self.assertEqual(proc.returncode, 0) + + def test_extraction_fails_loud_when_anchor_missing(self): + with self.assertRaises(RuntimeError): + _capture_invocation_after_anchor("no anchor here at all", "MISSING ANCHOR") + + def test_extraction_fails_loud_when_no_invocation_shaped_span_follows(self): + text = "Anchor here: `_bare.module.reference` and nothing runnable near it." + with self.assertRaises(RuntimeError): + _capture_invocation_after_anchor(text, "Anchor here:") + + def test_invocation_naming_this_repo_shim_is_accounted(self): + invocation = "python3 .github/scripts/_releaselib.py tag-prefix app" + self.assertEqual(_classify_invocation(invocation), "accounted") + + def test_invocation_with_nonexistent_git_subcommand_fails_rather_than_silently_passing(self): + # Proves the driver actually surfaces a failing exit code rather + # than treating "it ran" as "it succeeded" — the malformed-CLI arm + # AC-6.6 exists to catch, demonstrated with a synthetic invocation + # rather than depending on the live skill ever naming a broken one. + with tempfile.TemporaryDirectory() as scratch: + _git(["init", "-q"], scratch) + proc = _run_argv(["git", "this-subcommand-does-not-exist"], scratch) + self.assertNotEqual(proc.returncode, 0) + + def test_substitute_argv_never_reparses_a_backslash_bearing_path_through_shlex(self): + # The concrete Windows hazard the module docstring names: a + # `` token substituted with a backslash-bearing path + # must survive as ONE argv element, never re-split. + argv = shlex.split("git tag -a ${TAG_PREFIX}MAJOR.MINOR.PATCH -F ") + windows_path = r"C:\Users\example\AppData\Local\Temp\msg.txt" + substituted = _substitute_argv( + argv, {"${TAG_PREFIX}MAJOR.MINOR.PATCH": "v1.3.0", "": windows_path}) + self.assertEqual(substituted, ["git", "tag", "-a", "v1.3.0", "-F", windows_path]) + + +class RealHistoryTagStrippingEvidenceTest(unittest.TestCase): + """[NEEDS-TRIAGE] Corroborates `ConsumerEndToEndTest`'s discovery with + REAL evidence rather than only the scratch fixture's synthetic proof: + `git tag -a -F`'s default comment-stripping cleanup has ALREADY silently + corrupted this repo's own real, previously published release tag + message. `v2.8.13` is a real, PUBLISHED tag; the project's own release + skill ("Recovering from a bad release": no break-glass, a published tag + is never moved or deleted) makes it a permanent historical fact rather + than transient repo state, so pinning it here does not go stale the way + pinning "the current HEAD" or "the latest tag" would. Entirely + read-only against REPO_ROOT (`git cat-file -p`, never `git tag`) -- + creates no ref, mutates nothing. + + **Not mutation-killable by construction** (the same phrasing the sprint + plan uses for T-12's circularity proof): this asserts a HISTORICAL FACT + about a commit already in this repository's object database, not the + behavior of any function this module defines. There is no production + code path here for a mutant to corrupt; its value is corroborating + `ConsumerEndToEndTest`'s synthetic finding against real, already-shipped + evidence, not discriminating a mutation.""" + + def test_the_live_v2_8_13_tag_message_is_missing_its_own_changelog_heading(self): + result = subprocess.run( + ["git", "cat-file", "-p", "v2.8.13"], + cwd=REPO_ROOT, capture_output=True, encoding="utf-8", timeout=GIT_TIMEOUT) + self.assertEqual( + result.returncode, 0, + f"v2.8.13 is expected to be a real, permanently published tag in " + f"this repository's history: {result.stderr}") + _, _, body = result.stdout.partition("\n\n") + self.assertNotIn( + "## [2.8.13]", body, + "if this now PASSES, this repo's tagging process (or git's own " + "default cleanup behavior) changed -- re-triage the " + "[NEEDS-TRIAGE] finding in ConsumerEndToEndTest rather than " + "deleting this test") + + +# --------------------------------------------------------------------------- # +# T-76 — consumer back-fill, the real two-arm proof (issue #563, T-49/T-50). +# Replaces the 2026-07-31 canary (`BackfillNotYetImplementedTest`), which +# asserted the ABSENCE of back-fill prose and was written to fail the moment +# it closed, with the instruction to replace the whole class rather than +# loosen the assertion. T-49/T-50 landed in this same commit, so this is +# that replacement: both required arms (refuse without confirmation, persist +# on confirmation) plus the "second run reads, does not re-detect" property, +# run against the scratch consumer fixture (never this repo's dev tree), +# with the `backfill-detect` invocation extracted from the INSTALLED skill +# text and subprocess-executed — mirroring LaneDriverTest's own "prose, not +# import" discipline (AC-6.6's "Lane driver" layer). +# --------------------------------------------------------------------------- # + +_BACKFILL_DETECT_ANCHOR = "From the project root, run" + + +class _BackfillFixture: + """A fresh, disposable single-package consumer repo carrying NO declared + `release-targets.md` — the exact precondition the back-fill lane needs. + Private to this test class for the same reason `_LaneFixture` is private + to `LaneDriverTest`/`ConsumerEndToEndTest`: a mutating test must never + touch the shared module-level `_FIXTURE.consumer_root`, which + `ConsumerFixtureTest` asserts an exact tracked-file set against.""" + + def __init__(self, label): + self.scratch = tempfile.mkdtemp(prefix=f"ca-backfill-{label}-") + try: + self.consumer_root = os.path.join(self.scratch, "consumer") + build_consumer_repo(self.consumer_root) + except Exception: + # Mirrors `_Fixture.__init__`/`_LaneFixture.__init__`'s own + # pattern: allocate `self.scratch` before anything that can + # fail, so a failure here still removes it rather than leaks. + self.cleanup() + raise + + def cleanup(self): + _force_rmtree(self.scratch) + + +class BackfillTwoArmProofTest(unittest.TestCase): + """T-76 (AC-6.6 'backfill_detects'): with no declared file, the detected + shape is presented and does not proceed unconfirmed. `build_consumer_repo` + seeds exactly one `package.json` and one `CHANGELOG.md` at its root — the + single unambiguous candidate of each kind the back-fill lane's own + never-guess posture requires before it can propose anything at all.""" + + @classmethod + def setUpClass(cls): + # See LaneDriverTest.setUpClass's comment: unittest skips + # tearDownClass entirely if setUpClass raises, so cleanup on failure + # is handled explicitly here rather than left to tearDownClass. + cls.lane = _BackfillFixture("t76") + try: + skill_path = os.path.join(_FIXTURE.plugin_root, "skills", "release", "SKILL.md") + with open(skill_path, encoding="utf-8") as fh: + cls.skill_text = fh.read() + cls.core_lane = _load_mechanism( + os.path.join(_FIXTURE.plugin_root, "hooks", "_releaselib.py"), + "_backfill_two_arm_core") + cls.targets_path = os.path.join( + cls.lane.consumer_root, ".codearbiter", "release-targets.md") + cls.invocation = _capture_invocation_after_anchor( + cls.skill_text, _BACKFILL_DETECT_ANCHOR) + except Exception: + cls.lane.cleanup() + raise + + @classmethod + def tearDownClass(cls): + cls.lane.cleanup() + + def _run_detect(self): + plugin_root = os.path.dirname(os.path.dirname(self.core_lane.__file__)) + argv = _substitute_argv( + shlex.split(self.invocation), {"${CLAUDE_PLUGIN_ROOT}": plugin_root}) + return _run_argv(argv, self.lane.consumer_root) + + def test_no_declared_file_and_the_parser_still_refuses_to_default(self): + # The property `AbsentBlockError` is CORRECT and MUST stay (the task + # brief's own instruction): the back-fill lane HANDLES this error, + # it is never a silent default inside the parser itself. + # + # This owns its own pristine consumer rather than reading the shared + # class fixture. It previously asserted absence against `self. + # targets_path`, which `test_arm_2_persist_...` legitimately CREATES — + # and unittest orders methods alphabetically, so `arm_2` runs first and + # the absence assertion failed. It only ever passed while the class's + # setUpClass was erroring and none of these methods ran at all. A test + # asserting "no declared file exists" must not depend on no other test + # having made one. + pristine = _BackfillFixture("t76-pristine") + try: + path = os.path.join( + pristine.consumer_root, ".codearbiter", "release-targets.md") + self.assertFalse( + os.path.isfile(path), + "a freshly built consumer must carry no declared file") + with self.assertRaises(self.core_lane.AbsentBlockError): + self.core_lane.load_targets(path) + finally: + pristine.cleanup() + + def test_detection_extracted_from_the_installed_skill_finds_the_candidate(self): + proc = self._run_detect() + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("[app]", proc.stdout) + self.assertIn("prefix: v", proc.stdout) + self.assertIn("manifest: package.json", proc.stdout) + self.assertIn("changelog: CHANGELOG.md", proc.stdout) + self.assertIn("payload: .", proc.stdout) + + def test_lane_anchors_are_unique_in_every_rendering(self): + # The header comment above _LANE_INVOCATION_ANCHORS has always + # CLAIMED each anchor is "verified unique in the installed skill + # text". Nothing enforced it until now: the claim was prose, and a + # prose claim about a test is exactly the kind of thing that goes + # stale silently. + # + # Two distinct failure modes, both real: + # - MISSING (count 0) — a prose edit moves or deletes the + # landmark. This fires loudly at setUpClass, so it is already + # hard to miss; the run-3 reorder of Phase 2 step 1 removed + # "Tag with" and did exactly this. + # - AMBIGUOUS (count > 1) — a prose edit introduces a SECOND + # occurrence earlier in the file. `str.find()` takes the first + # match, so the driver silently captures a different + # invocation and every downstream assertion still passes, + # against the wrong command. That is the one worth a test. + # + # Checked across all three full-prose renderings, not just `ca`: a + # check reading one copy is blind to drift in a sibling. + root_by_host = { + "claude": _FIXTURE.plugin_root, + "codex": _FIXTURE.codex_plugin_root, + "pi": _FIXTURE.pi_plugin_root, + } + full_payloads = [ + (label, host, relpath) for label, host, relpath, _ in _RELEASE_SKILL_PAYLOADS + if label not in _STUB_PAYLOAD_LABELS + ] + self.assertEqual(len(full_payloads), 3) + for label, host, relpath in full_payloads: + skill_path = os.path.join(root_by_host[host], *relpath.split("/")) + with open(skill_path, encoding="utf-8") as fh: + text = fh.read() + for name, anchor, _classification in _LANE_INVOCATION_ANCHORS: + self.assertEqual( + text.count(anchor), 1, + f"payload {label!r}: lane anchor {name!r} = {anchor!r} " + f"occurs {text.count(anchor)} time(s); it must occur " + "exactly once. Zero means a prose edit moved the " + "landmark (update the anchor in the SAME commit). More " + "than one means find()'s first-match rule now silently " + "captures the wrong invocation while every assertion " + "downstream keeps passing.") + + def test_full_release_skill_payloads_extract_the_same_backfill_invocation(self): + # MEDIUM-3's exact defect class (mirrors LaneDriverTest's own + # cross-payload check): a driver reading only the `ca` copy is + # blind to a drift introduced into a sibling. Scope to the THREE + # full payloads (the two `ca-release` stubs never carry this + # section at all), normalize each host's own plugin-root token + # spelling, and assert the extracted invocation is identical + # across all three -- and that the anchor is unambiguous (occurs + # exactly once) in each. + root_by_host = { + "claude": _FIXTURE.plugin_root, + "codex": _FIXTURE.codex_plugin_root, + "pi": _FIXTURE.pi_plugin_root, + } + host_tokens = _load_host_tokens() + full_payloads = [ + (label, host, relpath) for label, host, relpath, _ in _RELEASE_SKILL_PAYLOADS + if label not in _STUB_PAYLOAD_LABELS + ] + self.assertEqual(len(full_payloads), 3) + per_payload_invocation = {} + for label, host, relpath in full_payloads: + skill_path = os.path.join(root_by_host[host], *relpath.split("/")) + with open(skill_path, encoding="utf-8") as fh: + text = fh.read() + self.assertEqual( + text.count(_BACKFILL_DETECT_ANCHOR), 1, + f"payload {label!r}: anchor {_BACKFILL_DETECT_ANCHOR!r} must " + "occur exactly once (an ambiguous anchor would silently " + "extract the WRONG invocation via find()'s first-match rule)") + plugin_token, _project_token = host_tokens[host] + invocation = _capture_invocation_after_anchor(text, _BACKFILL_DETECT_ANCHOR) + per_payload_invocation[label] = invocation.replace( + plugin_token, "\0PLUGIN_ROOT\0") + baseline_label, baseline = next(iter(per_payload_invocation.items())) + for label, invocation in per_payload_invocation.items(): + with self.subTest(payload=label): + self.assertEqual( + invocation, baseline, + f"payload {label!r} extracted a different backfill-detect " + f"invocation than {baseline_label!r} (after normalizing " + "each payload's own plugin-root token) -- the release " + "skill's copies have drifted apart") + + def test_arm_1_refuse_without_confirmation_writes_nothing(self): + # Detection alone is the mechanical half of "does not proceed + # without explicit confirmation": running it — even twice, as if a + # user has not yet answered — must never touch disk on its own. + self._run_detect() + self._run_detect() + self.assertFalse( + os.path.isfile(self.targets_path), + "backfill-detect must only PRINT the candidate block; it must " + "never write release-targets.md on its own") + + def test_arm_2_persist_on_confirmation_and_a_second_run_reads_not_redetects(self): + proc = self._run_detect() + self.assertEqual(proc.returncode, 0, proc.stderr) + confirmed_block = proc.stdout + + # Confirmation: the lane's own persist step (Back-fill step 3) -- + # write the confirmed block verbatim, exactly as the skill prose + # instructs. + os.makedirs(os.path.dirname(self.targets_path), exist_ok=True) + with open(self.targets_path, "w", encoding="utf-8", newline="") as fh: + fh.write(confirmed_block) + + rows = self.core_lane.load_targets(self.targets_path) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["target"], "app") + self.assertEqual(rows[0]["prefix"], "v") + self.assertEqual(rows[0]["manifest"], ["package.json"]) + self.assertEqual(rows[0]["changelog"], "CHANGELOG.md") + self.assertEqual(rows[0]["payload"], ".") + + # T-50: "a second run reads it rather than re-detecting" -- proven + # by NOT invoking detection again here at all (no `_run_detect` + # call below) and still getting the identical row back from the + # normal load path alone. + second_read = self.core_lane.load_targets(self.targets_path) + self.assertEqual(second_read, rows) + + def test_ambiguous_candidates_still_refuse_rather_than_guess(self): + # A DIFFERENT scratch repo, not the shared happy-path lane: a second + # candidate manifest makes the scan genuinely ambiguous, and the + # command must refuse (non-zero exit, nothing printed) rather than + # pick one silently. + ambiguous = _BackfillFixture("t76-ambiguous") + try: + with open(os.path.join(ambiguous.consumer_root, "pyproject.toml"), + "w", encoding="utf-8") as fh: + fh.write("") + plugin_root = os.path.dirname(os.path.dirname(self.core_lane.__file__)) + argv = _substitute_argv( + shlex.split(self.invocation), {"${CLAUDE_PLUGIN_ROOT}": plugin_root}) + proc = _run_argv(argv, ambiguous.consumer_root) + self.assertNotEqual(proc.returncode, 0) + self.assertEqual(proc.stdout, "") + ambiguous_targets_path = os.path.join( + ambiguous.consumer_root, ".codearbiter", "release-targets.md") + self.assertFalse(os.path.isfile(ambiguous_targets_path)) + finally: + ambiguous.cleanup() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_hooklib.py b/.github/scripts/test_hooklib.py index 50f022a7..ee8be0cf 100644 --- a/.github/scripts/test_hooklib.py +++ b/.github/scripts/test_hooklib.py @@ -320,6 +320,118 @@ def test_decision_log_edit_must_still_be_a_pure_tail_append(self): self.assertFalse(_hooklib.is_tail_append(current, "## DECISION-0001", "## DECISION-0001-REWRITTEN")) +class ClassifyProtectedStateTest(unittest.TestCase): + """T-05a (#564): classify_protected grows a FIFTH class, "state", for a + path the protected-state registry (_protectedstatelib.py, B1) recognizes + - evaluated strictly AFTER the four legacy classes, with ZERO change to + the classifier's return contract (still a bare set of strings; the + POLICY is resolved separately, inside whichever flank's own "state" + branch needs it - not here, and not by this test). + + REGISTRY ships EMPTY at this slice (T-33/T-65/T-66 enroll the three + named consumers later), so exercising the "state" branch needs an + INJECTED registry - patch the module-level _protectedstatelib.REGISTRY + directly (lookup_policy reads it fresh on every call, unlike + _bashguardlib's precompiled _STATE_WRITE_RES which snapshots at import).""" + + def setUp(self): + import _protectedstatelib + self._protectedstatelib = _protectedstatelib + self._orig_registry = _protectedstatelib.REGISTRY + + def tearDown(self): + self._protectedstatelib.REGISTRY = self._orig_registry + + def test_registered_path_classifies_as_state(self): + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + self.assertEqual(_hooklib.classify_protected(target, root), {"state"}) + + def test_unregistered_path_does_not_classify_as_state(self): + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "open-tasks.md") + self.assertNotIn("state", _hooklib.classify_protected(target, root)) + + def test_registered_backslash_path_still_classifies_as_state(self): + # norm_path() folds separators, so a Windows-style query path must + # still hit the registry entry (mirrors the existing + # is_audit_log/is_decisions_path backslash-normalization tests). + # + # The backslashes go in the REPO-RELATIVE tail, with the root left + # native. Backslashing the whole absolute path (what this test used + # to do, via `target.replace("/", "\\")`) is a Windows-only + # assertion wearing a cross-platform coat: on POSIX the result is + # not a path at all but one long filename, which `repo_rel` cannot + # resolve against `root`, so the test passed on the author's + # machine and failed every Linux and macOS CI job. The real payload + # shape is a native root plus a tail spelled the host's way, and + # that is what this now exercises on both. + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + win_target = os.path.join(root, ".codearbiter\\release-targets.md") + self.assertIn("state", _hooklib.classify_protected(win_target, root)) + + def test_state_can_coexist_with_a_legacy_class(self): + # classify_protected itself never resolves an audit/state collision - + # it reports every class a path hits, same as it already does for + # the pre-existing four (#528/#529). T-05b's separate overlap guard + # is what keeps this scenario from ever being LIVE registry data; + # this test only pins that the CLASSIFIER's own composition rule + # (report every hit) still applies once "state" exists. + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/overrides.log": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "overrides.log") + self.assertEqual(_hooklib.classify_protected(target, root), {"audit", "state"}) + + def test_default_registry_classifies_its_enrolled_consumer_as_state(self): + # A direct positive fact about PRODUCTION classify_protected, + # exercising the REAL default path rather than an injected + # synthetic registry. + # + # This asserted the OPPOSITE while the registry shipped empty. T-33 + # enrolled `.codearbiter/release-targets.md` as the first consumer, + # so the production classifier now returns "state" for it — and + # that IS the enrolment, on the door `pre-write`/`pre-edit` consult. + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + self.assertIn("state", _hooklib.classify_protected(target, root)) + + def test_default_registry_classifies_the_board_files_as_state(self): + # T-65/T-66 enrolled both board files, so the production + # classifier now returns "state" for them too. + with tempfile.TemporaryDirectory() as root: + for name in ("open-tasks.md", "done-tasks.md"): + with self.subTest(name=name): + target = os.path.join(root, ".codearbiter", name) + self.assertIn( + "state", _hooklib.classify_protected(target, root)) + + def test_default_registry_leaves_an_unenrolled_state_file_alone(self): + # The classifier's discrimination, kept honest now that all three + # consumers are enrolled: a neighbour nobody registered must stay + # untouched, or "protected" would just mean "under .codearbiter/". + with tempfile.TemporaryDirectory() as root: + for name in ("open-questions.md", "tech-stack.md"): + with self.subTest(name=name): + target = os.path.join(root, ".codearbiter", name) + self.assertNotIn( + "state", _hooklib.classify_protected(target, root)) + + class PathGlobTest(unittest.TestCase): """is_migration_path / is_ci_path / is_deploy_path against default globs (no security-controls.md in the tmp root, so defaults only).""" diff --git a/.github/scripts/test_payload_version_gate.py b/.github/scripts/test_payload_version_gate.py index 5a4c178f..2c63cd60 100644 --- a/.github/scripts/test_payload_version_gate.py +++ b/.github/scripts/test_payload_version_gate.py @@ -20,7 +20,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import payload_version_gate as gate # noqa: E402 -from _releaselib import RELEASE_TAG_PREFIXES # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[2] @@ -244,9 +243,92 @@ def test_every_gated_manifest_exists(self): self.assertTrue((REPO_ROOT / manifest).is_file(), f"{manifest} is missing") def test_every_gated_plugin_has_a_release_tag_namespace(self): + # Now asserted against the DERIVED map, not the retired constant. + namespaces = gate.tag_prefixes() for plugin in gate.GATED_MANIFESTS: with self.subTest(plugin=plugin): - self.assertIn(Path(plugin).name, RELEASE_TAG_PREFIXES) + self.assertIn(Path(plugin).name, namespaces) + + def test_no_prefix_literal_remains_in_the_gate(self): + """A-4.1: the gate derives every tag namespace from the declared + file, so no namespace string may be written into its source. + + A literal here is not a style nit. It is a SECOND source of truth + for the same fact: a target declared in + `.codearbiter/release-targets.md` under one prefix, and gated here + under another, would be checked in one namespace and released in + the other with nothing comparing them. + + Scanned as source text rather than by importing, because a literal + can hide in a default argument or a fallback branch that no test + input reaches.""" + source = (REPO_ROOT / ".github" / "scripts" + / "payload_version_gate.py").read_text(encoding="utf-8") + code = os.linesep.join( + line for line in source.splitlines() + if not line.lstrip().startswith("#")) + for prefix in gate.tag_prefixes().values(): + if prefix == "v": + # A bare "v" appears in ordinary prose and identifiers; the + # namespaced siblings are the discriminating case. + continue + with self.subTest(prefix=prefix): + self.assertNotIn( + f'"{prefix}"', code, + f"tag prefix {prefix!r} is written literally in the gate's " + "source; it must come from the declared file") + self.assertNotIn(f"'{prefix}'", code) + # Scoped to IMPORT and USE, not to any mention. The gate's own + # docstring names the retired constant to explain what replaced it + # and why, which is documentation worth keeping -- an assertion + # that banned the string outright would force deleting the + # rationale to satisfy the test. + self.assertNotIn( + "import RELEASE_TAG_PREFIXES", code, + "the gate must not import the retired constant") + self.assertNotIn( + "RELEASE_TAG_PREFIXES[", code, + "the gate must not read the retired constant") + self.assertNotIn( + "RELEASE_TAG_PREFIXES,", code, + "the gate must not import the retired constant in a list") + + def test_the_derived_map_agrees_with_the_declared_file(self): + # The derivation itself, against an independent read of the same + # file -- so a bug that returns an empty or partial map is caught + # rather than passing vacuously through the membership test above. + import importlib.util + spec = importlib.util.spec_from_file_location( + "_core_rl_for_gate_test", REPO_ROOT / "core" / "pysrc" / "_releaselib.py") + core = importlib.util.module_from_spec(spec) + spec.loader.exec_module(core) + rows = core.load_targets( + str(REPO_ROOT / ".codearbiter" / "release-targets.md")) + expected = { + (r["payload"] or "").strip("/").rsplit("/", 1)[-1]: r["prefix"] + for r in rows + if (r["payload"] or "").strip("/") not in ("", ".") + } + self.assertEqual(gate.tag_prefixes(), expected) + self.assertGreaterEqual(len(expected), len(gate.GATED_MANIFESTS)) + + def test_an_undeclared_gated_payload_fails_with_a_named_reason(self): + # Previously a KeyError against the hardcoded map. A gate that + # tracebacks tells an operator nothing about what to do. + with tempfile.TemporaryDirectory() as tmp: + empty = Path(tmp) / "release-targets.md" + empty.write_text("\n".join([ + "", + "[other]", + "prefix: other-v", + "changelog: CHANGELOG.md", + "payload: plugins/other/", + "", + "", + ]), encoding="utf-8") + namespaces = gate.tag_prefixes(empty) + self.assertNotIn("ca", namespaces) + self.assertEqual(namespaces, {"other": "other-v"}) def test_ca_pi_is_gated_elsewhere_and_not_here(self): """Double-gating ca-pi would apply two rules to one plugin.""" diff --git a/.github/scripts/test_provenancelib.py b/.github/scripts/test_provenancelib.py index cb55d950..f59e763f 100644 --- a/.github/scripts/test_provenancelib.py +++ b/.github/scripts/test_provenancelib.py @@ -2750,5 +2750,98 @@ def exploding_read(path): "the file that raised must be the one — and the only one — skipped") +class TestReleaseTargetsTriggers(unittest.TestCase): + """A-5.6 / T-52: `.codearbiter/.provenance/release-targets.json` records + the declared rows' OWN referenced paths as drift triggers. + + The declared file names other files -- each `manifest`, each + `changelog`, each `artifacts` entry. Nothing watched whether those + files still existed where the rows claim, so a renamed manifest left a + row pointing at nothing and the release lane found out at tag time. + """ + + @classmethod + def setUpClass(cls): + sys.path.insert(0, HERE) + import build_release_targets_provenance as builder + cls.builder = builder + cls.record_path = os.path.join( + REPO, ".codearbiter", ".provenance", "release-targets.json") + + def test_release_targets_triggers_record_is_valid_v1(self): + record = pl.read_provenance(self.record_path) + self.assertIsNotNone(record, "the record is missing or unreadable") + self.assertTrue(pl.valid_provenance_record(record)) + self.assertEqual(record["doc"], "release-targets") + + def test_release_targets_triggers_cover_every_declared_path(self): + # The check the generator's --check mode runs, asserted here too so + # a green suite means the record matches the declaration even if + # nobody runs the script. + self.assertEqual(self.builder.check(), []) + + def test_release_targets_triggers_are_exactly_the_declared_set(self): + # Two-way. A missing trigger loses the coverage; an extra one + # watches a path no row references any more, which goes stale + # silently and trains the reader to ignore the report. + record = pl.read_provenance(self.record_path) + recorded = {e["path"] for e in record["entries"] + if e.get("drift_trigger") is True} + self.assertEqual(recorded, set(self.builder.declared_paths())) + + def test_release_targets_triggers_exclude_scopes_and_commands(self): + # `payload`/`payload-exclude` are directory scopes -- batch_hash + # cannot hash a directory. `pre-tag`/`rebuild`/`generate` are + # command strings, not files. Recording either would produce a + # permanently-"missing" trigger that teaches operators the report + # is noise. + recorded = set(self.builder.declared_paths()) + for scope in ("plugins/ca/", "plugins/ca-pi/tools/", "."): + self.assertNotIn(scope, recorded) + for entry in recorded: + self.assertFalse( + entry.startswith("python3 ") or entry.startswith("cd "), + f"{entry!r} is a command, not a path") + + def test_release_targets_triggers_surface_a_moved_path(self): + # The failure this record exists to catch, driven end to end + # through compute_drift rather than asserted about the file's + # shape: a recorded path that git no longer knows is "missing". + record = pl.read_provenance(self.record_path) + entries = [dict(e) for e in record["entries"]] + self.assertTrue(entries, "no entries to exercise") + moved = entries[0]["path"] + current = {e["path"]: e["hash"] for e in entries if e["hash"]} + current.pop(moved, None) # simulate the rename + drift = pl.compute_drift({"release-targets": record}, current) + self.assertIn("release-targets", drift) + self.assertIn( + {"path": moved, "kind": "missing"}, drift["release-targets"], + "a declared path git no longer knows must surface as missing") + + def test_release_targets_triggers_surface_a_changed_file(self): + record = pl.read_provenance(self.record_path) + entries = [dict(e) for e in record["entries"]] + current = {e["path"]: e["hash"] for e in entries if e["hash"]} + target = next(iter(current)) + current[target] = "0" * 40 # simulate an edit + drift = pl.compute_drift({"release-targets": record}, current) + self.assertIn({"path": target, "kind": "changed"}, + drift["release-targets"]) + + def test_release_targets_triggers_check_reports_a_gap(self): + # The generator's own failure mode, exercised against a synthetic + # record rather than by mutating the real one. + with tempfile.TemporaryDirectory() as tmp: + partial = os.path.join(tmp, "release-targets.json") + full = pl.read_provenance(self.record_path) + trimmed = pl.new_record( + "release-targets", entries=full["entries"][:-1]) + pl.write_provenance(partial, trimmed) + errors = self.builder.check(record_path=partial) + self.assertTrue(errors) + self.assertTrue(any("declared but not recorded" in e for e in errors)) + + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/test_release_lib.py b/.github/scripts/test_release_lib.py index 40ebf8ac..a9e50799 100644 --- a/.github/scripts/test_release_lib.py +++ b/.github/scripts/test_release_lib.py @@ -12,10 +12,51 @@ The helpers are pure functions exercised with synthetic input — no git, no I/O. Stdlib only. Exit 0 = all pass; non-zero = failure. + +Portable-release split (issue #563, .codearbiter/specs/release-portable-fixture.md, +slice 1). The classes below this point exercise `core/pysrc/_releaselib.py` — the +PORTABLE mechanism module, loaded under a distinct module name (`_core_releaselib`) +so it never collides with `.github/scripts/_releaselib.py` (the repo's own, +data-carrying, currently-unmodified shim) imported above as `_releaselib`: + + DenylistTest A-1.2 — no repo-namespace/CI-vocabulary literal in the mechanism + RequiredParamsTest A-1.3 — repo defaults became required parameters + LoadTargetsTest A-1.4 — load_targets(path) returns the full row schema + AbsentBlockTest A-1.5 — an absent delimiter block raises a declared error + ParserContractTest A-1.6 — each parser-contract violation raises its own error + EmptyBlockTest A-1.7 — an empty delimiter block raises a declared error + SeriesIsolationTest A-1.8 — series isolation against loaded target data + +Adversarial-review remediation (2026-07-31, mutation campaign against +core/pysrc/_releaselib.py). Each class below exercises `core_releaselib` +directly, closing the gap where the mechanism shipped in a new module but its +proof still lived only against the old, unmodified shim: + + CoreDelimiterInValueTest H1 — value-embedded delimiter errors, both directions + CoreRequiredKeyEmptyValueTest H2 — an empty/whitespace required-key value is MISSING + CoreClassifyPublishStateTest H3 — classify_publish_state against the portable module + CoreNotesHeadingTest H3 — notes_heading_matches / _bare_version, ditto + CoreReleaseDatesTest H3 — release_dates_consistent, ditto + CorePeelTagTest H3 — peel_tag, ditto + CoreSemverTest H3 — semver_key / semver_greater, previously untested anywhere + CoreMergeReadinessArmsTest H3 — classify_merge_readiness's 4 non-green arms + CoreSelectReleaseTargetArmsTest H3 — select_release_target's arity/multiple arms + CorePrereleaseMarkerScopeTest M2 — marker denylist scoped to the version, not the prefix + CoreColonInValueTest M4 — values split on the FIRST colon only + CoreEscapeHatchWrappingTest M5 — non-string input / unreadable path stay in-hierarchy """ +import importlib.util +import inspect +import io +import json import os +import subprocess +import re +import shlex +import shutil import sys +import tempfile import unittest HERE = os.path.dirname(os.path.abspath(__file__)) @@ -23,6 +64,66 @@ import _releaselib # noqa: E402 — needs sys.path mutation above +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +_CORE_RELEASELIB_PATH = os.path.join(REPO_ROOT, "core", "pysrc", "_releaselib.py") +_core_spec = importlib.util.spec_from_file_location( + "_core_releaselib", _CORE_RELEASELIB_PATH) +core_releaselib = importlib.util.module_from_spec(_core_spec) +# Registered in sys.modules (not just bound to a local name) so `inspect. +# getsourcefile` can resolve it — inspect looks the object's `__module__` up +# in sys.modules to find `__file__`, and an unregistered dynamically-loaded +# module reads back as a "built-in class" with no source file at all. +sys.modules[_core_spec.name] = core_releaselib +_core_spec.loader.exec_module(core_releaselib) + +_BASH_PROBE = [] + + +def working_bash(): + """Path to a `bash` that actually runs a script fed on stdin, or None. + + RESOLVING the name is the load-bearing half here, and it is why this + returns a path rather than a bool. `subprocess.run(["bash", ...])` + without `shell=True` leaves the lookup to `CreateProcess`, whose search + order is not `PATH` order; on GitHub's `windows-latest` image that + picked something which exits 1 with BOTH streams empty — read back as + `AssertionError: 1 != 0` with an empty message, indistinguishable from + the script under test failing silently. Handing `subprocess` the + absolute path `shutil.which` finds by walking `PATH` runs the real Git + Bash and the same round trip passes. (Measured: CI run 30691314813 red, + run 30692612801 green with no other change to the exec path.) + + The PROBE is the belt to that braces: it separates "no usable shell on + this platform" from "the shell ran and disagreed", so the round-trip arm + can skip the former while still failing hard on the latter, on some + future runner where `which` finds nothing usable either. It is not what + fixed windows-latest — that test now passes there rather than skipping. + + Result is cached: the probe spawns a process, and the answer cannot + change within one run.""" + if not _BASH_PROBE: + _BASH_PROBE.append(_probe_bash()) + return _BASH_PROBE[0] + + +def _probe_bash(): + exe = shutil.which("bash") + if not exe: + return None + try: + probe = subprocess.run([exe, "-s"], input=b"printf ca-probe-ok\n", + capture_output=True, timeout=60) + # TimeoutExpired is a SubprocessError, NOT an OSError, so catching only + # OSError leaves the hole open on the exact platform this probe exists + # for: a `bash` that HANGS rather than exits (a WSL stub blocking on an + # install prompt is the canonical windows-latest shape) would propagate + # out and ERROR the test instead of skipping it. + except (OSError, subprocess.TimeoutExpired): + return None + if probe.returncode != 0 or probe.stdout.strip() != b"ca-probe-ok": + return None + return exe + class LastTagSelectTest(unittest.TestCase): """AC-1 (0006): highest ca SemVer tag, excluding pre-releases and ca-sandbox-v*.""" @@ -459,14 +560,29 @@ def test_peel_tag_prints_nothing_for_an_absent_tag(self): class SkillProseTest(unittest.TestCase): """AC-5 (0002) + skill-side wiring of AC-1..4 into release/SKILL.md. - Structural: the prose is what the orchestrator follows, so we assert the - helpers are invoked and the farm.js freshness instruction is unconditional - and points at CI as the backstop.""" + T-41a-d (issue #563) rewrote the skill from a hardcoded four-row table to + a declared-file loader, so this class now reads the SOURCE + (`core/surface/skills/release/SKILL.md`) rather than a generated payload — + the spec's own "Source of truth" rule ("every skill and command edit + lands in core/surface/, never a generated plugins/*/ copy. Guards and + structural assertions target the surface source"), which the pre-rewrite + version of this class violated by reading `plugins/ca/skills/...` + directly. Assertions that pinned this repo's four hardcoded rows + (`test_every_shipped_bundle_is_named_for_its_target`, + `test_every_release_target_is_reachable_from_the_command`, + `test_each_target_names_its_own_manifest_and_changelog`, + `test_only_ca_may_claim_the_latest_badge`, + `test_the_pi_root_manifest_is_generated_not_hand_edited`, + `test_names_ci_tools_job_as_backstop`) are retired below in favor of + `SkillPortabilityTest`, which asserts the LOADER-based replacement shape + instead — asserting the OLD literal text would now be asserting the + defect this migration exists to remove.""" @classmethod def setUpClass(cls): repo = os.path.dirname(os.path.dirname(HERE)) - path = os.path.join(repo, "plugins", "ca", "skills", "release", "SKILL.md") + path = os.path.join( + repo, "core", "surface", "skills", "release", "SKILL.md") with open(path, encoding="utf-8") as fh: cls.text = fh.read() @@ -485,61 +601,6 @@ def test_artifact_freshness_is_unconditional(self): self.text, "0002: the rebuild must not be conditional on an in-window source change") - def test_every_shipped_bundle_is_named_for_its_target(self): - # #382 generalised the freshness step over four targets, so the check - # that used to name ONE artifact now has to name all of them: a plugin - # whose bundle is absent from the Targets table can release a stale one - # simply because nothing told the orchestrator to rebuild it. Listed - # explicitly rather than derived, because ca-pi's bundles are NOT in - # payload_scope.SHIPPED_TOOLS_ARTIFACTS - they live under extensions/, - # which is already inside the payload scope. - for artifact in ("plugins/ca/tools/farm.js", - "plugins/ca-sandbox/tools/sandbox.js", - "plugins/ca-sandbox/tools/claude-inside.js", - "plugins/ca-pi/extensions/codearbiter.js", - "plugins/ca-pi/extensions/codearbiter-child.js"): - with self.subTest(artifact=artifact): - self.assertIn(artifact, self.text, - "the Targets table must name every shipped bundle") - - def test_every_release_target_is_reachable_from_the_command(self): - # The command half of #382: one command, four targets. Each target and - # its namespace must appear, and the namespace must come from the shared - # register rather than being typed into the prose. - self.assertIn("_releaselib.py tag-prefix", self.text, - "the skill must ASK for the namespace, not restate four of them") - for target, prefix in _releaselib.RELEASE_TAG_PREFIXES.items(): - with self.subTest(target=target): - self.assertIn(f"`{target}`", self.text) - self.assertIn(f"`{prefix}`", self.text) - - def test_each_target_names_its_own_manifest_and_changelog(self): - for path in ("plugins/ca/.claude-plugin/plugin.json", - "plugins/ca-codex/.codex-plugin/plugin.json", - "plugins/ca-sandbox/.claude-plugin/plugin.json", - "plugins/ca-pi/package.json", - "plugins/ca-codex/CHANGELOG.md", - "plugins/ca-sandbox/CHANGELOG.md", - "plugins/ca-pi/CHANGELOG.md"): - with self.subTest(path=path): - self.assertIn(path, self.text) - - def test_only_ca_may_claim_the_latest_badge(self): - # One repo-wide "Latest" across four series; a sibling claiming it hides - # ca's current release from every visitor. - self.assertIn("MUST NOT assert `--latest` for any target except `ca`", - self.text) - - def test_the_pi_root_manifest_is_generated_not_hand_edited(self): - # Pi installs the repository ROOT as the package, so the root manifest is - # a second thing that must agree with the tag - and it is generated. - self.assertIn("tools/build-host-packages.py", self.text) - self.assertIn("never hand-edit", self.text.lower()) - - def test_names_ci_tools_job_as_backstop(self): - self.assertIn("`tools` job", self.text, - "AC-5: the local check must name the CI tools job as the mechanical backstop") - def test_date_derived_once(self): # The release date is computed once and reused (no second hand-typed date). self.assertIn("date +%F", self.text) @@ -551,8 +612,250 @@ def test_changelog_roll_uses_bracket_heading(self): # made notes-match no-match on the real v2.6.0 publish). `vMAJOR.MINOR. # PATCH` without the `## ` prefix is still correct for tag names, so the # assertion is scoped to the markdown-heading form only. - self.assertIn("## [MAJOR.MINOR.PATCH]", self.text) - self.assertNotIn("## vMAJOR.MINOR.PATCH", self.text) + self.assertIn("## [${VERSION}]", self.text) + self.assertNotIn("## v${VERSION}", self.text) + + +class SkillPortabilityTest(unittest.TestCase): + """T-41a-d (issue #563, A-6.0): the release skill's Targets table becomes + a `load_targets()` call, its helper invocations resolve under + `${CLAUDE_PLUGIN_ROOT}` (rendered; the SOURCE spells it + `{{PLUGIN_ROOT}}`), its Phase-3 tag-provenance step reads the row's + `provenance-manifest` field, and its hosted-lane/immutability prose is + conditional on what the consumer's own repo has. Reads the SURFACE + SOURCE, never a generated payload (spec: "Source of truth").""" + + @classmethod + def setUpClass(cls): + cls.repo = os.path.dirname(os.path.dirname(HERE)) + path = os.path.join( + cls.repo, "core", "surface", "skills", "release", "SKILL.md") + with open(path, encoding="utf-8") as fh: + cls.text = fh.read() + + # -- T-41a: table -> loader; no hardcoded row survives ------------------ + + def test_skill_uses_loader(self): + self.assertIn("load", self.text.lower()) + self.assertIn("declared file", self.text) + self.assertIn("release-targets.md", self.text) + # No hardcoded row survives: neither this repo's four target names, + # nor any of its literal manifest/changelog/artifact paths, appear as + # skill PROSE any longer (a bare filename with no directory qualifier, + # e.g. `CHANGELOG.md`/`package.json` as a placeholder VALUE label, + # remains fine and is not checked here). + for literal in ( + "ca-codex", "ca-sandbox", "ca-pi", + "plugins/ca/.claude-plugin/plugin.json", + "plugins/ca-codex/.codex-plugin/plugin.json", + "plugins/ca-sandbox/.claude-plugin/plugin.json", + "plugins/ca-pi/package.json", + "plugins/ca-codex/CHANGELOG.md", + "plugins/ca-sandbox/CHANGELOG.md", + "plugins/ca-pi/CHANGELOG.md", + "plugins/ca/tools/farm.js", + "plugins/ca-sandbox/tools/sandbox.js", + "plugins/ca-sandbox/tools/claude-inside.js", + "plugins/ca-pi/extensions/codearbiter.js", + "plugins/ca-pi/extensions/codearbiter-child.js"): + with self.subTest(literal=literal): + self.assertNotIn( + literal, self.text, + f"a hardcoded row literal {literal!r} survives the " + "table -> loader rewrite") + self.assertNotIn( + "| `$TARGET` | `$TAG_PREFIX` |", self.text, + "the old hardcoded Targets table header survives") + + def test_skill_uses_loader_field_names(self): + # The row's own field vocabulary (grammar names, spec "File grammar") + # must appear so an agent following the prose knows which key to read. + for field in ("prefix", "manifest", "changelog", "payload", + "payload-exclude", "rebuild", "artifacts", + "provenance-manifest", "pre-tag", "latest-eligible", + "generated-manifest", "generate", "display-name"): + with self.subTest(field=field): + self.assertIn(f"`{field}`", self.text) + + # -- T-41b: helpers repoint under {{PLUGIN_ROOT}}; no .github/scripts/, + # and the four prose-only mentions no longer name a repo path -------- + + def test_skill_helpers_repoint_under_plugin_root(self): + self.assertNotIn(".github/scripts/", self.text) + self.assertIn("{{PLUGIN_ROOT}}/hooks/_releaselib.py", self.text) + + def test_skill_no_longer_names_the_four_prose_only_scripts(self): + # MEDIUM-4 (ratchet comment, known-unresolved-refs.txt): these four + # were NAMED without being invoked, so a criterion about invocations + # alone would never clear them. Reworded to a conditional CI + # reference (AC-6.3 precedent) rather than a literal repo path. + for script in ("check_badge_consistency.py", "check_tag_immutability.py", + "payload_scope.py", "test_release_lib.py"): + with self.subTest(script=script): + self.assertNotIn(script, self.text) + # The ca-pi-specific root-manifest regeneration invocation is gone + # entirely -- it is now DATA (a declared `pre-tag` command in this + # repo's own release-targets.md), never skill prose. + self.assertNotIn("tools/build-host-packages.py", self.text) + + # -- T-41c: Phase 3 provenance step reads the row field ----------------- + + def test_skill_provenance_field(self): + self.assertIn("provenance-manifest", self.text) + self.assertIn("$PROVENANCE_MANIFEST", self.text) + self.assertNotIn(".github/published-tags.json", self.text) + # The absent-row-field skip must be documented explicitly in the + # report, not silent (A-3.5). + idx = self.text.index("Record the tag's provenance") + window = self.text[idx:idx + 1200] + self.assertIn("skips this step", window) + self.assertIn("say so explicitly in the report", window) + + # -- T-41d: hosted-lane and immutability prose are conditional ---------- + + def test_skill_conditional_prose(self): + phase3_idx = self.text.index("## Phase 3") + recovering_idx = self.text.index("## Recovering from a bad release") + phase3_preamble = self.text[phase3_idx:phase3_idx + 900] + recovering = self.text[recovering_idx:] + + self.assertIn("If this project has a hosted release workflow", phase3_preamble) + self.assertIn("A project with no hosted workflow", phase3_preamble) + + self.assertIn("If this project runs an automated tag-immutability", + recovering) + self.assertIn("A project with no such CI check", recovering) + # This repo's own concrete CI check name/artifact must not survive as + # an unconditional claim about every consumer's repo. + self.assertNotIn("[CHECK] | [REPO] | Published tag immutability", + self.text) + self.assertNotIn(".github/published-tags.json", self.text) + self.assertNotIn("repository rulesets", self.text) + + # The doctrine itself must survive: immutable-tag rule, no + # break-glass, correction-by-new-version, "manifest is the witness". + self.assertIn("A published tag is immutable", recovering) + self.assertIn("no break-glass", recovering.lower()) + self.assertIn("the manifest is the witness, not the suspect", recovering) + for step_text in ("git push --force", "git push --delete", + "gh release delete", "gh release edit"): + with self.subTest(step_text=step_text): + self.assertIn(step_text, recovering) + + def test_skill_latest_is_row_driven(self): + self.assertIn("latest-eligible", self.text) + self.assertNotIn( + "MUST NOT assert `--latest` for any target except `ca`", self.text) + self.assertIn( + "MUST NOT assert `--latest` for any target whose row does not " + "declare `latest-eligible: true`", self.text) + + def test_skill_pre_tag_replaces_the_hardcoded_surface_sync(self): + # The old per-target badge/root-manifest bullet list is gone; the + # portable replacement is the row's declared `pre-tag` commands + # (DECISION-0034), run in declared order with a BLOCK on non-zero. + self.assertNotIn("the README version badge (`version-X.Y.Z`)", self.text) + self.assertIn("pre-tag", self.text) + self.assertIn("DECISION-0034", self.text) + + # -- T-49/T-50 (issue #563, A-5.3/A-5.4): the back-fill lane itself now + # exists (replacing the HIGH-1 canary from 2026-07-31, which asserted + # the ABSENCE of this prose and was written to be replaced, not + # loosened, the moment this landed — see BackfillSkillProseTest and + # test_consumer_smoke.py's two-arm proof). -------------------------- + + def test_skill_names_the_backfill_lane_and_still_names_context_creation(self): + self.assertIn("back-fill", self.text.lower()) + self.assertIn("context-creation", self.text) + + # -- HIGH-3 (adversarial review 2026-07-31): a manifest path that is + # GENERATED output is declarable, never hand-edited by this skill. -- + + def test_skill_generated_manifest_is_never_hand_edited(self): + self.assertIn("generated-manifest", self.text) + # A bare `assertIn("generate", ...)` would pass on the substring + # inside "generated-manifest"/"regenerate" alone and never actually + # exercise the field-name reference — check the backticked field + # name, the same way test_skill_uses_loader_field_names does. + self.assertIn("`generate`", self.text) + self.assertIn("never hand-edited", self.text) + self.assertIn("MUST NOT hand-edit a path also listed in " + "`$GENERATED_MANIFEST`", self.text) + + # -- M-1 (adversarial review 2026-07-31): the Release title convention + # names a "display name" no grammar key ever supplied. ------------- + + def test_skill_release_title_uses_a_declared_display_name(self): + self.assertIn("display-name", self.text) + self.assertIn( + "The title convention is `<$DISPLAY_NAME> ${VERSION}: " + "

`", self.text) + self.assertNotIn("<$TARGET display name>", self.text) + + # -- M-2 (adversarial review 2026-07-31): the command and a hosted + # lane reading "the same declared file" is false until slice 4 + # (T-43-T-46) repoints the workflow/gate off hardcoded constants; + # the claim must be conditional on that, not asserted outright. ---- + + def test_skill_tag_prefix_agreement_with_a_hosted_lane_is_conditional(self): + self.assertNotIn( + "the same declared file a hosted publish lane (if this project " + "has one) reads, so the command and the lane cannot disagree", + self.text) + idx = self.text.index("Resolve `$TAG_PREFIX`") + window = self.text[idx:idx + 700] + self.assertIn("is ALSO wired to read this declared file", window) + self.assertIn("the two can drift", window) + + # -- Portability guard proxy (T-41b's own criterion; the guard SCRIPT + # that will enforce this mechanically, check_skill_portability.py, is + # T-68a/T-68b's own deliverable and is deliberately NOT authored here + # -- see the plan's dependency order). This is the honest local + # equivalent until then. -------------------------------------------- + + def test_no_github_scripts_invocation_remains_in_the_source(self): + self.assertNotIn(".github/scripts/", self.text) + + def test_generated_payloads_carry_no_github_scripts_reference(self): + # The source-only assertions above prove the SOURCE is clean, but + # `tools/build-surface.py --check` only proves the generator is + # IDEMPOTENT, not that what it rendered is free of `.github/scripts/` + # -- and `test_consumer_smoke.py`'s reference-resolution ratchet, + # which is the suite that would otherwise catch a payload-rendering + # regression, reads `git archive HEAD` and is blind to an uncommitted + # change. This is the one thing in this repo's GREEN, re-runnable + # test surface that actually reads the three full generated copies + # (`ca`, the `ca-codex`/`ca-pi` "routines" copies) rather than only + # the surface source or a stale committed snapshot. + payloads = ( + ("ca", os.path.join("plugins", "ca", "skills", "release", "SKILL.md")), + ("ca-codex", os.path.join( + "plugins", "ca-codex", "routines", "release", "SKILL.md")), + ("ca-pi", os.path.join( + "plugins", "ca-pi", "routines", "release", "SKILL.md")), + ) + host_plugin_root_token = {"ca": "claude", "ca-codex": "codex", "ca-pi": "pi"} + hosts_json_path = os.path.join(self.repo, "core", "hosts.json") + with open(hosts_json_path, encoding="utf-8") as fh: + hosts_data = json.load(fh) + tokens_by_host = { + host["name"]: host["tokens"]["PLUGIN_ROOT"] for host in hosts_data["hosts"] + } + for label, relpath in payloads: + with self.subTest(payload=label): + path = os.path.join(self.repo, relpath) + with open(path, encoding="utf-8") as fh: + text = fh.read() + self.assertNotIn( + ".github/scripts/", text, + f"{label!r}'s generated release skill still carries a " + ".github/scripts/ invocation") + plugin_token = tokens_by_host[host_plugin_root_token[label]] + self.assertIn( + f"{plugin_token}/hooks/_releaselib.py", text, + f"{label!r}'s generated release skill does not resolve " + f"its helper invocations under its own host's " + f"plugin-root token ({plugin_token!r})") @@ -569,7 +872,7 @@ class LastTagPerSeriesTest(unittest.TestCase): "ca-sandbox-v0.1.3", "ca-codex-v0.3.0", "v3.0.0-beta.1"] def test_the_prefix_register_covers_exactly_the_release_targets(self): - self.assertEqual(tuple(_releaselib.RELEASE_TAG_PREFIXES), + self.assertEqual(tuple(_releaselib.release_tag_prefixes()), _releaselib.RELEASE_TARGETS, "every releasable target needs a tag namespace, and no" " namespace may exist for a target that cannot release") @@ -581,7 +884,7 @@ def test_every_series_resolves_its_own_highest_tag(self): "ca-sandbox": "ca-sandbox-v0.1.3", "ca-pi": "ca-pi-v0.1.30", } - for target, prefix in _releaselib.RELEASE_TAG_PREFIXES.items(): + for target, prefix in _releaselib.release_tag_prefixes().items(): with self.subTest(target=target): self.assertEqual(_releaselib.last_tag_select(self.TAGS, prefix), expected[target]) @@ -589,11 +892,11 @@ def test_every_series_resolves_its_own_highest_tag(self): def test_no_series_resolves_another_series_tag(self): # The defect this prevents: basing an entire release on another plugin's # baseline, which `git describe --tags` does by commit-graph ancestry. - for target, prefix in _releaselib.RELEASE_TAG_PREFIXES.items(): + for target, prefix in _releaselib.release_tag_prefixes().items(): with self.subTest(target=target): chosen = _releaselib.last_tag_select(self.TAGS, prefix) self.assertTrue(chosen.startswith(prefix), chosen) - for other, other_prefix in _releaselib.RELEASE_TAG_PREFIXES.items(): + for other, other_prefix in _releaselib.release_tag_prefixes().items(): if other == target or not other_prefix.startswith(prefix): continue self.assertFalse( @@ -617,7 +920,7 @@ def test_a_series_with_no_release_yet_is_the_none_sentinel(self): _releaselib.NONE_SENTINEL) def test_prereleases_are_excluded_in_every_series(self): - for prefix in _releaselib.RELEASE_TAG_PREFIXES.values(): + for prefix in _releaselib.release_tag_prefixes().values(): with self.subTest(prefix=prefix): self.assertEqual( _releaselib.last_tag_select( @@ -690,7 +993,7 @@ def _notes(self, version): return f"## [{version}] - 2026-07-26\n\n### Added\n\n- a thing\n" def test_every_release_series_matches_its_own_notes(self): - for target, prefix in _releaselib.RELEASE_TAG_PREFIXES.items(): + for target, prefix in _releaselib.release_tag_prefixes().items(): version = "1.2.3" with self.subTest(target=target): self.assertTrue( @@ -729,5 +1032,3580 @@ def test_bare_version_extracts_the_semver_from_any_spelling(self): with self.subTest(spelling=spelling): self.assertEqual(_releaselib._bare_version(spelling), want) + +class ShimReexportCompletenessTest(unittest.TestCase): + """The shim must re-export every name ANY consumer imports, not just the + names the tests happen to use. + + This exists because of a real regression. The first shim conversion + re-exported `semver_key` and `semver_greater` but dropped `SEMVER`, which + `tools/build-host-packages.py` imports. Nothing failed: the whole suite + stayed green, because no test imported `SEMVER`. The re-export surface was + covered for the names the tests use rather than the names consumers use, + and the break only surfaced when a declared `pre-tag` command ran it. + + The required set is DERIVED by scanning the repo, never restated here. A + hand-maintained list would reproduce the same defect the moment someone + adds an import without updating it. + """ + + _IMPORT_RE = re.compile(r"^\s*from\s+_releaselib\s+import\s+(.+)$", re.M) + + def _required_names(self): + roots = [os.path.join(REPO_ROOT, ".github", "scripts"), + os.path.join(REPO_ROOT, "tools")] + required = {} + for root in roots: + if not os.path.isdir(root): + continue + for entry in sorted(os.listdir(root)): + if not entry.endswith(".py"): + continue + path = os.path.join(root, entry) + with io.open(path, encoding="utf-8") as handle: + text = handle.read() + for clause in self._IMPORT_RE.findall(text): + clause = clause.split("#", 1)[0] + for raw in clause.split(","): + name = raw.strip().split(" as ")[0].strip() + if name and name.isidentifier(): + required.setdefault(name, []).append(entry) + return required + + def test_the_scan_finds_real_consumers(self): + """Guard the guard: an empty or trivial scan would make the next test + vacuously pass, which is exactly how the original defect survived.""" + required = self._required_names() + self.assertGreaterEqual( + len(required), 3, + "the import scan found almost nothing, so the completeness check " + "below would pass without measuring anything: %r" % (required,)) + + def test_shim_reexports_every_name_any_consumer_imports(self): + required = self._required_names() + missing = {n: v for n, v in sorted(required.items()) + if not hasattr(_releaselib, n)} + self.assertEqual( + missing, {}, + "the shim does not export every name its consumers import. Each " + "entry maps a missing name to the files importing it; a consumer " + "importing it will fail at module load: %r" % (missing,)) + + +class ReleaselibShimTest(unittest.TestCase): + """The shim (`.github/scripts/_releaselib.py`, imported above as + `_releaselib`) must be a thin RE-EXPORT of the portable mechanism + (`core_releaselib`), not a second implementation. + + A-1.9's TRANSITIONAL clause is RETIRED here, as A-4.4 requires in the + same commit that removes the constants. That clause said "no commit may + leave RELEASE_TAG_PREFIXES unimportable from this module" -- a rule that + existed only to keep consumers working while the data still lived here. + T-46 removed the literal (payload_version_gate.py derives its own map, + nothing else consumed it), so the rule now protects nothing and would + forbid the very change it was written to enable.""" + + def test_releaselib_shim_exports_constants(self): + # payload_version_gate.py:53 imports exactly these three names from + # this shim at module load; they must still resolve. + self.assertEqual(_releaselib.release_tag_prefixes(), + {"ca": "v", "ca-codex": "ca-codex-v", + "ca-sandbox": "ca-sandbox-v", "ca-pi": "ca-pi-v"}) + self.assertTrue(callable(_releaselib.semver_greater)) + self.assertTrue(callable(_releaselib.semver_key)) + + def test_shim_error_hierarchy_is_the_portable_modules_not_a_duplicate(self): + # A duplicate hierarchy would let the two modules' exceptions + # silently diverge: a caller catching the shim's ReleaseTargetsError + # would not catch one raised by core/pysrc/_releaselib.py, and vice + # versa. `inspect.getsourcefile` -- rather than `assertIs` -- is the + # right proof here: the shim loads its own copy of the mechanism + # module under a private name (`_release_mechanism`), so its classes + # are never the SAME objects as this test's own independently-loaded + # `core_releaselib` (a second, distinct exec of the same file). + # Source-file identity is what "re-exported, not reimplemented" + # actually means; a hand-written duplicate hierarchy inside the shim + # itself would report the SHIM's own path here, not the core one. + self.assertEqual( + inspect.getsourcefile(_releaselib.AbsentBlockError), _CORE_RELEASELIB_PATH) + self.assertEqual( + inspect.getsourcefile(_releaselib.ReleaseTargetsError), _CORE_RELEASELIB_PATH) + self.assertEqual( + inspect.getsourcefile(_releaselib.MissingRequiredKeyError), + _CORE_RELEASELIB_PATH) + + # M-3 (adversarial review, 2026-07-31): the 3-name spot-check above + # proves PROVENANCE (the class comes from the core file) but not + # IDENTITY (it is the RIGHT class from that file) -- a swap bug like + # `AbsentBlockError = _mechanism.MalformedBlockError` inside the shim + # would still pass every assertion above unchanged, since both classes + # live in the same core file. Enumerated explicitly, all 11 names. + _EXCEPTION_NAMES = ( + "ReleaseTargetsError", "AbsentBlockError", "EmptyBlockError", + "MalformedBlockError", "UnknownKeyError", "DuplicateKeyError", + "DuplicateTargetError", "InvalidBooleanError", "MultipleBlocksError", + "DelimiterInValueError", "MissingRequiredKeyError", + "FileExistsNoBlockError", + ) + + def test_shim_exception_names_are_not_silently_swapped(self): + # `__name__` is set at CLASS-DEFINITION time in the core module and + # is independent of whichever shim-level variable currently holds a + # reference to it -- so a swap (the shim name binds to the WRONG + # core class) shows up here even though `inspect.getsourcefile` + # alone cannot see it (both classes share one file). Paired with the + # existing source-file check and `issubclass`, this closes both + # directions the review named: a same-file swap (caught by + # `__name__`) and a hand-written duplicate under the right name + # (caught by `getsourcefile`). + for name in self._EXCEPTION_NAMES: + with self.subTest(name=name): + cls = getattr(_releaselib, name) + self.assertEqual(cls.__name__, name) + self.assertEqual(inspect.getsourcefile(cls), _CORE_RELEASELIB_PATH) + self.assertTrue(issubclass(cls, _releaselib.ReleaseTargetsError)) + + def test_shim_mechanism_functions_are_the_portable_modules_not_a_duplicate(self): + # Same reasoning as the error-hierarchy test above, applied to every + # non-defaulted mechanism function: each must be DEFINED in + # core/pysrc/_releaselib.py, never reimplemented in the shim. (The + # three wrapped functions -- last_tag_select, select_release_target, + # classify_merge_readiness -- restore this repo's OLD default + # arguments and so are legitimately NEW, thin functions defined in + # the shim itself; they are covered by the wrapper test below, not + # here.) + for fn in (_releaselib.semver_key, _releaselib.semver_greater, + _releaselib.notes_heading_matches, + _releaselib.release_dates_consistent, + _releaselib.classify_publish_state, _releaselib.peel_tag, + _releaselib.load_targets, _releaselib.parse_release_targets): + with self.subTest(fn=fn.__name__): + self.assertEqual(inspect.getsourcefile(fn), _CORE_RELEASELIB_PATH) + + def test_shim_wrapped_functions_restore_the_old_default_arguments(self): + # last_tag_select, select_release_target, and classify_merge_readiness + # now REQUIRE, in the portable module, the argument that used to + # default (A-1.3). The shim's own wrappers must still work with no + # argument, exactly as every existing caller invokes them. + self.assertEqual(_releaselib.last_tag_select(["v1.0.0"]), "v1.0.0") + self.assertEqual( + _releaselib.select_release_target("1.0.0", "", "", ""), "ca") + self.assertEqual( + _releaselib.classify_merge_readiness( + [{"name": _releaselib.MERGE_READINESS_CHECK, "head_sha": "a" * 40, + "status": "completed", "conclusion": "success"}], "a" * 40), + "green") + + def test_shim_resolves_the_mechanism_regardless_of_cwd(self): + # The whole point of locating the mechanism via this file's own + # __file__ rather than the process cwd (mirroring + # payload_version_gate.py's own REPO resolution): invoking the CLI + # from a directory with no relationship to this repo must not break + # the import. A cwd-dependent relative lookup would fail here. + import subprocess + import tempfile + with tempfile.TemporaryDirectory() as elsewhere: + result = subprocess.run( + [sys.executable, os.path.join(HERE, "_releaselib.py"), "last-tag"], + cwd=elsewhere, input="v1.0.0\nv1.1.0\n", + capture_output=True, text=True, timeout=30) + self.assertEqual((result.returncode, result.stdout.strip()), (0, "v1.1.0")) + + def test_payload_version_gate_imports_and_runs_unchanged(self): + # The load-bearing consumer (payload_version_gate.py:53) does + # `from _releaselib import RELEASE_TAG_PREFIXES, semver_greater, + # semver_key` at module load. This used to shell out to the gate CLI + # with `--base origin/main`, but the hooks job's checkout has no + # `fetch-depth: 0` and never fetches `origin/main`, so that + # subprocess correctly returned FAIL (exit 1) there for a reason that + # has nothing to do with this shim (HIGH-3). It also duplicated the + # `version-bump-ca` gate's verdict under a different trigger set, + # coupling this shim's own test to this repo's release/payload state. + # + # A plain in-process `import` exercises the EXACT statement the + # consumer runs, in the same interpreter this test file is already + # running in (`sys.path.insert(0, HERE)` above puts `.github/scripts` + # first, exactly where `payload_version_gate.py` itself inserts it). + # `from … import` binds names to the SAME objects the module-level + # `_releaselib` import at the top of this file already produced, so + # `assertIs` is a genuine identity proof of the shim/consumer wiring + # — no git, no network, no repo release-state dependency at all. + import payload_version_gate # noqa: E402 — needs sys.path mutation above + # Repointed for A-4.1/T-43: the gate stopped importing + # RELEASE_TAG_PREFIXES and now derives every namespace from the + # declared file. The test's PURPOSE is unchanged — it proves the + # gate's names bind to the shim's own objects — so it follows the + # import list rather than being deleted. Retiring it outright + # belongs to T-46, which removes the data constants themselves. + self.assertIs(payload_version_gate.load_targets, _releaselib.load_targets) + self.assertIs(payload_version_gate.semver_greater, _releaselib.semver_greater) + self.assertIs(payload_version_gate.semver_key, _releaselib.semver_key) + # And the constant is genuinely no longer reachable through the gate, + # so a silent re-import cannot pass this test by accident. + self.assertFalse(hasattr(payload_version_gate, "RELEASE_TAG_PREFIXES")) + + def test_bare_invocation_exits_2(self): + # Argparse's own required-argument failure, not a crash -- the args + # are required, so a bare invocation must refuse rather than guess. + # In-process (`main([])` raises SystemExit before any git call is + # made, so this never depended on repo state) rather than a 120s- + # timeout subprocess with its own cwd dependency. + import payload_version_gate + with self.assertRaises(SystemExit) as ctx: + payload_version_gate.main([]) + self.assertEqual(ctx.exception.code, 2) + + +class DenylistTest(unittest.TestCase): + """A-1.2: the portable mechanism (core/pysrc/_releaselib.py) must carry no + literal from this repository's namespace or CI vocabulary. `.github/ + scripts/_releaselib.py` (imported above as `_releaselib`) is EXEMPT — it + still carries this repo's own MERGE_READINESS_CHECK (a CI check-run + name, which the declared file has no field for) and is deliberately not + scanned here.""" + + DENYLIST = ( + "[REPO]", "ca-pi", "ca-codex", "ca-sandbox", "plugins/", + "RELEASE_TAG_PREFIXES", "RELEASE_TARGETS", + ) + + @classmethod + def setUpClass(cls): + with open(_CORE_RELEASELIB_PATH, encoding="utf-8") as fh: + cls.text = fh.read() + + def test_denylist_has_no_repo_namespace_or_ci_vocabulary_literal(self): + for token in self.DENYLIST: + with self.subTest(token=token): + self.assertNotIn( + token, self.text, + f"core/pysrc/_releaselib.py must not contain the " + f"repo-specific literal {token!r}") + + +class RequiredParamsTest(unittest.TestCase): + """A-1.3: repo-specific defaults became required parameters, so no + module-global fact can survive to detonate later. Each of the three + functions must reject a call missing the argument that used to default.""" + + def test_required_params_classify_merge_readiness_requires_check_name(self): + with self.assertRaises(TypeError): + core_releaselib.classify_merge_readiness([], "a" * 40) + + def test_required_params_last_tag_select_requires_prefix(self): + with self.assertRaises(TypeError): + core_releaselib.last_tag_select(["v1.0.0"]) + + def test_required_params_select_release_target_requires_targets(self): + with self.assertRaises(TypeError): + core_releaselib.select_release_target("1.0.0", "") + + def test_required_params_still_work_when_supplied(self): + # Non-regression: removing the default must not have broken the + # happy path, only removed the silent fallback. + self.assertEqual( + core_releaselib.last_tag_select(["v1.0.0"], "v"), "v1.0.0") + self.assertEqual( + core_releaselib.classify_merge_readiness( + [{"name": "gate", "head_sha": "a" * 40, "status": "completed", + "conclusion": "success"}], "a" * 40, "gate"), + "green") + self.assertEqual( + core_releaselib.select_release_target( + "1.0.0", "", targets=["app", "lib"]), + "app") + + +class LoadTargetsTest(unittest.TestCase): + """A-1.4: load_targets(path) returns rows carrying the full declared + schema, reading a real file from disk (stdlib only).""" + + FIXTURE = ( + "prose before the block is ignored\n\n" + "\n" + "[app]\n" + "prefix: v\n" + "display-name: My App\n" + "manifest: package.json\n" + "manifest: nested/package.json\n" + "generated-manifest: nested/package.json\n" + "generate: node scripts/regen-nested-manifest.js\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "payload-exclude: tools/\n" + "rebuild: npm run build\n" + "artifacts: dist/bundle.js\n" + "artifacts: dist/bundle.min.js\n" + "provenance-manifest: .well-known/published-tags.json\n" + "latest-eligible: true\n" + "pre-tag: npm run lint\n" + "pre-tag: npm run typecheck\n" + "\n" + "prose after the block is ignored\n" + ) + + def _write_fixture(self, tmp_path_dir, text=None): + import tempfile + fd, path = tempfile.mkstemp(suffix=".md", dir=tmp_path_dir) + with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh: + fh.write(text if text is not None else self.FIXTURE) + return path + + def test_load_targets_returns_the_full_row_schema(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = self._write_fixture(tmp) + rows = core_releaselib.load_targets(path) + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["target"], "app") + self.assertEqual(row["prefix"], "v") + self.assertEqual(row["display_name"], "My App") + self.assertEqual(row["manifest"], ["package.json", "nested/package.json"]) + self.assertEqual(row["generated_manifest"], ["nested/package.json"]) + self.assertEqual(row["generate"], "node scripts/regen-nested-manifest.js") + self.assertEqual(row["changelog"], "CHANGELOG.md") + self.assertEqual(row["payload"], ".") + self.assertEqual(row["payload_exclude"], ["tools/"]) + self.assertEqual(row["rebuild"], "npm run build") + self.assertEqual(row["artifacts"], ["dist/bundle.js", "dist/bundle.min.js"]) + self.assertEqual(row["provenance_manifest"], ".well-known/published-tags.json") + self.assertEqual(row["pre_tag"], ["npm run lint", "npm run typecheck"]) + self.assertIs(row["latest_eligible"], True) + + def test_load_targets_minimal_row_has_documented_defaults(self): + text = ("\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n") + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = self._write_fixture(tmp, text) + rows = core_releaselib.load_targets(path) + row = rows[0] + self.assertIsNone(row["display_name"]) + self.assertEqual(row["manifest"], []) + self.assertEqual(row["generated_manifest"], []) + self.assertIsNone(row["generate"]) + self.assertEqual(row["payload_exclude"], []) + self.assertIsNone(row["rebuild"]) + self.assertEqual(row["artifacts"], []) + self.assertIsNone(row["provenance_manifest"]) + self.assertEqual(row["pre_tag"], []) + self.assertIs(row["latest_eligible"], False) + + def test_load_targets_multiple_targets_all_load(self): + text = ("\n" + "[one]\n" + "prefix: one-v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" + "[two]\n" + "prefix: two-v\n" + "changelog: two/CHANGELOG.md\n" + "payload: two/\n" + "\n") + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = self._write_fixture(tmp, text) + rows = core_releaselib.load_targets(path) + self.assertEqual([row["target"] for row in rows], ["one", "two"]) + + +class AbsentBlockTest(unittest.TestCase): + """A-1.5: an absent delimiter block raises a distinguishable declared + error, never a silent empty result.""" + + def test_no_delimiter_at_all_raises_absent_block_error(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets("just some prose, no block\n") + + def test_empty_string_raises_absent_block_error(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets("") + + def test_absent_block_error_is_a_release_targets_error(self): + # Callers that only need the broad category can catch the base class. + self.assertTrue( + issubclass(core_releaselib.AbsentBlockError, + core_releaselib.ReleaseTargetsError)) + + +class EmptyBlockTest(unittest.TestCase): + """A-1.7: a present-but-empty delimiter block raises a distinguishable + declared error, distinct from AbsentBlockError.""" + + def test_whitespace_only_block_raises_empty_block_error(self): + text = "\n \n\n\n" + with self.assertRaises(core_releaselib.EmptyBlockError): + core_releaselib.parse_release_targets(text) + + def test_zero_byte_block_raises_empty_block_error(self): + text = "" + with self.assertRaises(core_releaselib.EmptyBlockError): + core_releaselib.parse_release_targets(text) + + def test_empty_block_error_is_distinct_from_absent_block_error(self): + self.assertFalse( + issubclass(core_releaselib.EmptyBlockError, + core_releaselib.AbsentBlockError)) + self.assertFalse( + issubclass(core_releaselib.AbsentBlockError, + core_releaselib.EmptyBlockError)) + + +class FileExistsNoBlockTest(unittest.TestCase): + """HIGH-1 (adversarial review 2026-07-31): an EXISTING declared-target + file that carries no delimiter block at all must raise a DIFFERENT, + distinguishable error than a genuinely ABSENT file -- the release + skill's Back-fill lane triggers on `AbsentBlockError` alone, and + treating "exists but no block" as the same case would make the lane + silently overwrite an operator's own existing (bad) file. Only + `load_targets` can make this distinction (it is the one function that + knows whether `open()` succeeded); `parse_release_targets` sees text + only and is UNCHANGED -- it keeps raising `AbsentBlockError` for a + blockless string, proven by `AbsentBlockTest` above, which this class + must not weaken.""" + + def test_existing_file_with_no_block_raises_file_exists_no_block_error(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write("just some prose, no block at all\n") + with self.assertRaises(core_releaselib.FileExistsNoBlockError): + core_releaselib.load_targets(path) + + def test_genuinely_missing_file_still_raises_absent_block_error(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "does-not-exist.md") + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.load_targets(path) + + def test_existing_no_block_is_never_caught_by_except_absent_block_error(self): + # The exact hazard HIGH-1 names: a caller written BEFORE this class + # existed, with `except AbsentBlockError:` as its Back-fill trigger, + # must not accidentally widen to catch the existing-file case too. + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write("no block here either\n") + caught_as_absent = False + try: + try: + core_releaselib.load_targets(path) + except core_releaselib.AbsentBlockError: + caught_as_absent = True + except core_releaselib.FileExistsNoBlockError: + pass + self.assertFalse( + caught_as_absent, + "an existing-but-blockless file must never be caught by " + "`except AbsentBlockError:` -- that is the Back-fill " + "lane's genuinely-absent-only trigger") + + def test_file_exists_no_block_error_is_a_sibling_not_a_subclass(self): + # Deliberately NOT `issubclass(FileExistsNoBlockError, + # AbsentBlockError)` in either direction -- both are siblings under + # ReleaseTargetsError, so a pre-existing `except AbsentBlockError` + # clause's behavior is unchanged by this class's addition. + self.assertFalse( + issubclass(core_releaselib.FileExistsNoBlockError, + core_releaselib.AbsentBlockError)) + self.assertFalse( + issubclass(core_releaselib.AbsentBlockError, + core_releaselib.FileExistsNoBlockError)) + self.assertTrue( + issubclass(core_releaselib.FileExistsNoBlockError, + core_releaselib.ReleaseTargetsError)) + + def test_parse_release_targets_on_synthetic_text_is_unchanged(self): + # parse_release_targets has no file to have opened, so a blockless + # STRING still raises the ORIGINAL AbsentBlockError -- this class + # must not weaken AbsentBlockTest's existing proof. + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets("just prose, no block\n") + + +class ParserContractTest(unittest.TestCase): + """A-1.6: each parser-contract violation raises its own distinguishable + declared error. Eight cases, matching the spec's enumeration: malformed + block, CRLF-bearing boolean (correct-parse case, not an error), non- + boolean boolean, duplicate scalar key, duplicate target block, unknown + key, multiple delimiter blocks, delimiter-in-value.""" + + VALID_HEADER = ("\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n") + CLOSE = "\n" + + def test_parser_contract_malformed_block_empty_header(self): + text = ("\n[]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + self.CLOSE) + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_malformed_block_header_bad_characters(self): + text = ("\n[app name!]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + self.CLOSE) + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_malformed_block_key_before_first_header(self): + text = ("\n" + "prefix: v\n[app]\nchangelog: CHANGELOG.md\npayload: .\n" + + self.CLOSE) + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_crlf_bearing_boolean_parses_as_the_boolean_not_an_error(self): + # This repo has documented LF->CRLF drift from editing on Windows. A + # naive parser would turn "latest-eligible: true\r" into a value that + # is not "true" and either silently drop the flag or raise the wrong + # error. The trailing \r must be stripped BEFORE the boolean check. + text = ("\r\n[app]\r\nprefix: v\r\n" + "changelog: CHANGELOG.md\r\npayload: .\r\n" + "latest-eligible: true\r\n" + "\r\n") + rows = core_releaselib.parse_release_targets(text) + self.assertIs(rows[0]["latest_eligible"], True) + + def test_parser_contract_non_boolean_boolean_raises_invalid_boolean_error(self): + text = self.VALID_HEADER + "latest-eligible: yes\n" + self.CLOSE + with self.assertRaises(core_releaselib.InvalidBooleanError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_duplicate_scalar_key_raises_duplicate_key_error(self): + text = self.VALID_HEADER + "prefix: w\n" + self.CLOSE + with self.assertRaises(core_releaselib.DuplicateKeyError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_list_keys_repeat_without_error(self): + # Non-regression: manifest/artifacts/pre-tag/payload-exclude are + # explicitly NOT subject to the duplicate-scalar-key rule. + text = (self.VALID_HEADER + "manifest: a.json\nmanifest: b.json\n" + + self.CLOSE) + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["manifest"], ["a.json", "b.json"]) + + def test_parser_contract_generated_manifest_repeats_without_error(self): + # HIGH-3: generated-manifest is a list key too, same as manifest. + text = (self.VALID_HEADER + + "manifest: a.json\nmanifest: b.json\n" + + "generated-manifest: a.json\ngenerated-manifest: b.json\n" + + self.CLOSE) + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["generated_manifest"], ["a.json", "b.json"]) + + def test_parser_contract_duplicate_target_block_raises_duplicate_target_error(self): + text = (self.VALID_HEADER + "[app]\nprefix: w\n" + "changelog: CHANGELOG.md\npayload: .\n" + self.CLOSE) + with self.assertRaises(core_releaselib.DuplicateTargetError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_unknown_key_raises_unknown_key_error(self): + text = self.VALID_HEADER + "latest-eligibile: true\n" + self.CLOSE + with self.assertRaises(core_releaselib.UnknownKeyError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_multiple_delimiter_blocks_raises_multiple_blocks_error(self): + text = (self.VALID_HEADER + self.CLOSE + "\nmore prose\n\n" + + self.VALID_HEADER + self.CLOSE) + with self.assertRaises(core_releaselib.MultipleBlocksError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_delimiter_in_value_raises_delimiter_in_value_error(self): + # A value that literally contains the closing delimiter must not + # silently truncate the block under a non-greedy match. + text = (self.VALID_HEADER + + "rebuild: echo ''\n" + self.CLOSE) + with self.assertRaises(core_releaselib.DelimiterInValueError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_missing_required_key_raises_missing_required_key_error(self): + # M3: previously untested — gutting _finish_row's check, or shrinking + # _REQUIRED_KEYS to one entry, both left the suite green. Each of the + # three required keys is dropped in its own fixture so a shrink of + # _REQUIRED_KEYS to any single remaining entry is still caught, + # rather than only a shrink that happens to keep the one key this + # class's fixtures omit. + cases = { + "prefix": "changelog: CHANGELOG.md\npayload: .\n", + "changelog": "prefix: v\npayload: .\n", + "payload": "prefix: v\nchangelog: CHANGELOG.md\n", + } + for missing_key, body in cases.items(): + with self.subTest(missing=missing_key): + text = "\n[app]\n" + body + self.CLOSE + with self.assertRaises(core_releaselib.MissingRequiredKeyError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_boolean_is_exact_case_true_or_false_only(self): + # M6: `value == "true"` mutated to a case-insensitive comparison + # (`value.lower() == "true"`) survived the suite otherwise — the + # spec requires the boolean to be EXACTLY "true"/"false". + text = self.VALID_HEADER + "latest-eligible: TRUE\n" + self.CLOSE + with self.assertRaises(core_releaselib.InvalidBooleanError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_unclosed_block_raises_malformed_block_error(self): + # M6: the unclosed-block branch (no `` at + # all) was previously untested directly. + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(self.VALID_HEADER) + + def test_parser_contract_every_violation_is_its_own_distinguishable_type(self): + # The point of the contract: a caller can tell these apart by type. + types = { + core_releaselib.MalformedBlockError, + core_releaselib.InvalidBooleanError, + core_releaselib.DuplicateKeyError, + core_releaselib.DuplicateTargetError, + core_releaselib.UnknownKeyError, + core_releaselib.MultipleBlocksError, + core_releaselib.DelimiterInValueError, + core_releaselib.MissingRequiredKeyError, + } + self.assertEqual(len(types), 8, "the 8 non-CRLF violations must be distinct types") + for cls in types: + with self.subTest(cls=cls.__name__): + self.assertTrue(issubclass(cls, core_releaselib.ReleaseTargetsError)) + + +class SeriesIsolationTest(unittest.TestCase): + """A-1.8: series isolation holds against LOADED data — a fixture + declaring two series resolves each declared prefix to its own newest + tag, with pre-releases excluded, composing `load_targets` with + `last_tag_select`.""" + + FIXTURE = ( + "\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" + "[app-pi]\n" + "prefix: ca-pi-v\n" + "changelog: sub/CHANGELOG.md\n" + "payload: sub/\n" + "\n" + ) + + TAGS = [ + "v1.0.0", "v0.9.0", "v1.1.0-beta.1", + "ca-pi-v0.1.0", "ca-pi-v0.0.9", "ca-pi-v0.2.0-beta.1", + ] + + def test_series_isolation_each_series_resolves_its_own_newest_tag(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + import os as _os + path = _os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self.FIXTURE) + rows = core_releaselib.load_targets(path) + + resolved = { + row["target"]: core_releaselib.last_tag_select(self.TAGS, row["prefix"]) + for row in rows + } + self.assertEqual(resolved["app"], "v1.0.0") + self.assertEqual(resolved["app-pi"], "ca-pi-v0.1.0") + + def test_series_isolation_prereleases_excluded_from_both_series(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + import os as _os + path = _os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self.FIXTURE) + rows = core_releaselib.load_targets(path) + + for row in rows: + chosen = core_releaselib.last_tag_select(self.TAGS, row["prefix"]) + with self.subTest(target=row["target"]): + self.assertNotIn("beta", chosen) + + def test_series_isolation_no_series_resolves_the_others_tag(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + import os as _os + path = _os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self.FIXTURE) + rows = core_releaselib.load_targets(path) + + prefixes = [row["prefix"] for row in rows] + for row in rows: + chosen = core_releaselib.last_tag_select(self.TAGS, row["prefix"]) + for other_prefix in prefixes: + if other_prefix == row["prefix"]: + continue + with self.subTest(target=row["target"], other=other_prefix): + self.assertFalse(chosen.startswith(other_prefix)) + + +# --------------------------------------------------------------------------- # +# Adversarial-review remediation (2026-07-31). See the module docstring for +# the finding each class closes. Every class here loads `core_releaselib` +# directly (never the old, unmodified `.github/scripts/_releaselib.py` shim), +# because the mutation gap this remediates was exactly that: real logic now +# ships in core/pysrc/_releaselib.py, but its only proof lived against the +# untouched shim. +# --------------------------------------------------------------------------- # + + +class CoreDelimiterInValueTest(unittest.TestCase): + """H1: a value containing the literal closing delimiter must error in BOTH + directions — never silently truncate (even when the embedded delimiter is + the ONLY close in the text), and never misdiagnose a legitimate stray + mention of the delimiter text in prose that follows a properly-closed + block.""" + + HEADER = ("\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n") + + def test_a_non_required_value_embedding_the_delimiter_errors_not_truncates(self): + # Was: parses OK with rebuild == 'echo' (silently truncated). + text = self.HEADER + "payload: .\nrebuild: echo \n" + with self.assertRaises(core_releaselib.DelimiterInValueError): + core_releaselib.parse_release_targets(text) + + def test_a_required_value_embedding_the_delimiter_errors_not_emptied(self): + # Was: parses OK with payload == '' — a REQUIRED key silently emptied, + # the worst case the finding names. + text = self.HEADER + "payload: \n" + with self.assertRaises(core_releaselib.DelimiterInValueError): + core_releaselib.parse_release_targets(text) + + def test_a_stray_close_in_trailing_prose_is_not_misdiagnosed(self): + # Two close-delimiter matches exist in the text, but the FIRST one is + # the genuine, own-line block terminator; the second is a legitimate + # mention in prose AFTER the block. Only the direction above (a match + # embedded mid-line, encountered BEFORE any genuine terminator) is a + # violation. + text = (self.HEADER + "payload: .\n" + "\n" + "\n" + "See also in this doc as an example.\n") + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["payload"], ".") + + +class CoreRequiredKeyEmptyValueTest(unittest.TestCase): + """H2: a required key present with an empty or whitespace-only value must + be treated as MISSING. An `is None` check alone lets `prefix:` (no value) + through as `''`, which then resolves `last_tag_select`'s `` + sentinel — a typo'd declaration silently becomes a first-release + baseline.""" + + def test_empty_value_on_a_required_key_is_missing(self): + text = ("\n[app]\n" + "prefix:\n" + "changelog: CHANGELOG.md\npayload: .\n" + "\n") + with self.assertRaises(core_releaselib.MissingRequiredKeyError): + core_releaselib.parse_release_targets(text) + + def test_whitespace_only_value_on_a_required_key_is_missing(self): + text = ("\n[app]\n" + "prefix: \n" + "changelog: CHANGELOG.md\npayload: .\n" + "\n") + with self.assertRaises(core_releaselib.MissingRequiredKeyError): + core_releaselib.parse_release_targets(text) + + def test_a_genuinely_present_value_still_passes(self): + # Non-regression: the stricter check must not reject real content. + text = ("\n[app]\n" + "prefix: v\nchangelog: CHANGELOG.md\npayload: .\n" + "\n") + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["prefix"], "v") + + +class CorePrereleaseMarkerScopeTest(unittest.TestCase): + """M2: the pre-release marker denylist must test the VERSION portion + only, never the whole tag (prefix included) — a consumer whose own + prefix happens to contain one of the marker substrings must not lose + every release tag in that series.""" + + def test_a_prefix_containing_beta_still_resolves_its_own_releases(self): + tags = ["web-beta-v1.0.0", "web-beta-v1.2.0"] + self.assertEqual( + core_releaselib.last_tag_select(tags, "web-beta-v"), "web-beta-v1.2.0") + + def test_a_prefix_containing_rc_still_resolves_its_own_releases(self): + tags = ["api-rc-v2.0.0", "api-rc-v2.1.0"] + self.assertEqual( + core_releaselib.last_tag_select(tags, "api-rc-v"), "api-rc-v2.1.0") + + def test_a_prefix_containing_alpha_still_resolves_its_own_releases(self): + tags = ["thing-alpha-v0.1.0", "thing-alpha-v0.2.0"] + self.assertEqual( + core_releaselib.last_tag_select(tags, "thing-alpha-v"), "thing-alpha-v0.2.0") + + +class CorePrereleaseMarkersSecondLineOfDefenseTest(unittest.TestCase): + """Adversarial-review remediation (M-4, 2026-07-31): `_PRERELEASE_MARKERS` + is documented in `core/pysrc/_releaselib.py` as an explicit SECOND line of + defense behind the anchored `_release_re` matcher — but `_release_re`'s + own trailing `$` already rejects any tag carrying a suffix past + `MAJOR.MINOR.PATCH`, so a marker-bearing tag never reaches the + `_PRERELEASE_MARKERS` check at all through the public `last_tag_select` + API today. Mutating `_PRERELEASE_MARKERS` to `()` therefore changes + NOTHING observable through that path — confirmed empirically, and + exactly what the review found ("survives both suites"). + + A bare `assertEqual(_PRERELEASE_MARKERS, (...))` would die to that + mutant too, but would prove only that the tuple's literal value is + pinned, not that the defense DOES anything — the constant could be + renamed to nonsense words and that test would still only check its own + copy of the value. This test instead exercises the second line of + defense in the scenario it exists FOR: a hypothetical relaxation of the + first line. It monkeypatches `_release_re` to an UNANCHORED matcher (no + trailing `$`) for the duration of one call, so a marker-bearing "version" + now clears the (weakened) regex and reaches the `_PRERELEASE_MARKERS` + check on its own merits — the only way to make this line reachable + without weakening the shipped regex itself, which is out of scope here. + """ + + def test_marker_check_still_excludes_when_the_regex_alone_would_not(self): + import re as _re + import unittest.mock as mock + lax = _re.compile(r"^v(\d+)\.(\d+)\.(\d+)") # deliberately no trailing $ + with mock.patch.object(core_releaselib, "_release_re", return_value=lax): + tags = ["v1.0.0", "v1.1.0-beta.1"] + # Under the lax regex alone, "v1.1.0-beta.1" matches (span + # v1.1.0) and is numerically higher than v1.0.0 — only + # _PRERELEASE_MARKERS can still exclude it here. + self.assertEqual(core_releaselib.last_tag_select(tags, "v"), "v1.0.0") + + +class CoreColonInValueTest(unittest.TestCase): + """M4: values split on the FIRST colon only; a later colon is part of the + value. `line.find(":")` vs `line.rfind(":")` are indistinguishable + without a fixture value that itself contains a colon.""" + + def test_a_value_containing_a_colon_is_preserved_whole(self): + text = ("\n[app]\n" + "prefix: v\nchangelog: CHANGELOG.md\npayload: .\n" + 'rebuild: echo "a:b" && date +%H:%M:%S\n' + "\n") + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["rebuild"], 'echo "a:b" && date +%H:%M:%S') + + +class CoreEscapeHatchWrappingTest(unittest.TestCase): + """M5: two escape hatches must not bypass the declared ReleaseTargetsError + hierarchy the module promises callers can catch as one type.""" + + def test_non_string_input_stays_in_the_declared_hierarchy(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets(None) + + def test_an_unreadable_path_stays_in_the_declared_hierarchy(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.load_targets("no/such/release-targets.md") + + +class CoreClassifyPublishStateTest(unittest.TestCase): + """H3: classify_publish_state exercised against the portable module — + previously only the (untouched) shim's copy carried this proof.""" + + def test_no_tag_is_publish_fresh(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=False, tag_sha=None, head_sha="abc", + tag_version=None, manifest_version="2.6.0", release_is_nondraft=False), + "publish_fresh") + + def test_tag_at_head_version_match_no_release_is_resume(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="abc", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=False), + "resume_publish") + + def test_nondraft_release_is_already_published(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="abc", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=True), + "already_published") + + def test_tag_points_elsewhere_is_abort(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="xyz", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=False), + "abort_mismatch") + + def test_version_mismatch_is_abort(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="abc", head_sha="abc", + tag_version="2.5.0", manifest_version="2.6.0", release_is_nondraft=False), + "abort_mismatch") + + def test_nondraft_release_on_a_tag_at_another_commit_is_abort(self): + # Mismatch outranks publication state (issue #380's original defect). + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="xyz", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=True), + "abort_mismatch") + + +class CoreNotesHeadingTest(unittest.TestCase): + """H3: notes_heading_matches / _bare_version exercised against the + portable module.""" + + def test_matching_heading(self): + notes = "## v2.6.0 — 2026-06-26\n\n### Added\n- thing\n" + self.assertTrue(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_matching_bracket_heading(self): + notes = "## [2.6.0] — 2026-06-27\n\n### Added\n- thing\n" + self.assertTrue(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_mismatched_heading(self): + notes = "## v2.5.0 — 2026-06-01\n\n### Fixed\n- bug\n" + self.assertFalse(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_first_heading_is_authoritative(self): + notes = "## v2.5.0 — 2026-06-01\n\n## v2.6.0 — 2026-06-26\n" + self.assertFalse(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_no_heading_is_false(self): + self.assertFalse( + core_releaselib.notes_heading_matches("no headings here", "v2.6.0")) + + def test_never_raises_on_non_string(self): + self.assertFalse(core_releaselib.notes_heading_matches(None, "v2.6.0")) + + def test_namespaced_tag_matches_via_bare_version(self): + notes = "## [0.1.31] - 2026-07-26\n\n### Added\n\n- a thing\n" + self.assertTrue( + core_releaselib.notes_heading_matches(notes, "ca-pi-v0.1.31")) + + def test_bare_version_extracts_the_semver_from_any_spelling(self): + cases = { + "v2.6.0": "2.6.0", + "2.6.0": "2.6.0", + "[2.6.0]": "2.6.0", + "ca-pi-v0.1.31": "0.1.31", + } + for spelling, want in cases.items(): + with self.subTest(spelling=spelling): + self.assertEqual(core_releaselib._bare_version(spelling), want) + + def test_bare_version_never_raises_on_non_string(self): + self.assertIsNone(core_releaselib._bare_version(None)) + + +class CoreReleaseDatesTest(unittest.TestCase): + """H3: release_dates_consistent exercised against the portable module.""" + + def test_consistent_dates(self): + section = "## v2.6.0 — 2026-06-26\n\n### Added\n- thing\n" + tagmsg = "codeArbiter 2.6.0\n\nstuff\n\nReleased-at: 2026-06-26\n" + self.assertTrue(core_releaselib.release_dates_consistent(section, tagmsg)) + + def test_inconsistent_dates(self): + section = "## v2.6.0 — 2026-06-26\n" + tagmsg = "Released-at: 2026-06-25\n" + self.assertFalse(core_releaselib.release_dates_consistent(section, tagmsg)) + + def test_missing_changelog_date_is_false(self): + self.assertFalse(core_releaselib.release_dates_consistent( + "## v2.6.0\n", "Released-at: 2026-06-26\n")) + + def test_missing_tag_date_is_false(self): + self.assertFalse(core_releaselib.release_dates_consistent( + "## v2.6.0 — 2026-06-26\n", "no footer")) + + def test_never_raises(self): + self.assertFalse(core_releaselib.release_dates_consistent(None, None)) + + +class CorePeelTagTest(unittest.TestCase): + """H3: peel_tag exercised against the portable module.""" + + TAG_OBJ = "1" * 40 + COMMIT = "2" * 40 + + def test_annotated_tag_resolves_to_the_peeled_commit(self): + text = (f"{self.TAG_OBJ}\trefs/tags/v2.6.0\n" + f"{self.COMMIT}\trefs/tags/v2.6.0^{{}}\n") + self.assertEqual(core_releaselib.peel_tag(text, "v2.6.0"), self.COMMIT) + + def test_lightweight_tag_resolves_to_its_direct_target(self): + text = f"{self.COMMIT}\trefs/tags/v2.6.0\n" + self.assertEqual(core_releaselib.peel_tag(text, "v2.6.0"), self.COMMIT) + + def test_a_prefix_sharing_tag_is_not_mistaken_for_it(self): + text = f"{self.COMMIT}\trefs/tags/v2.6.0-beta.1\n" + self.assertEqual(core_releaselib.peel_tag(text, "v2.6.0"), "") + + def test_absent_tag_is_empty(self): + self.assertEqual(core_releaselib.peel_tag("", "v2.6.0"), "") + + def test_never_raises_on_non_string(self): + self.assertEqual(core_releaselib.peel_tag(None, "v2.6.0"), "") + self.assertEqual(core_releaselib.peel_tag("whatever", None), "") + + +class CoreSemverTest(unittest.TestCase): + """H3: semver_key / semver_greater exercised against the portable + module — previously untested against ANY module, old shim included.""" + + def test_semver_key_parses_a_plain_version(self): + self.assertEqual(core_releaselib.semver_key("2.9.1"), (2, 9, 1, None)) + + def test_semver_key_none_on_unparseable(self): + self.assertIsNone(core_releaselib.semver_key("not-a-version")) + self.assertIsNone(core_releaselib.semver_key(None)) + self.assertIsNone(core_releaselib.semver_key(42)) + + def test_semver_key_discards_build_metadata_for_equality(self): + self.assertEqual( + core_releaselib.semver_key("1.0.0+a"), + core_releaselib.semver_key("1.0.0+b")) + + def test_semver_greater_numeric_advance(self): + self.assertTrue(core_releaselib.semver_greater("2.10.0", "2.9.0")) + self.assertFalse(core_releaselib.semver_greater("2.9.0", "2.10.0")) + self.assertFalse(core_releaselib.semver_greater("2.9.0", "2.9.0")) + + def test_semver_greater_release_beats_its_own_prerelease(self): + self.assertTrue(core_releaselib.semver_greater("1.0.0", "1.0.0-beta")) + self.assertFalse(core_releaselib.semver_greater("1.0.0-beta", "1.0.0")) + + def test_semver_greater_degrades_to_false_on_unparseable_input(self): + self.assertFalse(core_releaselib.semver_greater("nope", "1.0.0")) + self.assertFalse(core_releaselib.semver_greater("1.0.0", "nope")) + + +class CoreMergeReadinessArmsTest(unittest.TestCase): + """H3: classify_merge_readiness's non-green arms exercised against the + portable module — replacing this module's own copy with a hardcoded + constant previously stayed green, because only the shim's copy was + tested for anything but the required-parameter contract.""" + + SHA = "a" * 40 + OTHER = "b" * 40 + CHECK = "gate" + + def _run(self, status="completed", conclusion="success", head_sha=None, name=None): + return { + "name": self.CHECK if name is None else name, + "status": status, + "conclusion": conclusion, + "head_sha": self.SHA if head_sha is None else head_sha, + } + + def test_missing_when_no_run_matches_the_name(self): + self.assertEqual( + core_releaselib.classify_merge_readiness( + [self._run(name="other")], self.SHA, self.CHECK), + "missing") + + def test_missing_on_empty_check_runs(self): + self.assertEqual( + core_releaselib.classify_merge_readiness([], self.SHA, self.CHECK), + "missing") + + def test_pending_when_not_completed(self): + run = self._run(status="in_progress", conclusion=None) + self.assertEqual( + core_releaselib.classify_merge_readiness([run], self.SHA, self.CHECK), + "pending") + + def test_sha_mismatch_on_a_different_commit(self): + run = self._run(head_sha=self.OTHER) + self.assertEqual( + core_releaselib.classify_merge_readiness([run], self.SHA, self.CHECK), + "sha_mismatch") + + def test_not_successful_on_a_failed_conclusion(self): + run = self._run(conclusion="failure") + self.assertEqual( + core_releaselib.classify_merge_readiness([run], self.SHA, self.CHECK), + "not_successful") + + def test_green_is_still_reachable(self): + self.assertEqual( + core_releaselib.classify_merge_readiness([self._run()], self.SHA, self.CHECK), + "green") + + +class CoreSelectReleaseTargetArmsTest(unittest.TestCase): + """H3: select_release_target's `arity` and `multiple` arms exercised + against the portable module.""" + + TARGETS = ["app", "lib"] + + def test_multiple_when_more_than_one_selected(self): + self.assertEqual( + core_releaselib.select_release_target( + "1.0.0", "2.0.0", targets=self.TARGETS), + "multiple") + + def test_arity_on_a_length_mismatch(self): + self.assertEqual( + core_releaselib.select_release_target("1.0.0", targets=self.TARGETS), + "arity") + + def test_none_when_nothing_selected(self): + self.assertEqual( + core_releaselib.select_release_target("", "", targets=self.TARGETS), + "none") + + def test_the_matching_position_selects_its_target(self): + self.assertEqual( + core_releaselib.select_release_target("", "2.0.0", targets=self.TARGETS), + "lib") + + +class CoreCLITest(unittest.TestCase): + """T-41f (issue #563): `core/pysrc/_releaselib.py` gains a `__main__` CLI + entry point. Without this, T-41b's repointing of the release skill's + helper invocations under `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py` + would aim prose at a file with no runnable entry point at all — + `tag-prefix`, `last-tag`, and `classify` would fail in a consumer even + though the rewrite reports success. Every case here calls + `core_releaselib.main(argv)` directly EXCEPT + `test_consumer_shaped_subprocess_invocation_exits_zero`, which shells out + for real (the literal verification named in the plan: `python + "/hooks/_releaselib.py" tag-prefix ca` exits 0 from a + consumer-shaped environment) — a direct-call test alone could pass while + the file has a syntax error or an import that only breaks when actually + executed as a script.""" + + def _run_core(self, *argv): + """`main(argv)` with BOTH streams captured, as a SimpleNamespace with + `.returncode` / `.stdout` / `.stderr`. + + This class otherwise calls `main` bare and asserts only the code; + `show-row` and `payload-pathspec` answer on STDOUT and diagnose on + STDERR, so both have to be readable to test them at all. + """ + import contextlib + from types import SimpleNamespace + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = core_releaselib.main(list(argv)) + return SimpleNamespace(returncode=rc, stdout=out.getvalue(), + stderr=err.getvalue()) + + def _write_targets(self, tmp_dir, block): + path = os.path.join(tmp_dir, "release-targets.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write(block) + return path + + APP_BLOCK = ( + "\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" + ) + + def test_no_argv_prints_usage_and_exits_2(self): + import io, contextlib + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main([]) + self.assertEqual(rc, 2) + self.assertIn("usage", err.getvalue()) + + def test_bad_invocation_exits_2(self): + import io, contextlib + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main(["nonsense"]) + self.assertEqual(rc, 2) + + def test_tag_prefix_resolves_via_explicit_targets_file(self): + import io, contextlib + with tempfile.TemporaryDirectory() as tmp: + path = self._write_targets(tmp, self.APP_BLOCK) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = core_releaselib.main( + ["tag-prefix", "app", "--targets-file", path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue().strip(), "v") + + def test_tag_prefix_unknown_target_exits_2(self): + import io, contextlib + with tempfile.TemporaryDirectory() as tmp: + path = self._write_targets(tmp, self.APP_BLOCK) + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main( + ["tag-prefix", "nope", "--targets-file", path]) + self.assertEqual(rc, 2) + self.assertIn("unknown release target", err.getvalue()) + + def test_tag_prefix_targets_file_flag_with_no_value_exits_2(self): + import io, contextlib + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main(["tag-prefix", "app", "--targets-file"]) + self.assertEqual(rc, 2) + self.assertIn("--targets-file requires a value", err.getvalue()) + + def test_targets_file_flag_is_not_special_cased_for_other_subcommands(self): + # The flag-stripping pass lives INSIDE the tag-prefix branch only, so + # a caller-supplied positional value that happens to equal the flag + # spelling is never lexically special-cased out of a DIFFERENT + # subcommand's argument list. + import io, contextlib + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = core_releaselib.main( + ["classify", "false", "--targets-file", "-", "1.0.0", "1.0.0", "false"]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue().strip(), "publish_fresh") + + def test_tag_prefix_absent_declared_file_exits_3_not_a_traceback(self): + # HIGH-1 (adversarial review 2026-07-31): a genuinely absent file + # gets its OWN exit code (3), never the generic bad-invocation/ + # unknown-target code (2) it used to share with everything else. + import io, contextlib + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main( + ["tag-prefix", "app", "--targets-file", + os.path.join(tempfile.gettempdir(), + "codearbiter-cli-test-does-not-exist.md")]) + self.assertEqual(rc, 3) + self.assertIn("could not read declared release targets", err.getvalue()) + self.assertIn("AbsentBlockError", err.getvalue()) + + def test_tag_prefix_existing_no_block_file_exits_4_distinct_from_absent(self): + # HIGH-1: an EXISTING file with no block is a DIFFERENT declared-file + # state than a genuinely absent one and must exit with a DIFFERENT + # code -- the exact discrimination the finding says the CLI lacked. + import io, contextlib + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write("no block in this file at all\n") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main( + ["tag-prefix", "app", "--targets-file", path]) + self.assertEqual(rc, 4) + self.assertIn("FileExistsNoBlockError", err.getvalue()) + + def test_list_targets_prints_every_declared_name(self): + import io, contextlib + with tempfile.TemporaryDirectory() as tmp: + path = self._write_targets( + tmp, + "\n" + "[one]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" + "[two]\n" + "prefix: two-v\n" + "changelog: two/CHANGELOG.md\n" + "payload: two/\n" + "\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = core_releaselib.main( + ["list-targets", "--targets-file", path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue().splitlines(), ["one", "two"]) + + def test_list_targets_absent_file_exits_3(self): + import io, contextlib + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main( + ["list-targets", "--targets-file", + os.path.join(tempfile.gettempdir(), + "codearbiter-cli-list-targets-does-not-exist.md")]) + self.assertEqual(rc, 3) + + def test_list_targets_bad_invocation_exits_2(self): + import io, contextlib + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main(["list-targets", "unexpected-positional"]) + self.assertEqual(rc, 2) + + # `dates-match` (run 4). Phase 1 step 5 and Phase 2 step 1 both named + # `release_dates_consistent`, and Phase 2 said it "must pass" — with no + # CLI entry point, so an operator following the prose could not run the + # check the prose demanded. The exercising agent reached it only by + # importing the module, which the skill never instructs. + def _dates_files(self, tmp, section_date, released_at): + section = os.path.join(tmp, "section.md") + message = os.path.join(tmp, "msg.txt") + with open(section, "w", encoding="utf-8") as fh: + fh.write(f"## [1.3.0] - {section_date}\n\n### Added\n\n- A thing.\n") + with open(message, "w", encoding="utf-8") as fh: + fh.write(f"## [1.3.0] - {section_date}\n\n- A thing.\n\n" + f"Released-at: {released_at}\n") + return section, message + + def test_dates_match_exits_0_when_the_two_dates_agree(self): + with tempfile.TemporaryDirectory() as tmp: + section, message = self._dates_files(tmp, "2026-07-31", "2026-07-31") + self.assertEqual(core_releaselib.main(["dates-match", section, message]), 0) + + def test_dates_match_exits_1_when_the_dates_disagree(self): + # The defect the guard exists for: a hand-typed second date. + with tempfile.TemporaryDirectory() as tmp: + section, message = self._dates_files(tmp, "2026-07-31", "2026-01-01") + self.assertEqual(core_releaselib.main(["dates-match", section, message]), 1) + + def test_dates_match_exits_1_on_an_unreadable_file_never_a_traceback(self): + # Same contract as notes-match: an unreadable file is empty text, + # which has no date, so the comparison is False and the exit is 1. + # A traceback here would break a `set -e` lane with no diagnosis. + with tempfile.TemporaryDirectory() as tmp: + _section, message = self._dates_files(tmp, "2026-07-31", "2026-07-31") + missing = os.path.join(tmp, "does-not-exist.md") + self.assertEqual(core_releaselib.main(["dates-match", missing, message]), 1) + + # `semver-greater` (run 6). The hard rules say the version MUST NOT be + # guessed, yet the bump arithmetic and the strictly-greater assertion + # were both hand-done: `semver_greater` was public API reachable only + # by import. This is also the mechanism the manifest-FLOOR check needs + # (HIGH, run 6). + def test_semver_greater_exits_0_when_strictly_greater(self): + self.assertEqual( + core_releaselib.main(["semver-greater", "1.5.0", "1.4.2"]), 0) + + def test_semver_greater_exits_1_when_lesser(self): + # The exact defect: a never-tagged project deriving 0.1.0 against a + # manifest already at 1.4.2. + self.assertEqual( + core_releaselib.main(["semver-greater", "0.1.0", "1.4.2"]), 1) + + def test_semver_greater_exits_1_when_equal_not_0(self): + # "Strictly" is the whole point -- re-releasing the same version is + # the case an inclusive comparison would wave through. + self.assertEqual( + core_releaselib.main(["semver-greater", "1.4.2", "1.4.2"]), 1) + + def test_semver_greater_exits_2_on_an_unparseable_version(self): + # Distinct from exit 1. `semver_greater` is non-raising and returns + # False for garbage, which is fail-closed but indistinguishable + # from a real "not greater" -- the same conflation the exit-3-vs-4 + # work already removed from the declared-file path. + import io, contextlib + for argv in (["semver-greater", "nope", "1.4.2"], + ["semver-greater", "1.4.2", "nope"]): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = core_releaselib.main(argv) + self.assertEqual(rc, 2, argv) + self.assertIn("not valid SemVer", err.getvalue()) + + # ---- A-2.4: the declared-value cap (ADR-0002 precedent) ---- + @staticmethod + def _block(lines): + """Wrap declaration lines in the delimiter block, so a cap test + exercises the REAL parser entry point rather than an internal.""" + return ("\n" + "\n".join(lines) + + "\n\n") + + def test_pre_tag_cap_rejects_an_over_long_value(self): + block = self._block([ + "[app]", "prefix: v", "changelog: CHANGELOG.md", "payload: .", + "pre-tag: " + "A" * (core_releaselib.VALUE_MAX_CHARS + 1)]) + with self.assertRaises(core_releaselib.ValueTooLongError): + core_releaselib.parse_release_targets(block) + + def test_pre_tag_cap_admits_a_value_exactly_at_the_limit(self): + # Off-by-one in the boundary is the whole risk of a cap: a `>=` + # would reject a legitimate 1024-char value, a `>` on the wrong + # side would admit 1025. + block = self._block([ + "[app]", "prefix: v", "changelog: CHANGELOG.md", "payload: .", + "pre-tag: " + "A" * core_releaselib.VALUE_MAX_CHARS]) + rows = core_releaselib.parse_release_targets(block) + self.assertEqual(len(rows[0]["pre_tag"][0]), core_releaselib.VALUE_MAX_CHARS) + + def test_pre_tag_cap_applies_to_every_key_not_only_pre_tag(self): + # The cap exists because these values are operator-authored input a + # `contents: write` lane later executes or interpolates. `rebuild` + # and `generate` are executed exactly as `pre-tag` is, so capping + # only the key that motivated the rule would leave the identical + # exposure one field over. + for key in ("rebuild", "generate", "payload", "prefix"): + with self.subTest(key=key): + block = self._block([ + "[app]", "prefix: v", "changelog: CHANGELOG.md", "payload: .", + f"{key}: " + "A" * (core_releaselib.VALUE_MAX_CHARS + 1)]) + with self.assertRaises(core_releaselib.ValueTooLongError): + core_releaselib.parse_release_targets(block) + + def test_value_too_long_is_a_declared_file_error_exiting_4_not_3(self): + # Exit 3 is the Back-fill lane's ONE trigger. An over-long value is + # a malformed declaration on a file that plainly exists; routing it + # to 3 would send the lane to overwrite that file. + self.assertIsInstance( + core_releaselib.ValueTooLongError("x"), core_releaselib.ReleaseTargetsError) + self.assertNotIsInstance( + core_releaselib.ValueTooLongError("x"), core_releaselib.AbsentBlockError) + self.assertEqual( + core_releaselib._targets_error_exit_code( + core_releaselib.ValueTooLongError("x")), 4) + + # ---- run-pre-tag: the tree assertion is "changed nothing NEW" ---- + def _pretag_repo(self, tmp, commands, dirty=False): + """A consumer with `commands` declared, optionally left with the + uncommitted manifest/changelog edits Phase 1 makes BEFORE this step + runs -- which is the normal lane, not an edge case.""" + root = os.path.join(tmp, "consumer") + os.makedirs(os.path.join(root, ".codearbiter")) + with open(os.path.join(root, "package.json"), "w") as fh: + fh.write('{"version": "1.0.0"}\n') + with open(os.path.join(root, "CHANGELOG.md"), "w") as fh: + fh.write("# Changelog\n") + with open(os.path.join(root, ".codearbiter", "release-targets.md"), "w") as fh: + fh.write("\n[app]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + + "".join(f"pre-tag: {c}\n" for c in commands) + + "\n") + # HERMETIC and CHECKED. Unconfigured `subprocess.run(["git"] + argv)` + # inherits the developer's global config and ignores the exit code, + # so a host with `commit.gpgsign=true`, a `core.hooksPath`, or a + # commit template can make `commit` fail while this helper returns a + # fixture that merely LOOKS built -- and the test then asserts + # against a repo with no commit in it, passing or failing for a + # reason that has nothing to do with the code under test. + env = dict(os.environ, + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull, + GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@t", + GIT_COMMITTER_NAME="t", GIT_COMMITTER_EMAIL="t@t") + base = ["git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=", + "-c", "init.defaultBranch=main"] + for argv in (["init", "-q"], + ["add", "package.json", "CHANGELOG.md", + ".codearbiter/release-targets.md"], + ["commit", "-q", "-m", "init", "--no-verify"]): + proc = subprocess.run(base + argv, cwd=root, env=env, + capture_output=True, text=True) + self.assertEqual( + proc.returncode, 0, + f"fixture git {argv[0]} failed: {proc.stderr.strip()}") + if dirty: + with open(os.path.join(root, "package.json"), "w") as fh: + fh.write('{"version": "1.1.0"}\n') + with open(os.path.join(root, "CHANGELOG.md"), "w") as fh: + fh.write("# Changelog\n\n## [1.1.0] - 2026-07-31\n") + return root + + def _run_pre_tag(self, root): + env = dict(os.environ, CLAUDE_PROJECT_DIR=root, PYTHONDONTWRITEBYTECODE="1") + return subprocess.run( + [sys.executable, _CORE_RELEASELIB_PATH, "run-pre-tag", "app"], + cwd=tempfile.gettempdir(), env=env, capture_output=True, text=True) + + def test_run_pre_tag_tolerates_the_release_edits_that_precede_it(self): + # HIGH, run 9: the assertion used to be "the tree is pristine", + # which BLOCKED EVERY RELEASE -- Phase 1 rolls the changelog and + # bumps the manifest before this step, and it must, because a badge + # check compares a surface against the NEW version. + with tempfile.TemporaryDirectory() as tmp: + root = self._pretag_repo(tmp, ['python -c "print(1)"'], dirty=True) + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 0, proc.stderr) + + def test_run_pre_tag_exits_0_with_no_declared_commands_on_a_dirty_tree(self): + # The starkest form of the same defect: a row declaring NOTHING + # still exited 6. + with tempfile.TemporaryDirectory() as tmp: + root = self._pretag_repo(tmp, [], dirty=True) + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 0, proc.stderr) + + def test_run_pre_tag_still_catches_a_mutation_on_top_of_those_edits(self): + # The property the loosening must NOT cost: a declared command's + # own writes still surface, even when the tree was already dirty. + with tempfile.TemporaryDirectory() as tmp: + root = self._pretag_repo( + tmp, ['python -c "open(\'sneaky.txt\',\'w\').write(\'x\')"'], + dirty=True) + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 6, proc.stdout + proc.stderr) + self.assertIn("sneaky.txt", proc.stderr) + # Only the NEW path is reported, not the operator's own edits. + self.assertNotIn("package.json", proc.stderr) + + # ---- A-4.2: name-keyed target selection ---- + TARGETS = ["ca", "ca-codex", "ca-sandbox", "ca-pi"] + + def test_select_target_name_keyed_resolves_the_named_target(self): + self.assertEqual( + core_releaselib.select_release_target_by_name( + ["ca-pi=0.1.44", "ca=", "ca-codex="], self.TARGETS), + "ca-pi") + + def test_select_target_name_keyed_is_immune_to_row_order(self): + # THE defect this closes. The positional resolver aligns + # confirmations to `targets` by INDEX, so it is correct only while + # the workflow's input order and the declared file's row order + # agree -- and nothing enforced that. Insert one row at the front + # of the declared file and every confirmation shifts by one: a + # dispatch meaning to publish `ca-codex` publishes `ca` instead, + # holding a contents:write token, and every downstream check passes + # because the WRONG release is internally consistent. + shifted = ["NEW"] + self.TARGETS + self.assertEqual( + core_releaselib.select_release_target( + "", "2.4.0", "", "", targets=self.TARGETS), + "ca-codex") + self.assertEqual( + core_releaselib.select_release_target( + "", "2.4.0", "", "", targets=shifted[:4]), + "ca", + "if this stops being 'ca', the positional hazard changed shape " + "and this test no longer demonstrates what it claims") + # Same intent, name-keyed, against BOTH registers: + for register in (self.TARGETS, shifted): + self.assertEqual( + core_releaselib.select_release_target_by_name( + ["ca-codex=2.4.0"], register), + "ca-codex") + + def test_select_target_name_keyed_refuses_a_multi_target_dispatch(self): + # One dispatch, one publisher. Two confirmations must never start + # two contents:write jobs. + self.assertEqual( + core_releaselib.select_release_target_by_name( + ["ca=1.0.0", "ca-pi=2.0.0"], self.TARGETS), + "multiple") + + def test_select_target_name_keyed_treats_blank_as_not_selected(self): + for pairs in (["ca="], ["ca= "], ["ca=", "ca-pi="], []): + self.assertEqual( + core_releaselib.select_release_target_by_name(pairs, self.TARGETS), + "none", pairs) + + def test_select_target_name_keyed_reports_an_undeclared_name(self): + # Distinct from `none`: a fail-closed caller refuses both, but only + # this one means the workflow and the declared file disagree, which + # is worth reporting rather than passing off as an empty dispatch. + self.assertEqual( + core_releaselib.select_release_target_by_name( + ["nope=1.0.0"], self.TARGETS), + "unknown") + self.assertNotEqual("unknown", "none") + + def test_select_target_name_keyed_ignores_a_pair_with_no_equals(self): + # An empty workflow input can arrive as a bare name; that is a + # missing value, not a malformed dispatch. + self.assertEqual( + core_releaselib.select_release_target_by_name( + ["bare-name"], self.TARGETS), + "none") + + def test_select_target_name_keyed_never_raises_on_junk(self): + for pairs in (None, 42, ["ca=1.0.0", None, 7], "ca=1.0.0"): + result = core_releaselib.select_release_target_by_name( + pairs, self.TARGETS) + self.assertIn(result, self.TARGETS + ["none", "multiple", "unknown"]) + + def test_select_target_name_keyed_agrees_with_the_declared_register(self): + # Against this repo's real declared names, so a rename in the + # declared file surfaces here rather than at dispatch time. + rows = core_releaselib.load_targets( + os.path.join(REPO_ROOT, ".codearbiter", "release-targets.md")) + names = [r["target"] for r in rows] + for name in names: + with self.subTest(target=name): + self.assertEqual( + core_releaselib.select_release_target_by_name( + [f"{name}=1.2.3"], names), + name) + + # ---- Slice 3: which assertions a row's declared fields turn on ---- + ROW_FULL = { + "target": "app", "prefix": "v", "changelog": "CHANGELOG.md", + "payload": "plugins/app/", "manifest": ["plugins/app/plugin.json"], + "rebuild": "cd plugins/app/tools && npm run build", + "artifacts": ["plugins/app/tools/bundle.js"], + "payload_exclude": ["plugins/app/tools/"], + "provenance_manifest": ".github/published-tags.json", + } + ROW_BARE = { + "target": "bare", "prefix": "v", "changelog": "CHANGELOG.md", + "payload": ".", + } + + def test_manifest_declared_asserts_equality(self): + # A-3.1. A declared manifest means the lane compares versions. + verdict = core_releaselib.row_assertions(self.ROW_FULL) + self.assertTrue(verdict["assert_manifest_equal"]) + self.assertEqual(verdict["version_source"], "manifest") + self.assertEqual(verdict["manifests"], ["plugins/app/plugin.json"]) + self.assertNotIn("manifest-equality", + [name for name, _reason in verdict["skipped"]]) + + def test_manifest_declared_covers_every_path_not_just_the_first(self): + # A row may declare several; asserting only the first is the + # partial-bump hole run 12 found from the other direction. + row = dict(self.ROW_FULL, manifest=["a/plugin.json", "b/package.json"]) + self.assertEqual(core_releaselib.row_assertions(row)["manifests"], + ["a/plugin.json", "b/package.json"]) + + def test_manifest_absent_uses_the_tag_as_version_source(self): + # A-3.2. No manifest is a legitimate consumer shape, not an error: + # the derived tag IS the version, and there is nothing to compare. + verdict = core_releaselib.row_assertions(self.ROW_BARE) + self.assertFalse(verdict["assert_manifest_equal"]) + self.assertEqual(verdict["version_source"], "tag") + self.assertEqual(verdict["manifests"], []) + + def test_manifest_absent_is_reported_as_skipped_not_silently_dropped(self): + # The skill's own rule: a skipped step and a forgotten step must + # never look alike. Absence is an operator decision, so it is named. + skipped = dict(core_releaselib.row_assertions(self.ROW_BARE)["skipped"]) + self.assertIn("manifest-equality", skipped) + self.assertIn("no manifest", skipped["manifest-equality"]) + + def test_version_source_and_manifest_assertion_cannot_disagree(self): + # The reason these are ONE derivation. Two independent checks could + # report "tag is the version source" while also asserting manifest + # equality; a single derivation structurally cannot. + for row in (self.ROW_FULL, self.ROW_BARE, {}, + dict(self.ROW_FULL, manifest=[])): + verdict = core_releaselib.row_assertions(row) + self.assertEqual( + verdict["assert_manifest_equal"], + verdict["version_source"] == "manifest", row.get("target")) + + def test_rebuild_artifacts_are_reported_together(self): + # A-3.3. `rebuild` runs, then every `artifacts` entry is asserted + # clean; a stale bundle blocks. + verdict = core_releaselib.row_assertions(self.ROW_FULL) + self.assertEqual(verdict["rebuild"], + "cd plugins/app/tools && npm run build") + self.assertEqual(verdict["artifacts"], ["plugins/app/tools/bundle.js"]) + + def test_rebuild_artifacts_absent_are_named_as_skipped(self): + skipped = dict(core_releaselib.row_assertions(self.ROW_BARE)["skipped"]) + self.assertIn("rebuild", skipped) + self.assertIn("artifacts-clean", skipped) + + def test_provenance_optional_absent_is_skipped_and_reported(self): + # A-3.5. Absent means the recording step is skipped AND the report + # says so -- silence here reads as "forgotten", which is the exact + # ambiguity this field's optionality creates. + verdict = core_releaselib.row_assertions(self.ROW_BARE) + self.assertFalse(verdict["record_provenance"]) + self.assertIsNone(verdict["provenance_manifest"]) + self.assertIn("provenance-recording", + [name for name, _reason in verdict["skipped"]]) + + def test_provenance_optional_present_records(self): + verdict = core_releaselib.row_assertions(self.ROW_FULL) + self.assertTrue(verdict["record_provenance"]) + self.assertEqual(verdict["provenance_manifest"], + ".github/published-tags.json") + + def test_row_assertions_never_raises_on_junk(self): + for junk in (None, 42, "row", [], {"manifest": 7, "artifacts": None}): + verdict = core_releaselib.row_assertions(junk) + self.assertEqual(verdict["version_source"], "tag") + self.assertEqual(verdict["manifests"], []) + + def test_payload_exclude_removes_paths_under_the_excluded_directory(self): + # A-3.4, against ca-pi's real shape: tools/ ships neither generated + # policy nor a built runtime artifact, so a change there must not + # gate the release. + kept = core_releaselib.window_excludes_payload_paths( + ["plugins/ca-pi/extensions/codearbiter.js", + "plugins/ca-pi/tools/build.mjs", + "plugins/ca-pi/package.json"], + "plugins/ca-pi/", ["plugins/ca-pi/tools/"]) + self.assertEqual(kept, ["plugins/ca-pi/extensions/codearbiter.js", + "plugins/ca-pi/package.json"]) + + def test_payload_exclude_is_a_path_prefix_not_a_substring(self): + # "tools" must not swallow "toolsmith/". A substring test would + # silently drop a sibling directory whose name merely starts the + # same way -- a dropped commit, not a loud failure. + kept = core_releaselib.window_excludes_payload_paths( + ["a/toolsmith/x.js", "a/tools/y.js"], "a", ["a/tools"]) + self.assertEqual(kept, ["a/toolsmith/x.js"]) + + def test_payload_exclude_scopes_to_the_payload_first(self): + # A sibling target's paths are outside this row's payload and must + # not enter its window at all. + kept = core_releaselib.window_excludes_payload_paths( + ["plugins/ca-pi/a.js", "plugins/ca/b.py"], "plugins/ca-pi/", []) + self.assertEqual(kept, ["plugins/ca-pi/a.js"]) + + def test_payload_exclude_whole_repo_payload_keeps_everything(self): + # A consumer's `payload: .` scopes to the whole tree. + kept = core_releaselib.window_excludes_payload_paths( + ["src/a.py", "docs/b.md"], ".", []) + self.assertEqual(kept, ["src/a.py", "docs/b.md"]) + + def test_payload_exclude_matches_this_repos_declared_ca_pi_row(self): + # The spec names ca-pi's tools/ exclusion specifically, so assert + # against the DECLARED row rather than a synthetic one. + rows = {r["target"]: r for r in core_releaselib.load_targets( + os.path.join(REPO_ROOT, ".codearbiter", "release-targets.md"))} + verdict = core_releaselib.row_assertions(rows["ca-pi"]) + self.assertEqual(verdict["payload_exclude"], ["plugins/ca-pi/tools/"]) + self.assertEqual(len(verdict["manifests"]), 2) + + # ---- A-2.10: pre-tag content-hash confirmation (releasehash.py) ---- + def _releasehash(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "_rh_under_test", os.path.join(REPO_ROOT, "core", "pysrc", "releasehash.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_pre_tag_hash_is_order_sensitive(self): + # The commands run in DECLARED ORDER, and a check that ran after a + # rebuild is not the same check as one that ran before it. A + # reorder must therefore invalidate the confirmation, not preserve + # it -- an order-insensitive digest (sorted, or a set) would call a + # reordered lane "confirmed". + rh = self._releasehash() + self.assertNotEqual( + rh.pre_tag_digest(["check_a.py", "check_b.py"]), + rh.pre_tag_digest(["check_b.py", "check_a.py"])) + + def test_pre_tag_hash_cannot_collide_across_a_join(self): + # The length prefix's ACTUAL job, found by a surviving mutant: my + # first version of this test used ["ab","c"] vs ["a","bc"], which + # already differ once joined on a newline, so removing the prefix + # left the test green. The collision the prefix really prevents + # needs a command CONTAINING the separator: + # + # ["a\n b"] -> "1\na\nb" (one command, embedded newline) + # ["a", "b"] -> "1\na\nb" (two commands) + # + # Identical without the prefix. That is not academic here: these + # are shell command strings, and a multi-line command in a declared + # row would let a two-command list masquerade as the one-command + # list an operator confirmed. + rh = self._releasehash() + self.assertNotEqual(rh.pre_tag_digest(["a\nb"]), + rh.pre_tag_digest(["a", "b"])) + # And the plain adjacent-content case, kept for completeness. + self.assertNotEqual(rh.pre_tag_digest(["ab", "c"]), + rh.pre_tag_digest(["a", "bc"])) + + def test_pre_tag_hash_never_raises_on_junk(self): + # Mechanism invariant: this runs inside a release lane, so a + # malformed row must degrade rather than raise mid-release. + rh = self._releasehash() + for junk in (None, 42, "a string", [None, 7], {}): + self.assertEqual(len(rh.pre_tag_digest(junk)), 64) + + def test_pre_tag_hash_state_machine_over_a_real_marker(self): + rh = self._releasehash() + with tempfile.TemporaryDirectory() as root: + original = ["python3 checks/a.py"] + self.assertEqual( + rh.confirmation_state(root, "app", original), rh.NEVER) + rh.record_confirmation(root, "app", rh.pre_tag_digest(original)) + self.assertEqual( + rh.confirmation_state(root, "app", original), rh.CONFIRMED) + # THE case this exists for: the executed commands changed after + # somebody confirmed them. + self.assertEqual( + rh.confirmation_state(root, "app", ["python3 checks/evil.py"]), + rh.CHANGED) + # Appending is a change too -- a lane that only compared the + # first entry would admit a smuggled second command. + self.assertEqual( + rh.confirmation_state(root, "app", original + ["python3 x.py"]), + rh.CHANGED) + + def test_pre_tag_hash_confirmation_is_per_target(self): + # A multi-target repo confirming one row must not thereby confirm + # its siblings -- that would let a target's commands change under + # a confirmation earned by a different target entirely. + rh = self._releasehash() + with tempfile.TemporaryDirectory() as root: + commands = ["python3 checks/a.py"] + rh.record_confirmation(root, "ca", rh.pre_tag_digest(commands)) + self.assertEqual(rh.confirmation_state(root, "ca", commands), rh.CONFIRMED) + self.assertEqual(rh.confirmation_state(root, "ca-pi", commands), rh.NEVER) + + def test_pre_tag_hash_reports_no_commands_distinctly_from_confirmed(self): + # A row declaring nothing has nothing to confirm. Reporting it as + # "confirmed" would claim an operator approved content that does + # not exist; both proceed, but only one is a claim about review. + rh = self._releasehash() + with tempfile.TemporaryDirectory() as root: + self.assertEqual(rh.confirmation_state(root, "app", []), rh.NO_COMMANDS) + self.assertNotEqual(rh.NO_COMMANDS, rh.CONFIRMED) + + def test_pre_tag_hash_unreadable_marker_reads_as_never_confirmed(self): + # Conservative direction: an empty or unreadable marker prompts + # rather than admits. + rh = self._releasehash() + with tempfile.TemporaryDirectory() as root: + path = rh.confirmation_path(root, "app") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(" " + os.linesep) + self.assertEqual( + rh.confirmation_state(root, "app", ["python3 a.py"]), rh.NEVER) + + def test_show_row_prints_every_declared_field(self): + # Blind exercise run 14, HIGH. The lane forbids reading the declared + # file by eye, but only `prefix` and the target names had readers — + # so nine fields could ONLY be obtained the forbidden way, and an + # exercising agent did exactly that and said so. + out = self._run_core("show-row", "ca") + self.assertEqual(out.returncode, 0, out.stderr) + keys = dict(line.split("=", 1) for line in out.stdout.splitlines() if "=" in line) + for expected in ("TARGET", "TAG_PREFIX", "MANIFEST", "CHANGELOG", "PAYLOAD", + "ARTIFACTS", "PRE_TAG", "PROVENANCE_MANIFEST", + "LATEST_ELIGIBLE", "GENERATED_MANIFEST", "GENERATE", + "PAYLOAD_EXCLUDE", "REBUILD", "DISPLAY_NAME"): + self.assertIn(expected, keys, out.stdout) + self.assertEqual(keys["TARGET"], "ca") + self.assertEqual(keys["TAG_PREFIX"], "v") + # An UNDECLARED field prints empty rather than vanishing, so a + # caller can tell "not declared" from "I did not look". Shell-quoted, + # so an empty value is the two-character `''`. + self.assertIn(keys["GENERATED_MANIFEST"], ("", "''")) + + def test_show_row_output_is_shell_quoted_and_eval_executes_nothing(self): + # Blind exercise run 15, HIGH. Unquoted output made the documented + # `eval "$(show-row ...)"` EXECUTE declared values: `rebuild: cd x && + # npm run build` parsed as `REBUILD=cd` plus the command `x`, with + # `&& npm run build` next -- and eval still exited 0. These values + # are operator shell the lane runs only AFTER releasehash confirms a + # human read them, so executing a fragment at row-read time runs it + # BEFORE its own gate. + out = self._run_core("show-row", "ca") + rebuild = next(line for line in out.stdout.splitlines() + if line.startswith("REBUILD=")) + self.assertIn("&&", rebuild, "fixture must carry a shell metachar") + self.assertTrue(rebuild.startswith("REBUILD='"), + f"value must be shell-quoted, got: {rebuild}") + # The decisive check, and it runs on EVERY platform: POSIX-tokenize + # each emitted line and require it to be exactly ONE word. A single + # word is an assignment and nothing else -- there is no room after + # it for the `&&`, `;` or bare command that the run-15 finding had + # `eval` execute. Doing this with `shlex` rather than a real shell + # keeps the security property asserted where no usable `bash` + # exists (see `working_bash`), instead of silently skipping it on + # the platform whose quoting is most likely to differ. + for line in out.stdout.splitlines(): + if not line.strip(): + continue + words = shlex.split(line) + self.assertEqual( + len(words), 1, + f"row line tokenizes to {len(words)} words, so something " + f"after the assignment would run: {line!r}") + name, sep, _ = words[0].partition("=") + self.assertEqual(sep, "=", f"not an assignment: {line!r}") + self.assertRegex(name, r"\A[A-Z_][A-Z0-9_]*\Z", + f"assignment target is not a plain shell " + f"variable name: {line!r}") + # Corroborating arm: hand the whole block to a real shell and prove + # it assigns without executing. Skipped where no working shell + # exists, which is why the tokenizer check above is not optional. + bash = working_bash() + if bash is None: + self.skipTest("no working bash on this platform for the " + "shell round-trip arm") + script = (out.stdout + "\n" + + 'printf "%s|%s|%s" "$TAG_PREFIX" "$REBUILD" "$PRE_TAG"\n') + # Fed on STDIN, not `bash -c