diff --git a/.claude/skills/INDEX.md b/.claude/skills/INDEX.md index a88c3b0cf..caf4ac712 100644 --- a/.claude/skills/INDEX.md +++ b/.claude/skills/INDEX.md @@ -109,7 +109,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, 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 +- 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, privacy_guard.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 b198341f7..3275c72d8 100644 --- a/.claude/skills/agentic-delivery/INDEX-scripts.md +++ b/.claude/skills/agentic-delivery/INDEX-scripts.md @@ -77,6 +77,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/premise_check.py` | before handing PRs or issues to a lane or peer, say which are already done or stale. | premise_check.py N [N ...] [--repo OWNER/NAME] [--timeout S] | | `scripts/prevention_lint.py` | every prevention/action item needs an owner, a tracking id and a verifiable end state. | prevention_lint.py FILE.md | | `scripts/priority_gate.py` | no presentation-only PR while an aged P0/mechanism issue has no PR citing it. | priority_gate.py --labels-json FILE [options] priority_gate.py --repo OWNER/REPO --pr N [options] priority_gate.py --se… | +| `scripts/privacy_guard.py` | PreToolUse(Bash) hook: DENY a GitHub write whose title or body matches the repo's banlist (a write… | — | | `scripts/queue_guard.py` | single priority queue: print the next issue to pull, flag P0 inflation. | queue_guard.py --issues issues.json --priorities PRIORITIES.md [--max-unranked N] issues.json gh issue list --json numb… | | `scripts/reap_own.sh` | kill your own dev servers older than MAX_AGE_S whose cwd is under ROOT (your own lane or session tr… | reap_own.sh | | `scripts/red_main.py` | green-main alarm: every red test on the default branch needs a fix or an owner within 1h. | — | diff --git a/.claude/skills/agentic-delivery/INDEX.md b/.claude/skills/agentic-delivery/INDEX.md index 801f10afd..19740a9ee 100644 --- a/.claude/skills/agentic-delivery/INDEX.md +++ b/.claude/skills/agentic-delivery/INDEX.md @@ -38,4 +38,4 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing ## Scripts -109 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. +110 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/scripts/comms_guard.py b/.claude/skills/agentic-delivery/scripts/comms_guard.py index 2992f5c20..755a94d9f 100644 --- a/.claude/skills/agentic-delivery/scripts/comms_guard.py +++ b/.claude/skills/agentic-delivery/scripts/comms_guard.py @@ -34,11 +34,22 @@ REST = re.compile(r"(?:\bcurl\b|\bgh\s+api\b)[\s\S]{0,600}?\brepos/[^/\s'\"]+/[^/\s'\"]+/(pulls|issues)((?:/\d+)?(?:/(?:comments|reviews))?)(?=[\s'\"?]|$)") HEREDOC = re.compile(r"<<-?[ \t]*(['\"]?)(\w+)\1[ \t]*\n(.*?)\n[ \t]*\2[ \t]*(?=\n|\)|\"|'|$)", re.S) VALUE = re.compile(r"\"((?:[^\"\\]|\\.)*)\"|'([^']*)'|(\S+)", re.S) +QUOTED = re.compile(r"\\.|'[^']*'|\"(?:[^\"\\]|\\.)*\"", re.S) # an escape, or a complete quoted string + + +def _plain(tail, keep=None): + """`tail` with each heredoc body and each quoted string blanked to NULs, the same length so offsets line up; `keep(string)` true + spares a quoted string. Stray quotes in a heredoc body pair with nothing, and an unclosed quote hides nothing.""" + plain = list(tail) + for h in HEREDOC.finditer(tail): + plain[h.start(3):h.end(3)] = "\0" * len(h.group(3)) + return QUOTED.sub(lambda m: m.group() if m.group()[0] == "\\" or (keep and keep(m.group())) else "\0" * len(m.group()), "".join(plain)) def _flags(tail, names): - """Yield the raw text after each occurrence of a flag in `names` (regex alternation), `--flag value` or `--flag=value`.""" - for m in re.finditer(rf"(?:^|\s)(?:{names})(?:=|[ \t]+)", tail): + """Yield the raw text after each occurrence of a flag in `names` (regex alternation), `--flag value` or `--flag=value`. + A flag-like word inside a quoted string (the title "Add -b flag") or a heredoc body is not a flag.""" + for m in re.finditer(rf"(?:^|\s)(?:{names})(?:=|[ \t]+)", _plain(tail)): yield tail[m.end():] @@ -82,21 +93,30 @@ def _gh_body(tail, noun, cwd): return None -def _rest_body(tail, cwd): - for rest in _flags(tail, r"-f|-F|--field|--raw-field"): # gh api -f body=TEXT, -f 'body=TEXT', -F body=@FILE - q = re.match(r"([\"'])body=", rest) - if q: - return _value(q.group(1) + rest[q.end():], cwd) - if rest.startswith("body="): - return _file(rest[5:], cwd, tail) if rest[5:].startswith("@") else _value(rest[5:], cwd) +def _rest_field(tail, cwd, key, literal=False): + """The text of one field (`body`, `title`) a REST write sends, or None if it is absent or unreadable. gh sends `-f`/`--raw-field` + text as typed (`@jane` is a mention) and reads `-F`/`--field` `@FILE`; `literal` gives an unreadable `@FILE` as typed, not an error.""" + for names, at_file in ((r"-f|--raw-field", False), (r"-F|--field", True)): # gh api -f body=TEXT, -f 'body=TEXT', -F body=@FILE + for rest in _flags(tail, names): + q = re.match(rf"([\"']){key}=", rest) + if q or rest.startswith(key + "="): + val = _value(q.group(1) + rest[q.end():] if q else rest[len(key) + 1:], cwd) # one shell word: more arguments may follow + if not (at_file and val and val.startswith("@")): + return val + try: + return _file(val, cwd, tail) + except (OSError, ValueError): + if literal: + return val + raise for rest in _flags(tail, r"-d|--data|--data-raw|--data-binary"): # curl -d '{"body": ...}' or -d @FILE raw = _value(rest, cwd) raw = _file(raw, cwd, tail) if raw and (raw.startswith("@") or raw == "-") else raw - return json.loads(raw).get("body") if raw else None + return json.loads(raw).get(key) if raw else None for rest in _flags(tail, r"--input"): # gh api --input FILE name = _value(rest, cwd) raw = _file(name, cwd, tail) if name else None - return json.loads(raw).get("body") if raw else None + return json.loads(raw).get(key) if raw else None return None @@ -116,7 +136,7 @@ def extract(tool_name, tool_input, cwd=""): body = _gh_body(cmd[m.start():], noun, cwd) elif (m := REST.search(cmd)): kind = "pr" if m.group(1) == "pulls" and not m.group(2).endswith(("comments", "reviews")) else "message" - body = _rest_body(cmd[m.start():], cwd) + body = _rest_field(cmd[m.start():], cwd, "body") else: return None except (OSError, ValueError, AttributeError): # unreadable file, non-JSON data: nothing to lint diff --git a/.claude/skills/agentic-delivery/scripts/privacy_guard.py b/.claude/skills/agentic-delivery/scripts/privacy_guard.py new file mode 100644 index 000000000..ed2e5d95f --- /dev/null +++ b/.claude/skills/agentic-delivery/scripts/privacy_guard.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# rail: owner=agentic-delivery, why=deny a GitHub write whose title or body matches the repo banlist except into a sibling repo of the origin owner and deny a write into Perun that names the session repository, review=2027-01-30 +"""privacy_guard.py — PreToolUse(Bash) hook: DENY a GitHub write whose title or body matches the repo's banlist (a write into a sibling +repository of the session origin's owner is not scanned), and a write into the public Perun repository (SELF_REPO) that names the +repository of the session it is filed from. + +WHY: the privacy gate scans the files a repo ships, and nothing scanned the text an agent publishes through `gh` or the API, so a +public issue body that named a private repository stayed up for weeks. This hook runs the repo's own banlist over the title and +body of each GitHub write before it is sent. A project's banlist does not list the project's own name (it is all over its files), so a +field report filed from that project's session into the public Perun repository carried the name past the banlist; the own-repository +rule below closes that. + +Covers what comms_guard.py covers, through its extraction (imported, not copied): Bash `gh pr|issue|release create|edit|comment`, +and `curl` or `gh api` calls to repos///pulls or /issues. Title: `--title`/`-t`, `-f title=...`, JSON `title`. Body: +`--body`/`-b`/`--notes`, a heredoc, `$(cat FILE)`, `--body-file`/`-F FILE`, `-d`/`--input` JSON, `-f body=...`, `-F body=@FILE`. `-f` +text is scanned as typed (`@jane` is a mention); `-F` reads `@FILE`, and a file it cannot read is scanned as typed. Every write in a +command is read. A body file the same command writes (`> F`, `>> F`, `tee F`) is read from the heredoc that feeds it, not from the +disk, which holds an old copy or none; what it writes any other way (`echo`, `printf`, `cp`) is not read. Not covered: `gh pr +merge|review`, `gh issue|pr close --comment`, `gh repo edit`, `--fill`, PATCH of `issues/comments/ID`, POST of `pulls/N/comments/M/replies`, +`repos///releases`, `gh api graphql`, `curl --data-urlencode` and `--json`; a body fed to stdin by a pipe or `<` (`-F -`, +`--body-file -`; a heredoc is read); text that a shell variable or a command substitution other than `cat FILE` produces at run time; a +single-quoted value with an escaped quote inside, a `$'...'` string, `-b"text"` with no space; a relative body file after `cd DIR &&` +(read from the payload `cwd`, not DIR). + +Patterns: the actionable lines (non-blank, first non-space character not `#`) of `.banlist.txt`, then `.banlist.local.txt`, at the git +top-level of the payload `cwd`; a linked worktree lacks the gitignored local file, so the main checkout's is read instead. Each line +is an ERE run through `grep -E` over each line of the text, as `scripts/ci-gates.sh privacy` does: case-sensitive, and an invalid ERE +is skipped here (the gate fails closed on it). + +Scope: the banlist is the session repo's and runs over every write except one whose explicit target is another repository of the +session origin's owner (a sibling): a private repository's name belongs in a write into that repository. The session's own repository +(named or implicit), SELF_REPO, every other owner's repository and a session with no usable `origin` stay scanned: a private target +cannot be told from a public one without a network call, and a wrong deny is cheaper than a leak. Residuals: a public sibling in the +origin's organisation is not scanned, and another owner's private repository is, so a legitimate name in it is denied. The target is +`-R`/`--repo OWNER/REPO` (quoted, or with a `github.com/` host), a github.com URL, or a `repos/OWNER/REPO/(issues|pulls)` path, read from +the write's own region only: from the write to the next gh or curl write or the end of its command line (`&&`, `||`, `;`, `|`, a +newline, a `#` comment), with each heredoc body, each quoted string that holds a space and a one-word title or body blanked first, so +text that only mentions a repository (a body quoting `-R acme/other`, a later `echo`) sets no target. A `.git` suffix is dropped. A +write that names several repositories is skipped only when every one is a sibling; a target set by `GH_REPO` is not seen. + +Own-repository rule: a write that targets SELF_REPO (read as above) from a session whose `origin` remote names another repository is +denied when its title or a body line names that repository, as `owner/name` or as the bare name (case-insensitive, whole word; a short +plain name is skipped, see `_own_names`). No network: a missing `origin`, an unparsable URL or a session inside SELF_REPO has no such +rule, and a target set by `GH_REPO` is not seen. + +Output: on a hit, exit 2 and a stderr reason that names each hit as `title` or `body line N`, a banlist hit adding the pattern's 1-based +number among the actionable lines above. The matched text and the repository's name are never printed. Otherwise exit 0 and print nothing. +FAIL OPEN: a non-Bash tool, no git repo, no banlist, no title or body, an unreadable body file (a readable title is still checked), +bad stdin, a missing `grep` or `git`, or any error allows. Register it WITHOUT `|| true`, which would turn the deny into an allow. Opt +out for a session or a project with `PERUN_PRIVACY_GUARD=off` (settings `env`; a `VAR=off` prefix on the command never reaches a hook). +Side effects: read-only `git rev-parse` and `git remote get-url`, `grep` and file reads; no network. Test: scripts/test_privacy_guard.py. +""" +import json +import os +import re +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +SHOWN = 5 # hits named in the deny reason +SELF_REPO = "protocol/perun" # lower case; same constant as contribution/scripts/prefile_check.sh +# A repository a write names: `-R`/`--repo OWNER/REPO` (quoted, or with a github.com host), a github.com URL, or a REST path `repos/OWNER/REPO/issues`. +TARGETS = re.compile(r"(?:(?= 8 else []) + return re.compile(rf"(?>?|\btee\s+(?:-\S+\s+)*)\s*[\"']?{re.escape(path)}[\"']?(?=[\s;&|)]|$)", cmd)} + if not lines: + return disk(path, cwd) + return "\n".join(h.group(3) for h in G.HEREDOC.finditer(cmd) if cmd.rfind("\n", 0, h.start()) in lines) # the heredoc opens on the line of the redirect: `cat > F <<'EOF'` + + G._read = read # ponytail: patched around the extraction so it stays imported, not copied (one hook process, one command) + try: + writes = sorted([*G.GH.finditer(cmd), *G.REST.finditer(cmd)], key=lambda m: m.start()) + for i, m in enumerate(writes): + tail = cmd[m.start():] + if m.re is G.GH: + title = _try(lambda: next((G._value(r, cwd) for r in G._flags(tail, r"--title|-t")), None)) + body = _try(G._gh_body, tail, m.group(1), cwd) + else: + title, body = _try(G._rest_field, tail, cwd, "title", True), _try(G._rest_field, tail, cwd, "body", True) + end = writes[i + 1].start() - m.start() if i + 1 < len(writes) else len(tail) + out.append((_targets(G, tail, m.end() - m.start(), end, (title, body)), title, body)) + finally: + G._read = disk + return out + + +def _named(hits): + """The first SHOWN hits joined by `; `, then how many more there are.""" + return "; ".join(hits[:SHOWN]) + (f"; {len(hits) - SHOWN} more" if len(hits) > SHOWN else "") + + +def verdict(cmd, cwd): + """(exit code, stderr text) for one Bash command: 2 and a reason without the matched text or the session repo's name when a title or + body line matches the banlist (a write to a sibling repository of the origin's owner is not scanned) or, in a write to SELF_REPO, + names the session's own repository.""" + if not re.search(r"\b(?:gh|curl)\b", cmd): + return 0, "" + writes = [w for w in outgoing(cmd, cwd) if w[1] or w[2]] + if not writes: + return 0, "" + pats = patterns(cwd) + origin = _origin(cwd) if any(w[0] for w in writes) else None # no usable origin: every write is scanned and there is no own-repository rule + names = _own_names(cwd, origin) if origin else None + ban, own = [], [] + for targets, title, body in writes: + if not (origin and targets and all(_sibling(t, origin) for t in targets)): + ban += [("title", i) for _, i in scan(title, pats)] + [(f"body line {n}", i) for n, i in scan(body, pats)] + if names and SELF_REPO in targets: + own += (["title"] if names.search(title) else []) + [f"body line {n}" for n, ln in enumerate(body.split("\n"), 1) if names.search(ln)] + msg = [] + if ban: + msg.append("Blocked: the title or body of this GitHub write matches the repo banlist (matched text withheld): " + f"{_named([f'{where} (pattern {i})' for where, i in dict.fromkeys(ban)])}. Remove or reword that text and send it again. " + "Patterns are numbered over the actionable lines of .banlist.txt, then .banlist.local.txt.") + if own: + msg.append(f"Blocked: this write to {SELF_REPO} names the session's own repository (name withheld): {_named(list(dict.fromkeys(own)))}. " + "Reword that text without the repository's name and send it again.") + return (2, " ".join(msg)) if msg else (0, "") + + +def main(): + if os.environ.get("PERUN_PRIVACY_GUARD", "").lower() in ("off", "0", "false"): + return 0 + try: + data = json.load(sys.stdin) + if data.get("tool_name") != "Bash": + return 0 + code, msg = verdict(str((data.get("tool_input") or {}).get("command") or ""), str(data.get("cwd") or "")) + except Exception: # fail open: a broken privacy hook must never block work + return 0 + if msg: + print(msg, file=sys.stderr) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/agentic-delivery/templates/operating-layer.settings.json b/.claude/skills/agentic-delivery/templates/operating-layer.settings.json index 9e2068146..a86c246e3 100644 --- a/.claude/skills/agentic-delivery/templates/operating-layer.settings.json +++ b/.claude/skills/agentic-delivery/templates/operating-layer.settings.json @@ -99,6 +99,16 @@ } ] }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "timeout": 10, + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/skills/agentic-delivery/scripts/privacy_guard.py\"" + } + ] + }, { "matcher": "Bash", "hooks": [ diff --git a/.claude/skills/deep-code-review/INDEX.md b/.claude/skills/deep-code-review/INDEX.md index 4f88105bc..4194e55d4 100644 --- a/.claude/skills/deep-code-review/INDEX.md +++ b/.claude/skills/deep-code-review/INDEX.md @@ -98,7 +98,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/method-diff.md` | the review scope is a DIFF or FILE (a large or blast-radius-flagged DIFF, a fix that is itself the… | 1968 | | `references/method-lenses.md` | method.md routes here: a call site may bypass or re-derive a shared helper, component or predicate,… | 1935 | | `references/method-report.md` | writing the Phase 5 report (delivery shape, out-of-tree vs in-repo, ledger and claim reconciliation… | 2292 | -| `references/method-situational.md` | method.md routes here (its "Situational Phase 1 checks" bullet and its perceived-absent-premise par… | 11099 | +| `references/method-situational.md` | method.md routes here (its "Situational Phase 1 checks" bullet and its perceived-absent-premise par… | 11175 | | `references/method.md` | executing a review: after stating SCOPE / START_SHA / COVERAGE_LEDGER, work the phases in order. | 7484 | | `references/migration-parity.md` | the reference you are matching is a prototype / mockup / design export (a static page, a design-too… | 7610 | | `references/mobile-appsec.md` | the target is (or ships) a native mobile client — an iOS (Swift / Objective-C) or Android (Kotlin /… | 6478 | diff --git a/.claude/skills/deep-code-review/references/method-situational.md b/.claude/skills/deep-code-review/references/method-situational.md index b23f478ce..9719be861 100644 --- a/.claude/skills/deep-code-review/references/method-situational.md +++ b/.claude/skills/deep-code-review/references/method-situational.md @@ -9,7 +9,7 @@ Read this when `method.md` routes here (its "Situational Phase 1 checks" bullet gitleaks, trufflehog (offline, `--no-verification`: unverified; a row is detector, file, line, never the secret), semgrep with the repo's rules or else the bundled taint rules plus p/default (`REVIEW_SEMGREP_REGISTRY=0` drops the registry pack), trivy and osv-scanner offline, osv online with `REVIEW_OSV=1`) on the changed files and prints findings JSON; `--history` scans commit history, `--tests` adds the declared `REVIEW_TEST_CMD`; stderr ends with a one-line ran/skipped summary. Its findings are leads: verify each against the code before reporting, report a `not run` line or `not-checked` row as unchecked surface, and never read an empty - list as clean (analyzers miss fail-open and logic defects). Its gitleaks pass uses `templates/gitleaks.toml` + list as clean (analyzers miss fail-open and logic defects). In diff mode every row carries `"new": true|false` (true when its file and line are on a line the diff adds; line 0 and file-level rows are false; `--full` and `--history` omit the key): verify new rows first, and report a pre-existing row in a touched file only when the diff makes it reachable or worse. Its gitleaks pass uses `templates/gitleaks.toml` (default rules plus a skip list for `.history/`, `fixtures/`, `testdata/` and generated registry JSON and lockfiles; the skip count is printed) when the repo has no `.gitleaks.toml`. An optional repo-root `.gitleaks-allow` (`rule path-glob sha256-of-secret` per line, format in the template) drops known false positives by content hash, so it survives history rewrites; it is read from the base ref only, and a malformed list is ignored with a warning. **Verify or drop every scanner lead:** open the cited line, confirm the code is reachable and, for a secret, that a real value is present (a name like `api_key` or `*_key` with no value, a placeholder or a fixture is not a secret); drop what fails; report diff --git a/.claude/skills/deep-code-review/scripts/review_checks.sh b/.claude/skills/deep-code-review/scripts/review_checks.sh index 9a18bce43..6675afe7b 100755 --- a/.claude/skills/deep-code-review/scripts/review_checks.sh +++ b/.claude/skills/deep-code-review/scripts/review_checks.sh @@ -3,6 +3,7 @@ # changed since REF (default origin/main, compared as REF...HEAD) and print findings as a JSON array on # stdout: [{"file","line","rule","message","text","tool"}]. `text` ("rule: message") is the key # scripts/score_review.py matches on. Findings are LEADS: verify each against the code before reporting. +# Diff mode (not --full or --history) adds "new": true|false to every row: its file+line is on a line REF...HEAD adds (rename-aware; line 0 and file-level rows are false). No row is ever dropped. # # Tools (linters, gitleaks, trufflehog, osv-scanner, semgrep, trivy): see references/method-situational.md. Each runs only when # installed and only on matching changed files. Secrets are redacted and never printed; trufflehog runs offline (`--no-verification --no-update`) over the changed files (every tracked file under --full), @@ -846,7 +847,10 @@ if [ "$tests" = 1 ]; then fi r=; for t in $ran; do case " $failed " in *" ${t%%(*} "*) ;; *) r+=" $t" ;; esac; done; ran=$r -REVIEW_STATE="$(git rev-parse --git-common-dir)/review-checks-last.json" HERE="$here" python3 - "$raw" "$ran" "$missing" "$nex" "$dg" "$w1" "$nice19" "$wired" "$gap" "$wiredx" <<'PY' +# "new" flags, diff mode only (a --full or --history row may sit on a line no diff added). Flags are pinned so user git config cannot change the parse: +# --inter-hunk-context=0 and --no-color keep hunk counts and headers exact, the prefixes fix the "+++ b/" name, and -a gives a text file with a NUL byte (invisible-chars rows) hunks instead of "Binary files differ". +ud=; if [ "$wr" = 0 ] && [ "$hist" = 0 ]; then ud=$td/udiff; git -c core.quotePath=false diff -U0 --inter-hunk-context=0 -M -a --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ "$base"...HEAD >"$ud" || ud=; fi +REVIEW_STATE="$(git rev-parse --git-common-dir)/review-checks-last.json" HERE="$here" python3 - "$raw" "$ran" "$missing" "$nex" "$dg" "$w1" "$nice19" "$wired" "$gap" "$wiredx" "$ud" <<'PY' import json, os, re, shlex, sys, time rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1], encoding="utf-8", errors="replace")] rows = [t for i, t in enumerate(rows) if t[3:4] != ["not-checked"] or t not in rows[:i]] # a tool failing on N files is one not-checked row @@ -882,6 +886,17 @@ for t in data: k = key(t) if not k or len(groups[k]) <= 3: out.append(row(t)) elif k not in done: done.add(k); out.append(summ(k, groups[k])) +ud = sys.argv[11] # `git diff -U0` of BASE...HEAD; "" under --full/--history (no "new" key) +if ud: + adds, cur, inh = {}, None, False # adds: file -> [(first line, count)] of added lines; cur: the file whose hunks are being read; inh: past its first hunk header, where a "+++ " line is content + for l in open(ud, encoding="utf-8", errors="replace"): + if l.startswith("diff --git "): cur, inh = None, False + elif l.startswith("@@ "): + inh = True; m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", l) + if cur and m: adds[cur].append((int(m[1]), 1 if m[2] is None else int(m[2]))) # "+N" is one line, "+N,0" none + elif not inh and l.startswith("+++ b/"): cur = l[6:].rstrip("\n").split("\t")[0]; adds[cur] = [] # git adds a tab after a name with a space; a quoted name or /dev/null leaves cur unset + for r in out: # file-level rows (line 0, no file) are never new; every other path is read relative to the repo root, as the tools print it ("./x", absolute) + r["new"] = r["line"] > 0 and r["file"] not in ("", "-") and any(a <= r["line"] < a + c for a, c in adds.get(os.path.relpath(r["file"]), ())) print(json.dumps(out, indent=1)) n = len(out) dg, w1 = sys.argv[5].split(), sys.argv[6].split() # lease full: "1 worker" only for tools whose single-worker flag ran; "nice 19" only when nice worked @@ -889,6 +904,7 @@ def lab(t): b = t.split("(")[0] return t + (f" (degraded: lease full{', nice 19' if sys.argv[7] == '1' else ''}{', 1 worker' if b in w1 else ''})" if b in dg else "") print(f"review_checks: {n} findings; {sys.argv[4]} files excluded; analyzers ran: {', '.join(lab(t) for t in sys.argv[2].split()) or 'none'}; skipped (not installed): {', '.join(sys.argv[3].split()) or 'none'}", file=sys.stderr) +if ud: print(f"review_checks: {sum(r['new'] for r in out)} of {n} rows are on lines this diff adds", file=sys.stderr) # "ran X / wired Y": wired = scanners with work in this run (their files matched); fixable = wired and either missing or running without a cache a provision warms. # argv[10] (shellcheck, ruff/pyflakes, gitleaks-history) is wired too, but provision_tools.sh cannot install it, so it is never fixable. wired = sys.argv[8].split() diff --git a/SHA256SUMS b/SHA256SUMS index 563429640..37eb223a4 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -18,8 +18,8 @@ ef9e423362b12d0dd73c8e165faf32e9f53c4d4ac215b57c2e34002a88713838 .claude/skills 71def6259e7016301932ac474dd4b7afea29349a8dd0b6284d74b5e9ebd16936 .claude/skills/agentic-ceo/scripts/token_ratchet.py 372f7d86157392ebfab627f6a05a892e4dd3297db9b8bfc6f404759169c7500c .claude/skills/agentic-ceo/scripts/token_report.py 9521b057ff3db92d1e60923ebd88170713914f7e235ceb45717eb39419daf811 .claude/skills/agentic-ceo/scripts/weekly_receipt.py -001b7b0a350ff5b7d4e5b85ca2bc6060514410377461a4def7579a92ab7f187a .claude/skills/agentic-delivery/INDEX-scripts.md -29ffa479167c28a166aa0fe2cf189bb12335186256e391eccec68208fd5b36a0 .claude/skills/agentic-delivery/INDEX.md +7e2179751e9dd2fb371b3e9fb01fca987d69605af1e8e6e609ff9b0d0abb384b .claude/skills/agentic-delivery/INDEX-scripts.md +47fff02dcb4401d5a9b42b2b3b7f7c123125eece55e7f9adee8cfe488c70a146 .claude/skills/agentic-delivery/INDEX.md 04982647fd4125802d46d991ca23ce7fa648a6b2b71074ebbf2c71e6d483619e .claude/skills/agentic-delivery/SKILL.md 73e93e5ff760a52ccc7044d782e1db000a421b043567d8de6ed00a922ffc5959 .claude/skills/agentic-delivery/VERSION 94144c101f82399eb6c18f688321a00e1c195dd0ab334f3a5daa2543b40ff9f5 .claude/skills/agentic-delivery/evals/evals.json @@ -68,7 +68,7 @@ a6fc31cf16e20c1b3107b832c1c65b766984199fd64d5cc0937df88951183e69 .claude/skills 0193bc266dd2726b6b370e7d733fe7cdda33ebdf650748d561c9e6f8a311b8e0 .claude/skills/agentic-delivery/scripts/claim_probe.py 03af48a629587a7d5d826a84420373bd3bfce9f96097a1eea09bb3656d2b3359 .claude/skills/agentic-delivery/scripts/clean_finished.sh 264713d1667dd51c08ab3646450a5da198eda83f28f8f5705275e911cb3ee9b8 .claude/skills/agentic-delivery/scripts/clock_lint.py -3b6d6832f55540575c6a6e9840670c255b6e7d4ec25ab22ce5058f0b7761c523 .claude/skills/agentic-delivery/scripts/comms_guard.py +8aeadfe6f80dc66cb2ac5984c48d407e23e01679b045e263ec5350112febec76 .claude/skills/agentic-delivery/scripts/comms_guard.py 825a6881cf342d50f8ad8bb4dd78eb2403e8cfe1dec94c64adf0ed61f55fcd44 .claude/skills/agentic-delivery/scripts/context_nudge.py bb340c0138e40e403e2832f11b59199cf9e3427ffab9e18fcffd69f0e227fd6a .claude/skills/agentic-delivery/scripts/cost_statusline.py 703423ad6921f7dd2ca3f41c6f72adbcd16a07da3df18f205f11a07d479c7207 .claude/skills/agentic-delivery/scripts/decision_gate.py @@ -126,6 +126,7 @@ a9235428a933257a45cc486a58dffe4f8ec5a398311937c887066aa0e0698162 .claude/skills 6543ba858ea5e599d12ac8b7fc4cb56919d28ea66c521d887f574bb5d1139c11 .claude/skills/agentic-delivery/scripts/premise_check.py 5e6bf88353f68be4b012cd44742e8bba94db5fd17b246e73b682b9d70577540b .claude/skills/agentic-delivery/scripts/prevention_lint.py 3e3045c3d4a695927970cf767569cbf271b4ae30ddd11eb2b2defab880c485bc .claude/skills/agentic-delivery/scripts/priority_gate.py +19223509022e7a9c3133c1784643bea0f2bcd010a06f9ab4ef7e6eaf10601710 .claude/skills/agentic-delivery/scripts/privacy_guard.py 9eaf96f25ab91db9fde36ee43bd2a85f0fb422107f92cecfada96aafe1b62af3 .claude/skills/agentic-delivery/scripts/queue_guard.py df84be6c5c868d26b90c34ba720e87407f63bc259dbabfb30ae41789dfb5da63 .claude/skills/agentic-delivery/scripts/reap_own.sh d9135da4b8af3e2aacc5de5edb28843d6370ced0185860f49afc5eca9918987f .claude/skills/agentic-delivery/scripts/red_main.py @@ -169,7 +170,7 @@ ac7eb14ad5aa0add90a39d2f6d1f8ab1d53763b4912e13916f788cdd30a1d0c2 .claude/skills c3ef87aa65dec1f63df5f6b1aadc3a1e5c322ed7288a5dbd3b7617d56c12adbb .claude/skills/agentic-delivery/templates/loops/cleanup.md b956a4767c55179e732c5e34581abfd07b285dd73aa030ad9119005cb8e2d0a2 .claude/skills/agentic-delivery/templates/loops/coordinator.md 7f5e043836dca83534623994d97de0da88caec290e434d0c7a1a52cf2345f05b .claude/skills/agentic-delivery/templates/loops/peer.md -59507ff96d89724b022dee7234906c0af218655dbbdabd6f4f41493effc82607 .claude/skills/agentic-delivery/templates/operating-layer.settings.json +7fa1d9aa98581763f73f661b2ae46a5246ea6ecef54ea717c34be2603fea0ff7 .claude/skills/agentic-delivery/templates/operating-layer.settings.json 024c151cdc8b5175a25ebe44cf4cbb86539ae8d9f90e03b7e2ac359481841681 .claude/skills/agentic-delivery/templates/review-tiers.tsv 0a250e0345c762cbcd7ac018c161bc9a11b405fed644c936379fb74881cc5025 .claude/skills/agentic-delivery/templates/terse-replies.settings.json 986cd7d948bb0aea7abbaa89931756e68b63f48d6fc0fccb00a8e070e618ec6d .claude/skills/business-ops/INDEX.md @@ -204,7 +205,7 @@ d98f3e4380da3496061ba63be7beebe75017ab4ee1734eda9a3e798e19007b0e .claude/skills 92d22cc5d0a60ba6bdb1f4a9b597823dec5b8412cec09a5be6cf93b3973f4226 .claude/skills/contribution/scripts/prefile_check.sh 18f20db537ab784460ad33cf218f7a01ac0a172fc38413f14c0e19e1ce6821cc .claude/skills/contribution/scripts/share_learning.py 63e7d07ffa54b224a83abee280456f6a24dc8222b9b55f6e249b10ea0c7f5afc .claude/skills/deep-code-review/INDEX-scripts.md -848ddf8c618da617b98bbe76808e775e6ef4f25ccc71954a868a093411d5e9d4 .claude/skills/deep-code-review/INDEX.md +3145a0f11e8ecc8b510ae32e7de8eff9bea24e1e9bf46ed662221b7b4d386de2 .claude/skills/deep-code-review/INDEX.md a44ec08c8bd85b4adb4ce9c18e01e8c01a500f6efaceaf4bc79e2c396f4393d3 .claude/skills/deep-code-review/SKILL.md 73e93e5ff760a52ccc7044d782e1db000a421b043567d8de6ed00a922ffc5959 .claude/skills/deep-code-review/VERSION b97d95bc93cc23a8bc689e2e23db261a138367bcbc69d2f8a0452d5c2c6dfb68 .claude/skills/deep-code-review/evals/evals.json @@ -301,7 +302,7 @@ cfdc3609c63b7669dd0ddcab3546dbae6036e5214668802acfb0cfc9117a8f25 .claude/skills 712ff4adad24308045101ee386340a61dd80230ddcbdedc30603d18b412378d3 .claude/skills/deep-code-review/references/method-diff.md 1c428659366bb21fad34ab66f42390410dbf5191d97c07ca40db8b0476081fa2 .claude/skills/deep-code-review/references/method-lenses.md 43ce8532a5f85d5784bcfd1d052ed6d7e832902602d2f7e5e8c4fafebef0b8e2 .claude/skills/deep-code-review/references/method-report.md -77b2176d240a480e43fa11d93098e551ff9af49c25459a0fb22d07b64b645e02 .claude/skills/deep-code-review/references/method-situational.md +9a97de6c253d9d39bfa108c0608dfc6ff820abbad64e9d5b6362695d472ea8d4 .claude/skills/deep-code-review/references/method-situational.md fd354313f768db8a13a99e4012379a2c75f804a120c8e2de8556a8cac3981742 .claude/skills/deep-code-review/references/method.md 5fd086a47f860ab2b946d9409d1e1de03b1338cf832d5f1539fb3dfe63d85001 .claude/skills/deep-code-review/references/migration-parity.md 17838a58489145ecb8a466737ae5e12ac37e98260d335d160aff95d3d5354804 .claude/skills/deep-code-review/references/mobile-appsec.md @@ -396,7 +397,7 @@ e2903a566deee3423cc377b000475b30d6698ccebd640cb62b86b6d2001c4822 .claude/skills 5d6910164ddf6dfd4ed62dfc365a2f615f49dc07878ed3d1f881aa9518e481c2 .claude/skills/deep-code-review/scripts/post_review.sh b01cae69d80ca560cc414c1c70ef0ddfdbb910da5b68df4e9d46cf0daf3f8b16 .claude/skills/deep-code-review/scripts/provision_tools.sh f1108e2700864195a239744b319b1d04953879589f8a2b83708bc45a080e3d86 .claude/skills/deep-code-review/scripts/reaper_lint.py -4e7ff3d05f56d8e81864ba434bf4e9adfa3dc4243cef01a4d06bf0e0260d6314 .claude/skills/deep-code-review/scripts/review_checks.sh +6315b222c3993981e09b9b608502b71b055cbee6ef4819b3e5dc6293029b0c7d .claude/skills/deep-code-review/scripts/review_checks.sh 8c87c02a050a975e453b52ecacbaf01481c7134466a9c67feaf409d22e36d9f7 .claude/skills/deep-code-review/scripts/review_feedback.py 859c67d6a6a781c873f47d5662d403da2321da53e6994ca3d0ccdbf74afd39ac .claude/skills/deep-code-review/scripts/score_review.py e9dfa337ad16d0022a3b7a87e0ba49acb5c2c9c0a5ee24396ff80523f2f8fa1a .claude/skills/deep-code-review/scripts/security_verify.py diff --git a/changelog.d/guard-registry.md b/changelog.d/guard-registry.md new file mode 100644 index 000000000..6e099946c --- /dev/null +++ b/changelog.d/guard-registry.md @@ -0,0 +1,7 @@ +### Added +- `scripts/guard-registry.tsv`: one row per hook a shipped template runs or `install.sh` writes and per `ci-gates.sh` gate, with columns `id`, `owner`, `reason`, `review-by` and an optional fire log path. A guard is a hook script a shipped `.claude/skills/*/templates/*.settings.json` runs (`hook:/