@mmerterden/multi-agent-pipeline 20.28.0 → 20.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +14 -1
  3. package/docs/facts.json +1 -1
  4. package/install/claude.mjs +112 -0
  5. package/install/templates/claude-hooks.json +30 -1
  6. package/manifest.json +53 -42
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  9. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  10. package/pipeline/contract/types/index.d.ts +1 -1
  11. package/pipeline/lib/hook-registration.mjs +252 -0
  12. package/pipeline/lib/prefs-value.mjs +44 -0
  13. package/pipeline/lib/run-completion.mjs +148 -0
  14. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  15. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  16. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  17. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  18. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  19. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  21. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  22. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  23. package/pipeline/preferences-template.json +7 -0
  24. package/pipeline/rules/figma-pipeline.md +2 -0
  25. package/pipeline/schemas/agent-state.schema.json +20 -5
  26. package/pipeline/schemas/phases.json +3 -2
  27. package/pipeline/schemas/phases.schema.json +5 -1
  28. package/pipeline/schemas/prefs.schema.json +37 -0
  29. package/pipeline/schemas/runs-index.schema.json +1 -1
  30. package/pipeline/scripts/_active-run.mjs +158 -0
  31. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  32. package/pipeline/scripts/_guard_rules.py +605 -0
  33. package/pipeline/scripts/agent-guard.py +118 -7
  34. package/pipeline/scripts/agent-guard.sh +50 -3
  35. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  36. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  37. package/pipeline/scripts/confirm-command.mjs +70 -0
  38. package/pipeline/scripts/doctor.mjs +54 -0
  39. package/pipeline/scripts/evidence-gate.mjs +8 -4
  40. package/pipeline/scripts/gate-ledger.mjs +163 -7
  41. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  42. package/pipeline/scripts/pre-commit-check.sh +12 -0
  43. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  44. package/pipeline/scripts/run-scope-guard.sh +23 -0
  45. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  46. package/pipeline/scripts/run-stop-guard.sh +25 -0
  47. package/pipeline/scripts/uninstall.mjs +1 -0
  48. package/pipeline/scripts/validate-state.mjs +26 -0
  49. package/pipeline/scripts/write-state.mjs +114 -0
  50. package/pipeline/skills/.skill-manifest.json +1 -1
@@ -2,15 +2,18 @@
2
2
  """Decision core for agent-guard.sh (see that file for the hook contract).
3
3
 
4
4
  Reads the PreToolUse payload JSON on stdin and prints exactly one decision:
5
- BLOCK_ATTRIB | BLOCK_FORCE | BLOCK_UNATTENDED<TAB>reason | BLOCK_PARSE | OK
5
+ BLOCK_ATTRIB | BLOCK_FORCE | BLOCK_NOVERIFY<TAB>reason | BLOCK_READONLY<TAB>reason |
6
+ BLOCK_UNATTENDED<TAB>reason | BLOCK_PARSE | ASK<TAB>hook-json |
7
+ DENY<TAB>reason | OK
6
8
 
7
9
  Never executes the command. shlex.split only tokenizes shell-like text; a
8
10
  `$(...)` in the command is a literal token, never a subshell. The processes it
9
11
  does start are read-only `git rev-parse` / `git diff --name-only` calls on a
10
12
  path, with argv and no shell.
11
13
 
12
- Attended (MULTI_AGENT_UNATTENDED unset): the attribution and force-push rules
13
- only, and any error prints OK (fail-open) so a guard bug cannot break a
14
+ Attended (MULTI_AGENT_UNATTENDED unset): the attribution (argv and -F
15
+ message files), force-push and hook-skipping rules, plus the confirmations in
16
+ _guard_rules.ask_reason, and any error prints OK (fail-open) so a guard bug cannot break a
14
17
  legitimate tool call. Unattended: unattended_policy.py is consulted as well,
15
18
  and an error prints BLOCK_PARSE (fail-closed).
16
19
  """
@@ -22,6 +25,9 @@ import signal
22
25
  import sys
23
26
  import unicodedata
24
27
 
28
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
+ import _guard_rules # noqa: E402
30
+
25
31
  # An internal deadline well under the hook timeout: if the guard cannot decide
26
32
  # in this long, it prints the blocking verdict when unattended (fail-closed) and
27
33
  # the allow verdict when attended (a guard bug must not break legitimate work).
@@ -43,6 +49,7 @@ PROTECTED = {"main", "master", "develop"}
43
49
  REMOTES = {"origin", "upstream"}
44
50
  ATTRIBUTION = re.compile(
45
51
  r"co-authored-by:\s*(claude|anthropic|opus|sonnet|haiku|fable)"
52
+ r"|co-authored-by:[^\n]*\bcopilot\b"
46
53
  r"|noreply@anthropic\.com"
47
54
  r"|generated with\s+.*claude"
48
55
  r"|\U0001F916\s*generated"
@@ -276,16 +283,62 @@ def _nested_payload(toks: list):
276
283
  return None
277
284
 
278
285
 
286
+ def _attributed(text: str) -> bool:
287
+ return bool(ATTRIBUTION.search(text) or ATTRIBUTION.search(normalize_for_match(text)))
288
+
289
+
290
+ # The directory the hook payload names as the session's cwd; message files a
291
+ # command names relative to it are read from there.
292
+ PAYLOAD_CWD = {"dir": None}
293
+
294
+
295
+ def _command_rules(cmd: str) -> str:
296
+ """Rules 1b, 3, 4 and 5, judged on the line's simple commands with heredoc
297
+ bodies removed and only where the program is the command word
298
+ (_guard_rules.command_segments / command_word). A `cd` moves later
299
+ segments into another directory."""
300
+ pending_ask = None
301
+ cwd = None
302
+ for seg, toks in _guard_rules.command_segments(cmd):
303
+ if toks and toks[0] in ("{", "}"):
304
+ toks = toks[1:]
305
+ if toks and toks[0] in ("cd", "pushd"):
306
+ cwd = _cd_target(toks, cwd)
307
+ continue
308
+ here = cwd or PAYLOAD_CWD["dir"]
309
+ # Rule 1b: the commit message given as a file (-F / --file).
310
+ for path in _guard_rules.message_files(toks):
311
+ if _attributed(_guard_rules.read_bounded(path, here)):
312
+ return "BLOCK_ATTRIB"
313
+ # Rule 5: history writes in a repository listed as read-only.
314
+ reason = _guard_rules.read_only_git_reason(toks, here)
315
+ if reason:
316
+ return "BLOCK_READONLY\t" + reason
317
+ # Rule 3: skipping the repository's git hooks.
318
+ reason = _guard_rules.no_verify(toks)
319
+ if reason:
320
+ return "BLOCK_NOVERIFY\t" + reason
321
+ # Rule 4: writes a person confirms (issue closure, Jira transition).
322
+ ask = _guard_rules.ask_reason(toks, seg, here)
323
+ if ask and not pending_ask:
324
+ pending_ask = "ASK\t" + ask
325
+ return pending_ask or "OK"
326
+
327
+
279
328
  def decide(cmd: str, depth: int = 0) -> str:
280
329
  low = cmd.lower()
281
330
  segs = segments(flatten(cmd))
282
331
 
283
- # Rule 1: AI attribution inside a git commit.
332
+ # Rule 1: AI attribution inside a git commit, read from what the commit
333
+ # records (_guard_rules.commit_text), never from a neighbouring command.
284
334
  commits = re.search(r"git\s+commit", low) or any(
285
335
  git_subcommand(toks) == "commit" for _, toks in segs
286
336
  )
287
- if commits and (ATTRIBUTION.search(cmd) or ATTRIBUTION.search(normalize_for_match(cmd))):
337
+ if commits and _attributed(_guard_rules.commit_text(cmd, PAYLOAD_CWD["dir"])):
288
338
  return "BLOCK_ATTRIB"
339
+ if commits and _guard_rules.ai_generated(cmd, PAYLOAD_CWD["dir"]):
340
+ return "BLOCK_ATTRIB"
341
+ pending_ask = None
289
342
 
290
343
  # Rule 2: force-push to a protected branch. Evaluate each push segment on its
291
344
  # own so a neighbouring command's flags cannot implicate it. A `cd` earlier
@@ -301,7 +354,9 @@ def decide(cmd: str, depth: int = 0) -> str:
301
354
  payload = _nested_payload(toks)
302
355
  if payload and depth < MAX_DEPTH:
303
356
  verdict = decide(payload, depth + 1)
304
- if verdict != "OK":
357
+ if verdict.startswith("ASK"):
358
+ pending_ask = pending_ask or verdict
359
+ elif verdict != "OK":
305
360
  return verdict
306
361
  continue
307
362
  if git_subcommand(toks) != "push" and not re.search(r"git\s+push", seg.lower()):
@@ -310,7 +365,12 @@ def decide(cmd: str, depth: int = 0) -> str:
310
365
  if verdict != "OK":
311
366
  return verdict
312
367
 
313
- return "OK"
368
+ verdict = _command_rules(cmd)
369
+ if verdict.startswith("ASK"):
370
+ return pending_ask or verdict
371
+ if verdict != "OK":
372
+ return verdict
373
+ return pending_ask or "OK"
314
374
 
315
375
 
316
376
  def _cd_target(toks: list, cwd):
@@ -530,6 +590,46 @@ def fallback_verdict(text: str) -> str:
530
590
  return "OK"
531
591
 
532
592
 
593
+ FILE_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"}
594
+
595
+
596
+ # Permission modes in which a hook's "ask" has no documented prompt to raise.
597
+ # The hooks reference documents "ask" as forcing a prompt (and states it for
598
+ # auto mode); it says nothing for bypassPermissions or dontAsk, where the
599
+ # permission prompt is switched off. There the guard denies with the reason
600
+ # and names the channel the user's answer travels through: the model asks,
601
+ # confirm-command.mjs records a yes for that exact command (sha256, ten
602
+ # minutes, one use), and the rerun finds it. Attended only: unattended, the
603
+ # record is never read.
604
+ NO_PROMPT_MODES = {"bypassPermissions", "dontAsk"}
605
+
606
+
607
+ def ask_verdict(reason: str, data: dict, strict: bool) -> str:
608
+ """ASK<TAB>json for a prompt, DENY<TAB>reason where no prompt can be
609
+ raised, BLOCK_UNATTENDED<TAB>reason when nobody is there to answer."""
610
+ if strict:
611
+ return "BLOCK_UNATTENDED\t" + reason
612
+ if data.get("permission_mode") in NO_PROMPT_MODES:
613
+ # With prompts switched off the native ask cannot be raised, so the
614
+ # recorded confirmation stands in for it. In every other mode the
615
+ # prompt is the confirmation and a record must not skip it.
616
+ if _guard_rules.confirmed((data.get("tool_input") or {}).get("command", "")):
617
+ return "OK"
618
+ return (
619
+ "DENY\t" + reason + " (permission prompts are off in this mode: ask the user with AskUserQuestion; on yes, "
620
+ "record it with node \"$HOME/.claude/scripts/confirm-command.mjs\" --command '<the exact command>' "
621
+ "and run the command again, unchanged, within 10 minutes)"
622
+ )
623
+ out = {
624
+ "hookSpecificOutput": {
625
+ "hookEventName": "PreToolUse",
626
+ "permissionDecision": "ask",
627
+ "permissionDecisionReason": "agent-guard: " + reason,
628
+ }
629
+ }
630
+ return "ASK\t" + json.dumps(out)
631
+
632
+
533
633
  def unattended() -> bool:
534
634
  """MULTI_AGENT_UNATTENDED=1 in the hook's own environment - the launcher's,
535
635
  never anything the run wrote (lib/unattended.sh ma_unattended)."""
@@ -687,9 +787,20 @@ def main() -> None:
687
787
  try:
688
788
  data = json.loads(seen["text"])
689
789
  cmd = (data.get("tool_input") or {}).get("command", "")
790
+ if isinstance(data.get("cwd"), str) and data.get("cwd"):
791
+ PAYLOAD_CWD["dir"] = data["cwd"]
690
792
  if isinstance(cmd, str):
691
793
  seen["text"], seen["raw"] = cmd, False
692
794
  verdict = decide(cmd) if isinstance(cmd, str) and cmd else "OK"
795
+ if verdict == "OK" and data.get("tool_name") in FILE_TOOLS:
796
+ ti = data.get("tool_input") or {}
797
+ reason = _guard_rules.read_only_file_reason(
798
+ ti.get("file_path") or ti.get("notebook_path") or "", PAYLOAD_CWD["dir"]
799
+ )
800
+ if reason:
801
+ verdict = "BLOCK_READONLY\t" + reason
802
+ if verdict.startswith("ASK"):
803
+ verdict = ask_verdict(verdict.split("\t", 1)[1], data, strict)
693
804
  if verdict == "OK" and strict:
694
805
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
695
806
  import unattended_policy
@@ -2,10 +2,20 @@
2
2
  # agent-guard.sh - PreToolUse guard for the multi-agent pipeline.
3
3
  #
4
4
  # Turns prompt-level rules into deterministic, OS-enforced gates:
5
- # 1. No AI/assistant attribution in git commit messages (Co-Authored-By: Claude,
6
- # "Generated with Claude Code", robot emoji, Anthropic no-reply address).
5
+ # 1. No AI/assistant attribution in git commit messages (Co-Authored-By: Claude
6
+ # or Copilot, "Generated with Claude Code", "Generated by AI", robot emoji,
7
+ # Anthropic no-reply address), on the command line or in a `-F` file.
7
8
  # 2. No force-push to a protected branch (main / master / develop), whichever
8
9
  # remote, push options or `git -C` directory the push names.
10
+ # 2b. No skipping the repository's git hooks (commit / push / merge with
11
+ # --no-verify, commit -n, -c core.hooksPath=...).
12
+ # 2c. No literal credential on a Bash command line (argv-secret-scan.mjs).
13
+ # 2e. No Edit / Write, git commit or git push inside a repository listed in
14
+ # prefs.global.readOnlyRepos (a reference checkout).
15
+ # 2d. A person confirms (permissionDecision "ask") closing a GitHub issue, a
16
+ # PR body with Closes/Fixes/Resolves #N and a Jira transition POST. Where
17
+ # the permission mode raises no prompt (bypassPermissions, dontAsk) the
18
+ # call is denied with the reason instead; unattended it is blocked.
9
19
  # 3. With MULTI_AGENT_UNATTENDED=1 in the hook's environment only: no outward
10
20
  # writes (push, PR, issue, tracker publishers, mutating API calls), no
11
21
  # Keychain reads, no fetch outside the network allowlist, no package
@@ -15,7 +25,7 @@
15
25
  # Contract (Claude Code PreToolUse hook, matcher Bash and Edit|Write|NotebookEdit):
16
26
  # - Reads the tool-call JSON on stdin: {"tool_name":..., "tool_input":{...}, "cwd":...}.
17
27
  # - Exit 2 -> BLOCK the tool call (reason on stderr, shown to the model).
18
- # - Exit 0 -> allow.
28
+ # - Exit 0 -> allow, or, with hook JSON on stdout, ask the user to confirm.
19
29
  #
20
30
  # Safety design:
21
31
  # - The command string is ONLY parsed/pattern-matched, NEVER executed or eval'd
@@ -52,6 +62,27 @@ CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
52
62
 
53
63
  DECISION="$(printf '%s' "$PAYLOAD" | CUR_BRANCH="$CUR_BRANCH" python3 "$HELPER" 2>/dev/null || true)"
54
64
 
65
+ # A literal credential on a Bash command line is refused before anything else
66
+ # is said about the call (argv-secret-scan.mjs, the outbound gate's credential
67
+ # shapes). Only rule names are printed, never the value.
68
+ case "$DECISION" in
69
+ OK|ASK*|DENY*)
70
+ case "$PAYLOAD" in
71
+ *'"tool_name":"Bash"'*|*'"tool_name": "Bash"'*)
72
+ if command -v node >/dev/null 2>&1 && [ -f "$HERE/argv-secret-scan.mjs" ]; then
73
+ RULES="$(printf '%s' "$PAYLOAD" | node "$HERE/argv-secret-scan.mjs" 2>/dev/null)"
74
+ if [ "$?" = "7" ]; then
75
+ echo "BLOCKED by agent-guard: the command line carries a literal credential ($RULES)." >&2
76
+ echo "Read it from the credential store or an environment variable instead; a value typed" >&2
77
+ echo "into argv lands in the transcript, the process table and every log that echoes it." >&2
78
+ exit 2
79
+ fi
80
+ elif [ "$STRICT" = "1" ]; then
81
+ refuse_unjudged "node or argv-secret-scan.mjs is unavailable for the credential scan"
82
+ fi ;;
83
+ esac ;;
84
+ esac
85
+
55
86
  case "$DECISION" in
56
87
  BLOCK_ATTRIB)
57
88
  echo "BLOCKED by agent-guard: the commit message carries AI/assistant attribution." >&2
@@ -62,6 +93,22 @@ case "$DECISION" in
62
93
  echo "BLOCKED by agent-guard: force-push to a protected branch (main/master/develop) is not allowed." >&2
63
94
  echo "Rewriting shared history is a data-loss risk. Push a normal commit, or force-push a feature branch." >&2
64
95
  exit 2 ;;
96
+ BLOCK_READONLY*)
97
+ echo "BLOCKED by agent-guard: ${DECISION#BLOCK_READONLY?}." >&2
98
+ echo "Read it, cite it and port the change to the repository you are working in." >&2
99
+ exit 2 ;;
100
+ BLOCK_NOVERIFY*)
101
+ echo "BLOCKED by agent-guard: ${DECISION#BLOCK_NOVERIFY?}." >&2
102
+ echo "The repository's own hooks (lint, tests, secret checks) are part of the commit contract." >&2
103
+ echo "Fix what they report and commit without skipping them." >&2
104
+ exit 2 ;;
105
+ ASK*)
106
+ # The hook JSON the host reads: permissionDecision "ask" forces a prompt.
107
+ printf '%s\n' "${DECISION#ASK?}"
108
+ exit 0 ;;
109
+ DENY*)
110
+ echo "BLOCKED by agent-guard: ${DECISION#DENY?}." >&2
111
+ exit 2 ;;
65
112
  BLOCK_UNATTENDED*)
66
113
  echo "BLOCKED by agent-guard (unattended policy): ${DECISION#BLOCK_UNATTENDED?}." >&2
67
114
  echo "This run has nobody watching it. Outward writes go through pr-request.json and the runner;" >&2
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * argv-secret-scan.mjs - a Bash command line that carries a literal
4
+ * credential is refused before it runs.
5
+ *
6
+ * agent-guard.sh pipes the PreToolUse payload here for every Bash call. A
7
+ * credential typed into a command line lands in the transcript, the shell
8
+ * history of whatever runs it, the process table and any log that echoes
9
+ * argv; the outbound gate (lib/outbound-gate.mjs) already knows the shapes, so
10
+ * its `certain` rules are applied to the command text.
11
+ *
12
+ * Three adjustments keep it to credentials that can leak:
13
+ * - A credential URL to a local host (localhost, 127.x, ::1, *.local) or with
14
+ * a placeholder pair (`postgres:postgres`, `user:pass`) is a dev DSN, not a
15
+ * secret, and is removed before the scan (withoutLocalCredentials).
16
+ * - Shell variable references (`$TOKEN`, `${TOKEN}`) and command
17
+ * substitutions (`$(...)`) are removed before the scan. A command that
18
+ * reads its secret from the environment or the credential store is the
19
+ * correct way to do it, and a clone URL whose password part is
20
+ * `${TOKEN}` must not read as a URL with credentials.
21
+ * - Rules that match a marker rather than a value
22
+ * (`service-account-json`, `private-key-block`) are skipped: grepping for
23
+ * the marker is how an audit finds the leak.
24
+ *
25
+ * Output never contains the matched value, only rule names.
26
+ *
27
+ * Exit: 0 clean (or not a Bash payload), 7 a literal credential found,
28
+ * 2 the payload could not be read.
29
+ *
30
+ * @module pipeline/scripts/argv-secret-scan
31
+ */
32
+
33
+ import { readFileSync } from "node:fs";
34
+
35
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
36
+ import { scan } from "../lib/outbound-gate.mjs";
37
+
38
+ const MARKER_RULES = new Set(["service-account-json", "private-key-block"]);
39
+
40
+ /** The command with variable references and substitutions removed. */
41
+ export function withoutReferences(cmd) {
42
+ let out = String(cmd);
43
+ // Innermost substitutions first, until none are left.
44
+ for (let i = 0; i < 8 && /\$\([^()]*\)/.test(out); i++) out = out.replace(/\$\([^()]*\)/g, "");
45
+ out = out.replace(/`[^`]*`/g, "");
46
+ out = out.replace(/\$\{[^}]*\}/g, "");
47
+ out = out.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, "");
48
+ return out;
49
+ }
50
+
51
+ const URL_CREDS = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/:@]+):([^\s/@]+)@(\[[^\]\s]+\]|[^\s/:?#'"]+)/gi;
52
+ const LOCAL_HOST =
53
+ /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|::1|[\w.-]+\.(?:local|localhost|test|example|invalid))$/i;
54
+ const PLACEHOLDERS = new Set([
55
+ "user",
56
+ "username",
57
+ "pass",
58
+ "password",
59
+ "secret",
60
+ "postgres",
61
+ "root",
62
+ "admin",
63
+ "test",
64
+ "example",
65
+ "changeme",
66
+ "default",
67
+ ]);
68
+
69
+ /**
70
+ * The command with credentials removed from URLs that cannot leak anything:
71
+ * a local host (a dev database DSN), or a placeholder pair (`user:pass`,
72
+ * `postgres:postgres`). A credential URL to any other host is left for the
73
+ * scan.
74
+ */
75
+ export function withoutLocalCredentials(cmd) {
76
+ return String(cmd).replace(URL_CREDS, (all, scheme, user, pass, host) => {
77
+ const placeholder =
78
+ user === pass ||
79
+ (PLACEHOLDERS.has(user.toLowerCase()) && PLACEHOLDERS.has(pass.toLowerCase()));
80
+ return LOCAL_HOST.test(host) || placeholder ? `${scheme}${host}` : all;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Names of the credential rules a command line matches.
86
+ *
87
+ * @param {string} cmd
88
+ * @returns {string[]}
89
+ */
90
+ export function literalSecrets(cmd) {
91
+ if (typeof cmd !== "string" || !cmd) return [];
92
+ const found = scan(withoutLocalCredentials(withoutReferences(cmd))).filter(
93
+ (f) => f.certain && !MARKER_RULES.has(f.rule),
94
+ );
95
+ return [...new Set(found.map((f) => f.rule))];
96
+ }
97
+
98
+ function main() {
99
+ let payload;
100
+ try {
101
+ payload = JSON.parse(readFileSync(0, "utf8"));
102
+ } catch {
103
+ process.exitCode = 2;
104
+ return;
105
+ }
106
+ if (payload?.tool_name !== "Bash") return;
107
+ const rules = literalSecrets(payload?.tool_input?.command);
108
+ if (rules.length) {
109
+ process.stdout.write(`${rules.join(", ")}\n`);
110
+ process.exitCode = 7;
111
+ }
112
+ }
113
+
114
+ if (invokedDirectly(import.meta.url)) main();
@@ -82,6 +82,7 @@ import { runMain } from "../lib/fatal.mjs";
82
82
  import { LIVE_STATUSES, resolveClaudeBin } from "../lib/claude-sessions.mjs";
83
83
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
84
84
  import { readRegularJson } from "../lib/regular-file.mjs";
85
+ import { completionGaps } from "../lib/run-completion.mjs";
85
86
  import { messages, outputLanguage } from "../lib/user-facing.mjs";
86
87
  import {
87
88
  ARTIFACTS_SUBDIR,
@@ -1152,7 +1153,10 @@ export function prFromState(state) {
1152
1153
  *
1153
1154
  * "finished with no PR" is deliberately not "failed": a run can stop
1154
1155
  * legitimately without opening one, and calling that a failure would put a
1155
- * healthy item into the retry path.
1156
+ * healthy item into the retry path. It is only that when the state earns it
1157
+ * (lib/run-completion.mjs): a `complete` written below the mode's last phase,
1158
+ * without Phase 4 evidence or without a plan-coverage verdict is
1159
+ * `stopped-incomplete`, which is retried, not filed as finished.
1156
1160
  */
1157
1161
  export function outcomeFor(supervised, state, prUrl) {
1158
1162
  if (prUrl) return OUTCOME.PR_OPENED;
@@ -1166,7 +1170,9 @@ export function outcomeFor(supervised, state, prUrl) {
1166
1170
  if (rateLimited({ row: supervised.lastRow, state })) return OUTCOME.RATE_LIMITED;
1167
1171
  if (state && state.verificationFailed) return OUTCOME.VERIFICATION_FAILED;
1168
1172
  const status = state && state.status;
1169
- if (status === "complete" || status === "completed") return OUTCOME.COMPLETED_NO_PR;
1173
+ if (status === "complete" || status === "completed") {
1174
+ return completionGaps(state).length ? OUTCOME.STOPPED_INCOMPLETE : OUTCOME.COMPLETED_NO_PR;
1175
+ }
1170
1176
  if (status === "failed") return OUTCOME.FAILED;
1171
1177
  return state ? `stopped-${status || "unknown"}` : OUTCOME.NO_STATE;
1172
1178
  }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * confirm-command.mjs - carry the user's yes for one command to agent-guard.
4
+ *
5
+ * agent-guard asks before a write a person must confirm (closing an issue, a
6
+ * PR body that closes one, a Jira transition POST). In bypassPermissions and
7
+ * dontAsk no permission prompt is raised, so the guard denies and says to ask
8
+ * the user with AskUserQuestion. On a yes, this records it: a file named by the
9
+ * sha256 of the exact command under <log root>/confirmations/. The next time
10
+ * agent-guard sees that exact command it consumes the record and lets it run;
11
+ * a record older than ten minutes, or for any other command, does nothing.
12
+ *
13
+ * Attended only. Under MULTI_AGENT_UNATTENDED=1 nobody gave an answer, so the
14
+ * script refuses (exit 2) and agent-guard never reads a record there.
15
+ *
16
+ * Usage: confirm-command.mjs --command '<the exact command>'
17
+ * Exit: 0 recorded, 2 usage or unattended, 3 could not write
18
+ *
19
+ * @module pipeline/scripts/confirm-command
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { mkdirSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import { runMain } from "../lib/fatal.mjs";
28
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
29
+ import { isUnattended } from "../lib/unattended.mjs";
30
+
31
+ /** Where records live; agent-guard's _guard_rules.confirmations_dir agrees. */
32
+ export function confirmationsDir(env = process.env) {
33
+ const root = env.LOGS_ROOT || join(homedir(), ".claude", "logs", "multi-agent");
34
+ return join(root, "confirmations");
35
+ }
36
+
37
+ /** The record path for one command. */
38
+ export function recordPath(command, env = process.env) {
39
+ const digest = createHash("sha256").update(command, "utf8").digest("hex");
40
+ return join(confirmationsDir(env), `${digest}.json`);
41
+ }
42
+
43
+ function main() {
44
+ const argv = process.argv.slice(2);
45
+ const i = argv.indexOf("--command");
46
+ const command = i === -1 ? undefined : argv[i + 1];
47
+ if (typeof command !== "string" || !command) {
48
+ process.stderr.write("usage: confirm-command.mjs --command '<the exact command>'\n");
49
+ process.exitCode = 2;
50
+ return;
51
+ }
52
+ if (isUnattended()) {
53
+ process.stderr.write("confirm-command: refused, nobody can confirm in an unattended run\n");
54
+ process.exitCode = 2;
55
+ return;
56
+ }
57
+ try {
58
+ mkdirSync(confirmationsDir(), { recursive: true, mode: 0o700 });
59
+ writeFileSync(recordPath(command), `${JSON.stringify({ at: new Date().toISOString() })}\n`, {
60
+ mode: 0o600,
61
+ });
62
+ } catch (err) {
63
+ process.stderr.write(`confirm-command: ${err.message}\n`);
64
+ process.exitCode = 3;
65
+ return;
66
+ }
67
+ process.stdout.write("confirm-command: recorded for 10 minutes, one use\n");
68
+ }
69
+
70
+ if (invokedDirectly(import.meta.url)) runMain("confirm-command", main, { code: 3 });
@@ -61,6 +61,7 @@ import {
61
61
  } from "../lib/unattended-settings-location.mjs";
62
62
  import { prefsHosts, profileGaps } from "../lib/unattended-profile-check.mjs";
63
63
  import { xcodeSkillsStatus } from "../lib/xcode-plugin.mjs";
64
+ import { hookReport } from "../lib/hook-registration.mjs";
64
65
 
65
66
  const HERE = dirname(fileURLToPath(import.meta.url));
66
67
  const HOME = homedir();
@@ -94,6 +95,7 @@ const CHECK_IDS = [
94
95
  "prefs-valid",
95
96
  "identity",
96
97
  "hook-coverage",
98
+ "hook-reachability",
97
99
  "credential-mapping",
98
100
  "credential-liveness",
99
101
  "store-reference",
@@ -515,6 +517,57 @@ function checkHookCoverage() {
515
517
  ok("hook-coverage");
516
518
  }
517
519
 
520
+ // hook-coverage asks whether each template command appears in settings.json
521
+ // at all. This asks what Claude Code will actually run: per event, a matcher
522
+ // that covers the template's tool names, the script present under
523
+ // ~/.claude/scripts, no disableAllHooks; and which preferences do nothing
524
+ // because their hook is missing (lib/hook-registration.mjs).
525
+ function checkHookReachability() {
526
+ const tpl = join(CLAUDE, "templates", "claude-hooks.json");
527
+ if (!existsSync(tpl)) {
528
+ skip("hook-reachability", "no hooks template installed to compare against");
529
+ return;
530
+ }
531
+ const r = hookReport({ templatePath: tpl, home: HOME });
532
+ if (r.error) {
533
+ skip("hook-reachability", r.error);
534
+ return;
535
+ }
536
+ if (r.ok) {
537
+ ok("hook-reachability");
538
+ return;
539
+ }
540
+ const parts = [];
541
+ if (r.disabled) parts.push(r.disabled);
542
+ else if (r.missing.length) {
543
+ const names = [
544
+ ...new Set(
545
+ r.missing.map(
546
+ (m) => `${m.script || m.command} on ${m.event}${m.matcher ? ` ${m.matcher}` : ""}`,
547
+ ),
548
+ ),
549
+ ];
550
+ parts.push(
551
+ `${r.missing.length} template hook(s) Claude Code will not run: ${names.slice(0, 4).join(", ")}${names.length > 4 ? ` and ${names.length - 4} more` : ""}`,
552
+ );
553
+ }
554
+ if (r.inert.length) {
555
+ parts.push(
556
+ `inert preference(s): ${r.inert.map((i) => `${i.pref} (needs ${i.script})`).join(", ")}`,
557
+ );
558
+ }
559
+ report(
560
+ "hook-reachability",
561
+ "WARN",
562
+ parts.join("; "),
563
+ `merge ${tpl} into ${join(CLAUDE, "settings.json")} (or run ${hostCommand("setup", HERE)})`,
564
+ [
565
+ ...r.missing.map((m) => `${m.event} ${m.matcher || "-"} ${m.command}: ${m.why}`),
566
+ ...r.inert.map((i) => `prefs.global.${i.pref} -> ${i.script}`),
567
+ ],
568
+ );
569
+ }
570
+
518
571
  function credentialInventory() {
519
572
  const inv = join(CLAUDE, "lib", "credential-inventory.sh");
520
573
  if (!existsSync(inv)) return null;
@@ -1403,6 +1456,7 @@ function main() {
1403
1456
  checkPrefsValid();
1404
1457
  checkIdentity();
1405
1458
  checkHookCoverage();
1459
+ checkHookReachability();
1406
1460
  checkCredentials(credentialInventory());
1407
1461
  checkStoreReference();
1408
1462
  checkEmbeddedCredentials();
@@ -75,7 +75,7 @@ import { spawnSync } from "node:child_process";
75
75
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
76
76
  import { fileURLToPath } from "node:url";
77
77
 
78
- import { gateContext } from "./gate-ledger.mjs";
78
+ import { ATTENDED_RECORDED, gateContext } from "./gate-ledger.mjs";
79
79
 
80
80
  const LEDGER = fileURLToPath(new URL("./gate-ledger.mjs", import.meta.url));
81
81
 
@@ -203,15 +203,19 @@ function parseArgs(argv) {
203
203
  // the bytes are gone. The verdict is short today; the verdict is also the only
204
204
  // thing a caller reads, so losing half of it is the one failure that matters.
205
205
  let verdictExtra = {};
206
- // Only while the quality gates are active (lib/unattended.mjs, gatesActive):
207
- // one synchronous append, so the verdict and its ledger entry
206
+ // While the quality gates are active (lib/unattended.mjs, gatesActive), and
207
+ // for the build and test claims of an attended run: one synchronous append, so the verdict and its ledger entry
208
208
  // cannot come apart. A ledger problem is reported and never changes the verdict.
209
209
  let ledgerClaim = null;
210
210
  let ledgerState = null;
211
211
  function ledger(verdict, reason, extra) {
212
212
  if (!ledgerClaim) return;
213
213
  const ctx = gateContext({ statePath: ledgerState });
214
- if (!ctx.active) return;
214
+ // Attended, only the build and test claims are recorded (marked attended):
215
+ // the attended commit check reads them. Without a run state there is
216
+ // nothing to record into.
217
+ if (!ctx.active && !(ctx.statePath && ATTENDED_RECORDED.has(`evidence-gate/${ledgerClaim}`)))
218
+ return;
215
219
  const detail = { reason, ...(extra.summary ? { summary: extra.summary } : {}) };
216
220
  if (verdictExtra.stack) detail.stack = verdictExtra.stack;
217
221
  const argv = [LEDGER, "append", "--gate", `evidence-gate/${ledgerClaim}`, "--verdict", verdict];