From 7de8b7f44a7519d77cb6ddb424646cb88a26748d Mon Sep 17 00:00:00 2001 From: Remigiusz Antczak Date: Wed, 28 Oct 2026 21:34:15 +0100 Subject: [PATCH] Flow guards review fixes: real fitness_tick caller, default baseline checks, narrow wired allowlist, freeze_hook, one canonical reference fitness_tick closable needs executable wiring (JSON, CI, .sh, Makefile, loop entry line), not a mention; baseline_guard refuses by default (--force overrides); wired_check ALLOW_RE matches explicit orphan/unused allowlist names, a # orphan: marker and an allowlist: key; freeze_hook.py parses argv and denies git push, gh pr merge and gh api merge calls while frozen (fail closed on malformed input or unreadable state, allow in a non-repo), wired in the operating-layer template and doctor; finding_guard/fitness_tick/flow_alarm doctrine lives once in references/flow-guards.md. Tests: 43/43 (flow_guard_fixes 15, fitness_tick 6, baseline_guard 5, orphan_allowlist_wired 4, freeze_gate 6, skill_index_split 7) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/INDEX.md | 2 +- .../skills/agentic-delivery/INDEX-scripts.md | 3 +- .claude/skills/agentic-delivery/INDEX.md | 9 +- .../references/flow-guards.md | 11 ++ .../agentic-delivery/references/full-map.md | 1 + .../references/red-gate-triage.md | 2 +- .../references/unattended-operating-mode.md | 2 +- .../scripts/baseline_guard.py | 19 +- .../agentic-delivery/scripts/fitness_tick.py | 29 +++- .../agentic-delivery/scripts/freeze_hook.py | 108 ++++++++++++ .../agentic-delivery/scripts/wired_check.py | 3 +- .../templates/operating-layer.settings.json | 10 ++ changelog.d/wave-al-flow-guard-fixes.md | 6 + scripts/ci-tests.d/flow-guard-fixes.sh | 1 + scripts/perun_doctor.py | 2 +- scripts/test_baseline_guard.py | 2 +- scripts/test_flow_guard_fixes.py | 164 ++++++++++++++++++ scripts/test_skill_index_split.py | 2 +- 18 files changed, 353 insertions(+), 23 deletions(-) create mode 100644 .claude/skills/agentic-delivery/references/flow-guards.md create mode 100644 .claude/skills/agentic-delivery/scripts/freeze_hook.py create mode 100644 changelog.d/wave-al-flow-guard-fixes.md create mode 100644 scripts/ci-tests.d/flow-guard-fixes.sh create mode 100644 scripts/test_flow_guard_fixes.py diff --git a/.claude/skills/INDEX.md b/.claude/skills/INDEX.md index e2a9a5f58..a52d1800f 100644 --- a/.claude/skills/INDEX.md +++ b/.claude/skills/INDEX.md @@ -106,7 +106,7 @@ Run `python3 /scripts/` (`bash` for `.sh`). `name: purpose — argu - vacuous_test_gate.py: a changed test file must really run and assert. — [REPORT] (--changed PATH... | --base… - vendor_drift.py: list vendored skill files in TARGET that differ from the… — TARGET [--src DIR] - wired_check.py: warn when a NEW script has no caller. — BASE HEAD [--root DIR] -- No documented CLI: _clean_union.sh, _dirty_guard.sh, _lock.sh, admission_check.py, board_common.py, bypass_guard.py, ceo_altitude_guard.py, comms_guard.py, context_nudge.py, cost_statusline.py, decision_gate.py, destructive_guard.py, destructive_guard_core.py, finding_guard.py, flake_ledger.py, gh_rest.py, handback_cap.py, handoff_inject.py, heavy_gate.py, host_accept.py, host_installer.py, host_safety.py, landing_watchdog.py, lane_cap.py, lane_resume_nudge.py, lkg.py, outcome_claim_guard.py, peer_ack.py, perun_auto_update.py, pipe_mask_guard.py, pipeline_profile.py, plan_gate.py, pregate_parity.py, red_main.py, repeat_poll_guard.py, reply_cap.py, restore_ledger.py, scope_creep_check.py, session_brief.py, stall_guard.py, subagent_brief_lint.py, surface_check.py, terse_reply_check.py +- No documented CLI: _clean_union.sh, _dirty_guard.sh, _lock.sh, admission_check.py, board_common.py, bypass_guard.py, ceo_altitude_guard.py, comms_guard.py, context_nudge.py, cost_statusline.py, decision_gate.py, destructive_guard.py, destructive_guard_core.py, finding_guard.py, flake_ledger.py, freeze_hook.py, gh_rest.py, handback_cap.py, handoff_inject.py, heavy_gate.py, host_accept.py, host_installer.py, host_safety.py, landing_watchdog.py, lane_cap.py, lane_resume_nudge.py, lkg.py, outcome_claim_guard.py, peer_ack.py, perun_auto_update.py, pipe_mask_guard.py, pipeline_profile.py, plan_gate.py, pregate_parity.py, red_main.py, repeat_poll_guard.py, reply_cap.py, restore_ledger.py, scope_creep_check.py, session_brief.py, stall_guard.py, subagent_brief_lint.py, surface_check.py, terse_reply_check.py ### business-ops - bizops_check.py: Deterministic arithmetic checks business-ops already states… — [input.json] (stdin when no path) diff --git a/.claude/skills/agentic-delivery/INDEX-scripts.md b/.claude/skills/agentic-delivery/INDEX-scripts.md index 3cdcd9692..1cab38272 100644 --- a/.claude/skills/agentic-delivery/INDEX-scripts.md +++ b/.claude/skills/agentic-delivery/INDEX-scripts.md @@ -8,7 +8,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/_dirty_guard.sh` | Sourced by scripts that run git checkout\|switch\|reset on a caller-given tree. | — | | `scripts/_lock.sh` | Sourced by train scripts. | — | | `scripts/admission_check.py` | flag a per-PR admission config that omits a test class the batch (merge train) runs, and a stale sp… | — | -| `scripts/baseline_guard.py` | store a test baseline only when it is not poisoned by load. | baseline_guard.py --fails FILE --rerun 'CMD {id}' --out BASELINE.json [--normal N] [--max-factor F] [--max-load L] | +| `scripts/baseline_guard.py` | store a test baseline only when it is not poisoned by load. | baseline_guard.py --fails FILE --rerun 'CMD {id}' --out BASELINE.json [--normal N] [--max-factor F] [--max-load L] [--f… | | `scripts/bg_admit.py` | admission for background and shadow jobs, plus a lint for mktemp without a cleanup trap. | bg_admit.py admit SCRIPT... | | `scripts/board_common.py` | shared post grammar and forge I/O for the board_* scripts. | — | | `scripts/board_post.py` | compose one typed, capped, privacy-linted coordination post. | board_post.py --repo O/N --issue N --agent ID --type CLAIM --refs '#12' \ --body-file note.md [--ttl 90] [--post] board… | @@ -38,6 +38,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/focus_gate.py` | no work outside the owner's open priority until that priority is done or blocked. | focus_gate.py status [common options] [--evidence FILE] focus_gate.py check [common options] [--item REF] [--paths P ..… | | `scripts/focus_kit.py` | keep agents on task across long sessions and compactions: handoff, memory lint, docs map. | focus_kit.py handoff set\|add SECTION TEXT \| memory-lint [DIR] \| docs-index [--check] \| measure | | `scripts/freeze_gate.py` | while a freeze is on, allow only deletions and red-trunk fixes. | freeze_gate.py BASE HEAD | +| `scripts/freeze_hook.py` | PreToolUse(Bash) hook: while a freeze is on, deny git push and gh pr merge. | — | | `scripts/gh_rest.py` | curl REST fallback for gh api when gh's Go TLS fails inside a macOS sandbox. | — | | `scripts/handback.py` | lane handback, gates to merge | handback.py --sha SHA --base REF --branch BRANCH --gate-cmd CMD [--gate-cmd CMD ...] | | `scripts/handback_cap.py` | SubagentStop hook: cap a subagent handback, log how far it is from fields-only, and audit each lane… | — | diff --git a/.claude/skills/agentic-delivery/INDEX.md b/.claude/skills/agentic-delivery/INDEX.md index 24363b2c6..de6dc2e59 100644 --- a/.claude/skills/agentic-delivery/INDEX.md +++ b/.claude/skills/agentic-delivery/INDEX.md @@ -10,8 +10,9 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/dev-env-ownership.md` | booting a dev server in a lane, separating the serving tree from the committing tree, re-running a… | 8423 | | `references/fanout-host-sizing.md` | sizing a fan-out or capping in-flight write lanes, cutting per-lane cycle-time or handback verbosit… | 12456 | | `references/fast-agentic-delivery.md` | sizing a heavy-lane fan-out on a shared machine (the full environment-probe procedure and the fan-o… | 1703 | +| `references/flow-guards.md` | wiring or tuning the issue-dedupe hook, the per-fix fitness check, or the delivery-flow alarms. | 526 | | `references/focus-kit.md` | a session runs long or compacts, a new session must resume the work, or memory files and docs grow… | 1308 | -| `references/full-map.md` | a delivery needs more than the SKILL.md fast path: the full gate table with every hard condition, t… | 6122 | +| `references/full-map.md` | a delivery needs more than the SKILL.md fast path: the full gate table with every hard condition, t… | 6149 | | `references/gate-epistemology.md` | adding a gate (4) | 2950 | | `references/host-enforcement.md` | claiming that state, permissions, or spend are *enforced* rather than merely followed, briefing a w… | 11228 | | `references/incident-response.md` | drafting an incident runbook, defining severity levels or status/comms templates, setting up a brea… | 3264 | @@ -22,14 +23,14 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/pipeline-optimizer.md` | a delivery pipeline is slow (a commit or push hook over about a minute, CI over about ten minutes,… | 4870 | | `references/production-grade-product-playbook.md` | you are shaping a user-facing product surface in G3 Design, so you build to the domain-P quality ba… | 1658 | | `references/project-state.md` | beginning multi-step delivery, changing objectives, transferring ownership, checkpointing, or recov… | 1885 | -| `references/red-gate-triage.md` | a gate, union, or CI check is red and you must decide whether it is real: attributing a red union (… | 7169 | +| `references/red-gate-triage.md` | a gate, union, or CI check is red and you must decide whether it is real: attributing a red union (… | 7133 | | `references/retrospective.md` | running G10, or when a target's own incident/postmortem practice is in scope for a review. | 1273 | | `references/roles.md` | standing up (or running) the full delivery team on a repo: who does what, when a hat fires, which g… | 6830 | | `references/support-ops.md` | the product has users who file tickets, bug reports, or feature requests, and you need a support sy… | 1113 | | `references/template-adr.md` | G3 Design must record a decision — an interface, NFR budget, or data/security choice — as an ADR (c… | 660 | | `references/template-onepager.md` | G0 Intake turns a vague idea into a brief the owner can approve in one reply, or when /idea drafts… | 416 | | `references/template-postmortem.md` | G10 or an incident meets retrospective.md's mandatory-trigger criteria and you must write the blame… | 879 | -| `references/unattended-operating-mode.md` | an agent or a Conductor is granted a block of unattended time to work a backlog — an overnight or m… | 6980 | +| `references/unattended-operating-mode.md` | an agent or a Conductor is granted a block of unattended time to work a backlog — an overnight or m… | 6709 | | `references/unattended-trackers.md` | deciding whether an open issue's fix is absent, placing an auto-close keyword or a gate-parsed PR-b… | 11803 | | `references/verification-handback.md` | delegating visual/parity work by measured number, running or finalizing verification, writing a lan… | 15206 | | `references/work-tracking.md` | a repo uses a work tracker (Linear first-class | 1051 | @@ -37,4 +38,4 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing ## Scripts -106 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### agentic-delivery`, has only each one-line purpose and arguments. +107 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### agentic-delivery`, has only each one-line purpose and arguments. diff --git a/.claude/skills/agentic-delivery/references/flow-guards.md b/.claude/skills/agentic-delivery/references/flow-guards.md new file mode 100644 index 000000000..228fcbd5e --- /dev/null +++ b/.claude/skills/agentic-delivery/references/flow-guards.md @@ -0,0 +1,11 @@ +# Flow guards: finding_guard, fitness_tick, flow_alarm (canonical) + +Read this when: wiring or tuning the issue-dedupe hook, the per-fix fitness check, or the delivery-flow alarms. Other files link here; they do not restate it. + +**Search before you file (`finding_guard.py`).** A hit on a closed issue or PR is a regression to reopen, not a new filing. The operating-layer PreToolUse hook runs `file_finding.py` on every `gh issue create --title` and denies a match. Override with `# finding-force: ` in the command; a failed search fails open. + +**Every fix ships one executable fitness check (`fitness_tick.py`).** Put it in `.perun/fitness/` (exit 1 plus a one-line reason on failure). The loop runs `python3 .claude/skills/agentic-delivery/scripts/fitness_tick.py` each tick and prints `ALARM name: reason`. Close the fix only when `fitness_tick.py closable NAME` passes: 2 consecutive real passing ticks and a real caller, meaning a hook/settings/package JSON, CI workflow, shell script or Makefile line that runs the tick, or a loop entry (`loop.md`, a `routines/` file) whose line starts with the command. A prose or comment mention is not a caller. + +**One symptom alarm, not a monitor per invariant (`flow_alarm.py`).** Each tick run `flow_alarm.py symptom`: ready PRs but nothing merged in 2h, or `--red-since` trunk red over 1h. **Stop starting:** the same script is a PreToolUse hook that denies `gh pr create` while 5 or more PRs sit ready-but-unmerged (`PERUN_STOP_STARTING_READY`; override `# priority: `), because waiting work is queue. **Measure before optimizing agents:** `flow_alarm.py split export.json` prints median waiting-to-start, coding and ready-to-merged hours and the largest stage. + +**Freeze (`freeze_gate.py`, `freeze_hook.py`).** A freeze is a file (`.perun-freeze`, `/PERUN_FREEZE` or `$PERUN_FREEZE`). Pre-push runs `freeze_gate.py BASE HEAD` (deletions and `Red-Trunk-Fix:` commits only); the fail-closed PreToolUse hook `freeze_hook.py` also denies `git push` and `gh pr merge` while the file exists. Lift the freeze by removing the file. diff --git a/.claude/skills/agentic-delivery/references/full-map.md b/.claude/skills/agentic-delivery/references/full-map.md index 7e4380407..2a0c70074 100644 --- a/.claude/skills/agentic-delivery/references/full-map.md +++ b/.claude/skills/agentic-delivery/references/full-map.md @@ -22,6 +22,7 @@ chat may be terse. | Task | Open | |---|---| | Land PRs as a train | `scripts/merge_train.py`, `scripts/train_land.sh`; depth `references/merge-queue-worktrees.md` | +| Issue-dedupe hook, per-fix fitness check, flow alarms, stop-starting, freeze | `references/flow-guards.md` | | Fix a red gate or union | `references/gate-epistemology.md`, `references/red-gate-triage.md`; repeat flake: `scripts/flake_ledger.py` | | Start a lane | `templates/lane-preamble.md`: paste into every lane brief | | Isolate a lane's worktree | `references/worktree-isolation.md` | diff --git a/.claude/skills/agentic-delivery/references/red-gate-triage.md b/.claude/skills/agentic-delivery/references/red-gate-triage.md index 272c57db8..265201bd3 100644 --- a/.claude/skills/agentic-delivery/references/red-gate-triage.md +++ b/.claude/skills/agentic-delivery/references/red-gate-triage.md @@ -35,7 +35,7 @@ audit gate in every train; run the audit on the bare base first and report `BASE **Before a flaky-test fix, list the open PRs that touch that test file** (`gh pr list --state open --json number,headRefName,files`) and add the fix to the holder's PR rather than opening a second one; the pre-push hook runs `flake_holder.py`, which warns naming the holder (warn, never refuse). -**Never store a poisoned baseline.** Store a baseline only through `python3 .claude/skills/agentic-delivery/scripts/baseline_guard.py --fails F --rerun 'CMD {id}' --out B.json --normal N`: it re-runs each failure solo (a solo pass is load, dropped), records the 1-minute load at start, and refuses a run with far more failures than normal. **A quarantine entry needs a ticket and an expiry** (`flake_ledger.py quarantine --ticket T`); a full list (`--max`, default 10) exits 1 and `--file-issue` files a P0, it is not a note for a human. **A freeze is a file, not a message**: while `.perun-freeze` (or `/PERUN_FREEZE`) exists, `freeze_gate.py BASE HEAD` (run by pre-push) lets through only pure deletions and commits with a `Red-Trunk-Fix:` line. +**Never store a poisoned baseline.** Store a baseline only through `python3 .claude/skills/agentic-delivery/scripts/baseline_guard.py --fails F --rerun 'CMD {id}' --out B.json --normal N`: it re-runs each failure solo (a solo pass is load, dropped), records the 1-minute load at start, and refuses a run with far more failures than normal. **A quarantine entry needs a ticket and an expiry** (`flake_ledger.py quarantine --ticket T`); a full list (`--max`, default 10) exits 1 and `--file-issue` files a P0, it is not a note for a human. **A freeze is a file, not a message**: see `references/flow-guards.md` (Freeze). **Load flakes must not sink a train.** A train that is all-or-nothing under load can merge nothing for hours: failures that looked new all passed when re-run alone. With `RERUN_CMD` (and optionally `BASELINE_FAILS`), `train_land.sh` re-runs each diff --git a/.claude/skills/agentic-delivery/references/unattended-operating-mode.md b/.claude/skills/agentic-delivery/references/unattended-operating-mode.md index 67d077aa2..4469352f0 100644 --- a/.claude/skills/agentic-delivery/references/unattended-operating-mode.md +++ b/.claude/skills/agentic-delivery/references/unattended-operating-mode.md @@ -46,7 +46,7 @@ loop, not a single task**. Frame it as the default stance: **While the owner is away, `defer` alone — never a posted message**: a decision request posted mid-run scrolls past and is unfindable later; the ledger, not the chat, is the record. Before deferring, check `task_ledger.py questions` for a - near-duplicate already answered, so a settled question is never re-asked. Before filing an issue, search closed issues and PRs by meaning; a hit is a regression to reopen, not a new filing: the operating-layer `finding_guard.py` PreToolUse hook runs `file_finding.py` on every `gh issue create --title` and denies a match (override: `# finding-force: ` in the command; a failed search fails open). **Every fix ships one executable fitness check** in `.perun/fitness/` (exit 1 plus a one-line reason on failure) that the loop runs each tick with `python3 .claude/skills/agentic-delivery/scripts/fitness_tick.py` (prints `ALARM name: reason`); close the fix only when `fitness_tick.py closable NAME` passes (2 consecutive real passing ticks and a caller that runs the tick). **One symptom alarm, not a monitor per invariant**: each tick run `python3 .claude/skills/agentic-delivery/scripts/flow_alarm.py symptom` (ready PRs but nothing merged in 2h, or `--red-since` trunk red over 1h). **Stop starting**: the same script is a PreToolUse hook that denies `gh pr create` while 5 or more PRs sit ready-but-unmerged (`PERUN_STOP_STARTING_READY`; override `# priority: `), because waiting work is queue. **Measure before optimizing agents**: `flow_alarm.py split export.json` prints median waiting-to-start, coding and ready-to-merged hours and the largest stage. On the + near-duplicate already answered, so a settled question is never re-asked. Before filing an issue, search closed issues and PRs by meaning; a hit is a regression to reopen, not a new filing. Dedupe hook, per-fix fitness check, symptom alarm and stop-starting: `references/flow-guards.md`. On the owner's **next** message, whatever it says, **answer it first**, then append the full open batch from `questions` — never lead with the backlog of asks. Once `answer` records a reply, the ledger drops that item from the pending batch (it diff --git a/.claude/skills/agentic-delivery/scripts/baseline_guard.py b/.claude/skills/agentic-delivery/scripts/baseline_guard.py index fd89ae342..85877a13e 100644 --- a/.claude/skills/agentic-delivery/scripts/baseline_guard.py +++ b/.claude/skills/agentic-delivery/scripts/baseline_guard.py @@ -5,12 +5,14 @@ baseline. Before a baseline is stored: re-run its failures one at a time, record the host load at start, and refuse a run whose failure count is far above normal. - baseline_guard.py --fails FILE --rerun 'CMD {id}' --out BASELINE.json [--normal N] [--max-factor F] [--max-load L] + baseline_guard.py --fails FILE --rerun 'CMD {id}' --out BASELINE.json [--normal N] [--max-factor F] [--max-load L] [--force] --fails FILE failing test ids of the candidate baseline run, one per line (blank lines ignored). --rerun CMD solo re-run command (shlex-split, no shell); `{id}` is replaced by the test id; exit 0 = passes solo. - --normal N the usual failure count of a healthy run. With it, more than F x max(N, 1) failures (F default 3) REFUSES. - --max-load L refuse when the 1-minute load average at start is above L (default: no limit; the value is always recorded). + --normal N the usual failure count of a healthy run (default 5). More than F x max(N, 1) failures (F default 3) REFUSES. + --max-load L refuse when the 1-minute load average at start is above L (default: 2 x cores, `PERUN_GATE_CORES` overrides + the core count; the value is always recorded). + --force skip both refusals (a deliberate, visible override); the solo re-run still happens. Writes BASELINE.json {"fails": [ids that still fail solo], "flaky": [ids that passed solo], "load1": X, "stored_at": epoch} and exits 0. REFUSE (exit 1, one `REFUSE baseline: ` line, nothing written): too many failures, or load above --max-load. Exit 2 on usage error or unreadable FILE. A solo re-run that cannot start counts as still failing. Side effects: the one @@ -38,20 +40,25 @@ def main(argv=None): ap.add_argument("--fails", required=True) ap.add_argument("--rerun", required=True) ap.add_argument("--out", required=True) - ap.add_argument("--normal", type=int) + ap.add_argument("--normal", type=int, default=5) ap.add_argument("--max-factor", type=float, default=3.0) ap.add_argument("--max-load", type=float) + ap.add_argument("--force", action="store_true") a = ap.parse_args(argv) + if a.max_load is None: + a.max_load = 2.0 * int(os.environ.get("PERUN_GATE_CORES") or os.cpu_count() or 1) try: ids = sorted({ln.strip() for ln in open(a.fails, errors="replace") if ln.strip()}) except OSError as e: print(f"baseline_guard: {e}", file=sys.stderr) return 2 load1 = os.getloadavg()[0] if hasattr(os, "getloadavg") else 0.0 - if a.max_load is not None and load1 > a.max_load: + if a.force: + pass + elif load1 > a.max_load: print(f"REFUSE baseline: load1 {load1:.1f} above --max-load {a.max_load}; re-take it on a quiet host") return 1 - if a.normal is not None and len(ids) > a.max_factor * max(a.normal, 1): + if not a.force and len(ids) > a.max_factor * max(a.normal, 1): print(f"REFUSE baseline: {len(ids)} failures, far above normal ({a.normal} x {a.max_factor:g}); the run is suspect") return 1 flaky = [t for t in ids if solo_passes(a.rerun, t)] diff --git a/.claude/skills/agentic-delivery/scripts/fitness_tick.py b/.claude/skills/agentic-delivery/scripts/fitness_tick.py index d1c699982..d6bf83d37 100644 --- a/.claude/skills/agentic-delivery/scripts/fitness_tick.py +++ b/.claude/skills/agentic-delivery/scripts/fitness_tick.py @@ -12,14 +12,17 @@ that times out (60s) or cannot start is an ALARM. fitness_tick.py closable NAME [--dir D] [--state F] [--root R] exit 0 only when NAME exists in D, has >= 2 consecutive passing - ticks (two real runs), AND a caller exists: some file under R - (default `.`, excluding .git, tests, this script and the state file) - that names `fitness_tick.py`, so the loop really runs it. + ticks (two real runs), AND a real caller exists under R (default `.`): + a hook/settings/package JSON, CI workflow, `.sh` or Makefile line that + runs it (`python3|bash|sh ...fitness_tick.py` or `./...`), or a loop + entry (`loop.md`, a `routines/` file) whose line starts with that command. + A prose mention or a comment does not count. Otherwise exit 1 with the one missing reason. Exit 2 on usage error. Side effects: the state file write. Never edits or deletes a check. """ import json import os +import re import subprocess import sys @@ -68,9 +71,25 @@ def tick(o): def has_caller(o): skip = {os.path.realpath(__file__), os.path.realpath(o["--state"])} - r = subprocess.run(["grep", "-rIl", "--exclude-dir=.git", "--exclude=test*", "-F", "fitness_tick.py", o["--root"]], + r = subprocess.run(["grep", "-rIn", "--exclude-dir=.git", "--exclude=test*", "-F", "fitness_tick.py", o["--root"]], capture_output=True, text=True) - return any(os.path.realpath(f) not in skip for f in r.stdout.split()) + for ln in r.stdout.splitlines(): + f, _, txt = ln.split(":", 2) + low, base = "/" + f.lower(), os.path.basename(f.lower()) + if os.path.realpath(f) in skip: + continue + # executable wiring only: an interpreter or ./path invocation, never a prose mention or a comment + run = r"(?:(?:python3?|bash|sh)\s+\S*|\./\S*)fitness_tick\.py" + if base == "loop.md" or "/routines/" in low: # a loop entry: the line IS the command + ok = re.match(r"\s*(?:[-*$>]\s*)*`?" + run, txt) + elif base == "makefile" or low.endswith((".json", ".sh", ".yml", ".yaml")): + ok = re.search(run, txt) and not txt.lstrip().startswith("#") and ( + not low.endswith((".yml", ".yaml")) or "/.github/workflows/" in low) + else: + ok = False + if ok: + return True + return False def closable(o, name): diff --git a/.claude/skills/agentic-delivery/scripts/freeze_hook.py b/.claude/skills/agentic-delivery/scripts/freeze_hook.py new file mode 100644 index 000000000..f5a931a1c --- /dev/null +++ b/.claude/skills/agentic-delivery/scripts/freeze_hook.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""PreToolUse(Bash) hook: while a freeze is on, deny `git push` and `gh pr merge`. + +WHY: `freeze_gate.py` runs inside pre-push, so it only sees a push that reaches git; a `gh pr merge` never does, and a +freeze that a lane can walk around is not a freeze. This hook reads the same freeze file (`$PERUN_FREEZE`, +`/PERUN_FREEZE` or a repo-root `.perun-freeze`) and denies (exit 2, reason on stderr) a Bash command whose +parsed argv, after global flags, is `git push`, `gh pr merge`, or a `gh api` call to `pulls/N/merge` or `mergePullRequest`. +Commands are split on `;`, `&`, `|`, newline; env prefixes and `env|command|sudo|nohup|time` wrappers are skipped, and +`bash|sh|zsh -c STR` / `eval STR` are parsed recursively. Lift the freeze by removing the file. + +Not a freeze (cwd outside a git repo, no `$PERUN_FREEZE`) -> allow. FAIL CLOSED once a freeze exists or its state cannot +be read (no `git` on PATH, git failing inside a repo): malformed JSON, a non-object payload, a missing or non-string +command, or an unparseable command line is denied. +Known ceiling (a tripwire for honest lanes, not a sandbox): a name built by variable, alias, quoting tricks the parser +cannot see, `eval "$x"`, or a script that runs the push internally is not seen. Real control: branch protection and the +pre-push `freeze_gate.py`. Side effects: none (read-only). +""" +import json +import os +import re +import shlex +import subprocess +import sys + +SEP = re.compile(r"[;&|\n]+") +WRAP = {"env", "command", "sudo", "nohup", "time", "exec", "builtin"} +GIT_VAL = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"} +GH_VAL = {"-R", "--repo", "--hostname"} + + +def freeze_file(): + """Path of the active freeze file, None when no freeze applies. Raises on an unreadable state.""" + env = os.environ.get("PERUN_FREEZE") + if env: + return env if os.path.exists(env) else None + p = subprocess.run(["git", "rev-parse", "--git-common-dir", "--show-toplevel"], capture_output=True, text=True) + if p.returncode: + if "not a git repository" in p.stderr: + return None # no repo, no freeze + raise RuntimeError(p.stderr.strip() or "git failed") + common, top = p.stdout.split("\n")[:2] + return next((c for c in (os.path.join(common, "PERUN_FREEZE"), os.path.join(top, ".perun-freeze")) if os.path.exists(c)), None) + + +def gated(cmd): + """True when any segment of `cmd` is a gated push/merge. Raises ValueError on an unparseable line.""" + for seg in SEP.split(cmd): + toks = shlex.split(seg) + while toks and (re.match(r"\w+=", toks[0]) or os.path.basename(toks[0]) in WRAP): + toks = toks[1:] + if not toks: + continue + prog, rest = os.path.basename(toks[0]), toks[1:] + if prog in ("bash", "sh", "zsh") and "-c" in rest and rest.index("-c") + 1 < len(rest): + if gated(rest[rest.index("-c") + 1]): + return True + elif prog == "eval": + if gated(" ".join(rest)): + return True + elif prog in ("git", "gh"): + val, args, i = (GIT_VAL if prog == "git" else GH_VAL), [], 0 + while i < len(rest): # drop global flags (and their values) before the subcommand + t = rest[i] + if t in val: + i += 2 + elif t.startswith("-") and not args: + i += 1 + else: + args.append(t) + i += 1 + if prog == "git" and args[:1] == ["push"]: + return True + if prog == "gh" and (args[:2] == ["pr", "merge"] or (args[:1] == ["api"] and re.search( + r"pulls/[^/\s]+/merge|mergePullRequest", " ".join(args)))): + return True + return False + + +def main(): + try: + freeze = freeze_file() + except Exception as e: # no git, git failing in a repo: the freeze state is unreadable + print(f"freeze_hook: cannot read freeze state ({e}); denying", file=sys.stderr) + return 2 + if not freeze: + return 0 + try: + data = json.load(sys.stdin) + if data.get("tool_name", "Bash") != "Bash": + return 0 + cmd = data["tool_input"]["command"] + if not isinstance(cmd, str): + raise TypeError("command is not a string") + if not gated(cmd): + return 0 + except Exception as e: # malformed input or an unparseable command: fail closed + print(f"freeze_hook: unreadable tool input ({e}); denying while frozen", file=sys.stderr) + return 2 + try: + reason = (open(freeze, errors="replace").readline().strip() if os.path.isfile(freeze) else "") or "(no reason given)" + except OSError: + reason = "(no reason given)" + print(f"FROZEN: {reason}; `git push` and `gh pr merge` are blocked until the freeze file is removed", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/agentic-delivery/scripts/wired_check.py b/.claude/skills/agentic-delivery/scripts/wired_check.py index 724851065..3e51e98f2 100644 --- a/.claude/skills/agentic-delivery/scripts/wired_check.py +++ b/.claude/skills/agentic-delivery/scripts/wired_check.py @@ -22,7 +22,8 @@ CODE_EXT = (".json", ".yml", ".yaml", ".sh", ".py") RUN_RE = re.compile(r"python3?\b|\bbash\b|\bsh\b|\./|\brun\b", re.I) SKIP = ("INDEX.md", "CHANGELOG.md", "SHA256SUMS") -ALLOW_RE = re.compile(r"orphan|unused|allow[-_ ]?list|\bunreferenced\b", re.I) +# explicit names only: ORPHAN_ALLOW(LIST), unused-allowlist, a `# orphan:` marker or an `allowlist:` / `allow_list=` key +ALLOW_RE = re.compile(r"(?15080: host_installer, peer_ack, pregate_parity rows) +51 skills/INDEX.md (14295->14346), +100 for the vendor_drift row, +200 = +284 INDEX-scripts.md growth (admission_check, host_accept rows) plus INDEX.md regen, +100 for the lane_worktree row, +50 for the bg_admit row, +150 for wave L28 rows (council, flow_health), +1000 for wave AL rows (reply_cap, fitness_tick, flake_holder, finding_guard, baseline_guard, freeze_gate, flow_alarm, prevention_lint, quarantine; measured 21360) + self.assertLess(core + lazy, 16962 + 4700) # a script lookup reads both: about the old size, not far above; +130 for the reply_cap and decision_gate rows, +820 for the destructive_guard_core, file_finding, repeat_poll_guard, stage_run and subagent_brief_lint rows, +150 for the feeder_order row, +200 for the train_yield row, +100 for the lkg row, +200 for the red_main row and the stage-weights/serial CLI text, +400 = +348 INDEX-scripts.md (14732->15080: host_installer, peer_ack, pregate_parity rows) +51 skills/INDEX.md (14295->14346), +100 for the vendor_drift row, +200 = +284 INDEX-scripts.md growth (admission_check, host_accept rows) plus INDEX.md regen, +100 for the lane_worktree row, +50 for the bg_admit row, +150 for wave L28 rows (council, flow_health), +1000 for wave AL rows (reply_cap, fitness_tick, flake_holder, finding_guard, baseline_guard, freeze_gate, flow_alarm, prevention_lint, quarantine; measured 21360), +300 for the freeze_hook and flow-guards rows (measured 21617) def test_entry_index_points_at_the_lazy_file(self): self.assertIn(si.SCRIPTS_INDEX, (ROOT / ".claude/skills/INDEX.md").read_text(encoding="utf-8"))