@mmerterden/multi-agent-pipeline 20.27.2 → 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 (99) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -6
  3. package/README.tr.md +6 -5
  4. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
  5. package/docs/architecture.md +4 -4
  6. package/docs/ecosystem.md +5 -5
  7. package/docs/facts.json +14 -12
  8. package/docs/features.md +2 -0
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +112 -0
  12. package/install/index.mjs +0 -2
  13. package/install/templates/claude-hooks.json +30 -1
  14. package/manifest.json +101 -67
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +1 -0
  17. package/pipeline/agents/test-gen-analyst.md +98 -0
  18. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
  22. package/pipeline/contract/types/index.d.ts +1 -1
  23. package/pipeline/lib/hook-registration.mjs +252 -0
  24. package/pipeline/lib/prefs-value.mjs +44 -0
  25. package/pipeline/lib/run-completion.mjs +148 -0
  26. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  27. package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
  28. package/pipeline/multi-agent-refs/features/doctor.md +24 -0
  29. package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
  30. package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
  31. package/pipeline/multi-agent-refs/help/en.md +1 -0
  32. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  33. package/pipeline/multi-agent-refs/phases/operations.md +19 -2
  34. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  35. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  36. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
  37. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  38. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  39. package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
  40. package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
  41. package/pipeline/preferences-template.json +10 -0
  42. package/pipeline/rules/figma-pipeline.md +2 -0
  43. package/pipeline/schemas/agent-state.schema.json +20 -5
  44. package/pipeline/schemas/phases.json +3 -2
  45. package/pipeline/schemas/phases.schema.json +5 -1
  46. package/pipeline/schemas/prefs.schema.json +51 -0
  47. package/pipeline/schemas/runs-index.schema.json +1 -1
  48. package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
  49. package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
  50. package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
  51. package/pipeline/schemas/test-gen-results.schema.json +44 -0
  52. package/pipeline/scripts/_active-run.mjs +158 -0
  53. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  54. package/pipeline/scripts/_guard_rules.py +605 -0
  55. package/pipeline/scripts/_handle-patterns.mjs +166 -0
  56. package/pipeline/scripts/_http-post.mjs +141 -0
  57. package/pipeline/scripts/_stack-routing.mjs +4 -0
  58. package/pipeline/scripts/_test-gaming.mjs +227 -0
  59. package/pipeline/scripts/_unified-diff.mjs +104 -0
  60. package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
  61. package/pipeline/scripts/agent-guard.py +118 -7
  62. package/pipeline/scripts/agent-guard.sh +50 -3
  63. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  64. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  65. package/pipeline/scripts/confirm-command.mjs +70 -0
  66. package/pipeline/scripts/doctor.mjs +105 -0
  67. package/pipeline/scripts/evidence-gate.mjs +82 -7
  68. package/pipeline/scripts/gate-ledger.mjs +163 -7
  69. package/pipeline/scripts/handle-diff-audit.mjs +188 -0
  70. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  71. package/pipeline/scripts/pre-commit-check.sh +12 -0
  72. package/pipeline/scripts/routine-registry.mjs +5 -1
  73. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  74. package/pipeline/scripts/run-scope-guard.sh +23 -0
  75. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  76. package/pipeline/scripts/run-stop-guard.sh +25 -0
  77. package/pipeline/scripts/run-ui-tests.sh +27 -3
  78. package/pipeline/scripts/test-classify.mjs +296 -0
  79. package/pipeline/scripts/test-gen-gate.mjs +409 -0
  80. package/pipeline/scripts/test-gen-state.mjs +313 -0
  81. package/pipeline/scripts/test-integrity-gate.mjs +76 -11
  82. package/pipeline/scripts/uninstall.mjs +2 -0
  83. package/pipeline/scripts/usage-register-beacon.mjs +239 -0
  84. package/pipeline/scripts/usage-register.mjs +128 -33
  85. package/pipeline/scripts/usage-report.mjs +43 -33
  86. package/pipeline/scripts/validate-state.mjs +26 -0
  87. package/pipeline/scripts/write-state.mjs +114 -0
  88. package/pipeline/skills/.skill-manifest.json +23 -3
  89. package/pipeline/skills/.skills-index.json +57 -2
  90. package/pipeline/skills/shared/README.md +10 -5
  91. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  92. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
  93. package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
  94. package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
  95. package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
  96. package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
  97. package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
  98. package/pipeline/skills/skills-index.md +7 -2
  99. package/install/_telemetry.mjs +0 -61
@@ -0,0 +1,605 @@
1
+ """Command rules agent-guard.py applies attended and unattended alike.
2
+
3
+ None of these executes anything. The only side effects are reading a commit or
4
+ PR message file the command names (bounded by MAX_FILE_BYTES), because a
5
+ message given with `-F file` is invisible to a rule that reads argv alone, and
6
+ consuming a confirmation record (confirm-command.mjs) the user's answer left.
7
+
8
+ A rule judges a program only where it is the segment's command word: after
9
+ any VAR=value assignments and the wrappers in WRAPPERS. Text that merely
10
+ mentions `git push --no-verify` (an echo, a printf, a heredoc commit message)
11
+ is not that program. Heredoc bodies are removed before these rules see the
12
+ line (strip_heredoc_bodies); the attribution rules read them separately, since
13
+ a body is where a commit message usually is.
14
+
15
+ command_segments(cmd) the simple commands of a line, heredoc bodies out
16
+ command_word(toks) (assignments, argv) with wrappers stripped
17
+ no_verify(toks) a git commit / push / merge that skips the repo's
18
+ git hooks (--no-verify and its prefixes, commit -n,
19
+ -c core.hooksPath, HUSKY=0, GIT_CONFIG_* hooksPath),
20
+ or a `git config core.hooksPath` write
21
+ message_text(cmd, cwd) the commit message text a line carries
22
+ ask_reason(toks, seg) a write a person should confirm: closing a GitHub
23
+ issue, a PR body that closes one, a Jira transition
24
+ confirmed(cmd) a fresh confirmation of exactly this command
25
+ read_only_* a commit, push or file write into a repository
26
+ listed in prefs.global.readOnlyRepos
27
+ """
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import shlex
33
+ import time
34
+
35
+ MAX_FILE_BYTES = 256 * 1024
36
+ CONFIRM_TTL_SECONDS = 600
37
+
38
+ # Closing keywords GitHub acts on, followed by an issue reference.
39
+ CLOSING = re.compile(
40
+ r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s+(?:[\w.-]+/[\w.-]+)?#\d+",
41
+ re.IGNORECASE,
42
+ )
43
+ # "Generated by AI", "generated with an AI assistant"; never "ai-sdk".
44
+ AI_GENERATED = re.compile(
45
+ r"generated\s+(?:by|with)\s+(?:an?\s+)?(?:[\w-]+\s+){0,3}?ai\b(?![-\w])",
46
+ re.IGNORECASE,
47
+ )
48
+ JIRA_TRANSITION = re.compile(r"/rest/api/(?:\d+|latest)/issue/[^/\s'\"]+/transitions\b", re.IGNORECASE)
49
+ POST_METHOD = re.compile(r"(?:^|\s)(?:-X|--request)\s*=?\s*['\"]?post\b", re.IGNORECASE)
50
+ BODY_FLAG = re.compile(r"(?:^|\s)(?:-d|--data[\w-]*|--json|-F|--form)(?:\s|=|$)")
51
+ HEREDOC_OPEN = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
52
+ ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
53
+
54
+ GIT_VALUE_OPTIONS = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"}
55
+ COMMIT_VALUE_LETTERS = set("mFCct")
56
+ COMMIT_VALUE_LONG = {
57
+ "--message", "--file", "--reuse-message", "--reedit-message", "--template", "--author",
58
+ "--date", "--fixup", "--squash", "--trailer", "--cleanup", "--pathspec-from-file",
59
+ }
60
+ HOOK_SKIPPING_SUBS = {"commit", "push", "merge"}
61
+ NO_VERIFY_MIN = "--no-veri"
62
+ GH_ALIASES = {"i": "issue", "pr": "pr", "co": "pr"}
63
+ WRITE_SUBS = {"commit", "push", "merge", "cherry-pick", "revert", "am", "rebase", "tag"}
64
+ HTTP_CLIENTS = {"curl", "wget", "http", "https", "xh"}
65
+ # Wrappers that run the rest of the line as the command, with the options
66
+ # that take a value.
67
+ WRAPPERS = {
68
+ "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U"},
69
+ "doas": {"-u", "-C"},
70
+ "command": set(),
71
+ "builtin": set(),
72
+ "exec": {"-a"},
73
+ "env": {"-u", "--unset", "-C", "--chdir", "-S", "--split-string"},
74
+ "xargs": {"-I", "-L", "-n", "-P", "-s", "-d", "-E", "-a"},
75
+ "nice": {"-n"},
76
+ "nohup": set(),
77
+ "time": set(),
78
+ "timeout": {"-s", "-k", "--signal", "--kill-after"},
79
+ }
80
+
81
+
82
+ def strip_heredoc_bodies(cmd):
83
+ """(cmd without heredoc bodies, [bodies]). Line-based and lenient: a body
84
+ runs from the line after `<<DELIM` to the line that is DELIM (tabs allowed
85
+ before it for `<<-`); an unterminated one runs to the end."""
86
+ lines = cmd.split("\n")
87
+ out, bodies, pending, i = [], [], [], 0
88
+ while i < len(lines):
89
+ line = lines[i]
90
+ out.append(line)
91
+ pending.extend((m.group(1) == "-", m.group(3)) for m in HEREDOC_OPEN.finditer(line))
92
+ i += 1
93
+ while pending and i <= len(lines):
94
+ dash, delim = pending.pop(0)
95
+ body = []
96
+ while i < len(lines):
97
+ cand = lines[i].lstrip("\t") if dash else lines[i]
98
+ i += 1
99
+ if cand.strip() == delim:
100
+ break
101
+ body.append(lines[i - 1])
102
+ bodies.append("\n".join(body))
103
+ return "\n".join(out), bodies
104
+
105
+
106
+ def split_simple(cmd):
107
+ """Simple-command segments, split on `;`, `&&`, `||`, `|`, newlines and
108
+ unquoted `(`, `)` and backticks. Quote-aware, so an operator inside a quoted
109
+ commit message is text."""
110
+ segs, cur, q, i = [], [], None, 0
111
+ while i < len(cmd):
112
+ c = cmd[i]
113
+ if q == "'":
114
+ cur.append(c)
115
+ q = None if c == "'" else q
116
+ i += 1
117
+ continue
118
+ if c == "\\" and i + 1 < len(cmd):
119
+ cur.append(cmd[i:i + 2])
120
+ i += 2
121
+ continue
122
+ if q == '"':
123
+ if c == '"':
124
+ q = None
125
+ cur.append(c)
126
+ elif cmd.startswith("$(", i):
127
+ segs.append("".join(cur))
128
+ cur = []
129
+ i += 2
130
+ continue
131
+ else:
132
+ cur.append(c)
133
+ i += 1
134
+ continue
135
+ if c in ("'", '"'):
136
+ q = c
137
+ cur.append(c)
138
+ i += 1
139
+ continue
140
+ two = cmd[i:i + 2]
141
+ if two in ("&&", "||", "|&"):
142
+ segs.append("".join(cur))
143
+ cur = []
144
+ i += 2
145
+ continue
146
+ if c in (";", "|", "\n", "(", ")", "`"):
147
+ segs.append("".join(cur))
148
+ cur = []
149
+ i += 1
150
+ continue
151
+ cur.append(c)
152
+ i += 1
153
+ segs.append("".join(cur))
154
+ return [s for s in segs if s.strip()]
155
+
156
+
157
+ # shlex grows a token one character at a time, so a megabyte argument takes
158
+ # longer than the guard's deadline. Past this length a segment is split on
159
+ # whitespace with quotes and backslashes removed, as agent-guard.tokenize does.
160
+ SHLEX_MAX_CHARS = 16384
161
+
162
+
163
+ def tokens(seg):
164
+ if len(seg) > SHLEX_MAX_CHARS:
165
+ return [re.sub(r"[\"'\\]", "", t) for t in seg.split()]
166
+ try:
167
+ return shlex.split(seg)
168
+ except ValueError:
169
+ return seg.split()
170
+
171
+
172
+ def command_segments(cmd):
173
+ """[(segment, tokens)] for the simple commands of a line, bodies removed."""
174
+ stripped, _ = strip_heredoc_bodies(cmd)
175
+ return [(s, tokens(s)) for s in split_simple(stripped)]
176
+
177
+
178
+ def command_word(toks):
179
+ """(assignments, argv): leading VAR=value assignments (and those `env`
180
+ sets), then the command with the WRAPPERS in front of it consumed."""
181
+ assigns, i = [], 0
182
+ while i < len(toks):
183
+ t = toks[i]
184
+ if ASSIGN.match(t):
185
+ assigns.append(t)
186
+ i += 1
187
+ continue
188
+ base = os.path.basename(t)
189
+ if base not in WRAPPERS:
190
+ break
191
+ value_opts = WRAPPERS[base]
192
+ i += 1
193
+ while i < len(toks) and (toks[i].startswith("-") or (base == "env" and ASSIGN.match(toks[i]))):
194
+ if ASSIGN.match(toks[i]):
195
+ assigns.append(toks[i])
196
+ elif toks[i] in value_opts:
197
+ i += 1
198
+ i += 1
199
+ if base == "timeout" and i < len(toks) and re.match(r"^[0-9]", toks[i]):
200
+ i += 1
201
+ return assigns, toks[i:]
202
+
203
+
204
+ def _git_split(toks):
205
+ """(assignments, global_options, subcommand, args) when the segment's
206
+ command word is git, else None."""
207
+ assigns, argv = command_word(toks)
208
+ if not argv or os.path.basename(argv[0]).lower() != "git":
209
+ return None
210
+ opts, j = [], 1
211
+ while j < len(argv):
212
+ o = argv[j]
213
+ if o in GIT_VALUE_OPTIONS:
214
+ opts.append((o, argv[j + 1] if j + 1 < len(argv) else ""))
215
+ j += 2
216
+ elif o.startswith("-"):
217
+ key, _, val = o.partition("=")
218
+ opts.append((key, val))
219
+ j += 1
220
+ else:
221
+ return assigns, opts, o.lower(), argv[j + 1:]
222
+ return None
223
+
224
+
225
+ def _hookspath_assign(assigns):
226
+ for a in assigns:
227
+ name, _, val = a.partition("=")
228
+ low = val.lower().replace(" ", "")
229
+ if name == "HUSKY" and low in ("0", "false"):
230
+ return "HUSKY=0 switches the repository's git hooks off"
231
+ if (name == "GIT_CONFIG_PARAMETERS" or name.startswith("GIT_CONFIG_KEY_")) and "hookspath" in low:
232
+ return f"{name} overrides core.hooksPath and skips the repository's git hooks"
233
+ return None
234
+
235
+
236
+ def no_verify(toks):
237
+ """A reason when the segment skips the repository's own git hooks."""
238
+ assigns, argv = command_word(toks)
239
+ if argv and argv[0] == "export":
240
+ reason = _hookspath_assign([a for a in argv[1:] if ASSIGN.match(a)])
241
+ if reason:
242
+ return reason
243
+ split = _git_split(toks)
244
+ if not split:
245
+ return None
246
+ assigns, opts, sub, args = split
247
+ if sub == "config":
248
+ reading = {"--get", "--get-all", "--get-regexp", "--list", "-l", "--unset", "--unset-all", "--show-origin"}
249
+ keys = [a for a in args if not a.startswith("-")]
250
+ if keys and keys[0].lower() == "core.hookspath" and len(keys) > 1 and not reading & set(args):
251
+ return "git config core.hooksPath points git away from the repository's hooks"
252
+ return None
253
+ if sub not in HOOK_SKIPPING_SUBS:
254
+ return None
255
+ reason = _hookspath_assign(assigns)
256
+ if reason:
257
+ return reason
258
+ for key, val in opts:
259
+ if key == "-c" and val.lower().replace(" ", "").startswith("core.hookspath="):
260
+ return f"git {sub} with core.hooksPath overridden skips the repository's git hooks"
261
+ i = 0
262
+ while i < len(args):
263
+ a = args[i]
264
+ if a == "--":
265
+ break
266
+ if len(a) >= len(NO_VERIFY_MIN) and "--no-verify".startswith(a):
267
+ return f"git {sub} {a} skips the repository's git hooks"
268
+ if sub == "commit":
269
+ if a in COMMIT_VALUE_LONG:
270
+ i += 2
271
+ continue
272
+ if a.startswith("-") and not a.startswith("--") and len(a) > 1:
273
+ for ch in a[1:]:
274
+ if ch == "n":
275
+ return "git commit -n skips the repository's git hooks"
276
+ if ch in COMMIT_VALUE_LETTERS:
277
+ if ch == a[-1]:
278
+ i += 1 # the value is the next token
279
+ break
280
+ i += 1
281
+ return None
282
+
283
+
284
+ def message_files(toks):
285
+ """Paths a `git commit` reads its message from (`-F`, `--file`)."""
286
+ split = _git_split(toks)
287
+ if not split or split[2] != "commit":
288
+ return []
289
+ out, args, i = [], split[3], 0
290
+ while i < len(args):
291
+ a = args[i]
292
+ if a in ("-F", "--file"):
293
+ if i + 1 < len(args):
294
+ out.append(args[i + 1])
295
+ i += 2
296
+ continue
297
+ if a.startswith("--file="):
298
+ out.append(a.split("=", 1)[1])
299
+ elif a.startswith("-") and not a.startswith("--") and "F" in a[1:]:
300
+ rest = a[a.index("F") + 1:]
301
+ if rest:
302
+ out.append(rest)
303
+ elif i + 1 < len(args):
304
+ out.append(args[i + 1])
305
+ i += 1
306
+ i += 1
307
+ return [p for p in out if p and p != "-"]
308
+
309
+
310
+ def _message_args(toks):
311
+ split = _git_split(toks)
312
+ if not split or split[2] != "commit":
313
+ return []
314
+ out, args, i = [], split[3], 0
315
+ while i < len(args):
316
+ a = args[i]
317
+ if a in ("-m", "--message"):
318
+ if i + 1 < len(args):
319
+ out.append(args[i + 1])
320
+ i += 2
321
+ continue
322
+ if a.startswith("--message="):
323
+ out.append(a.split("=", 1)[1])
324
+ elif a.startswith("-m") and len(a) > 2:
325
+ out.append(a[2:])
326
+ i += 1
327
+ return out
328
+
329
+
330
+ def message_text(cmd, cwd):
331
+ """The commit message text a line carries: -m values, -F files and, when
332
+ the line commits, its heredoc bodies (the `-F -` and `$(cat <<EOF)` forms)."""
333
+ stripped, bodies = strip_heredoc_bodies(cmd)
334
+ parts, commits = [], False
335
+ for seg in split_simple(stripped):
336
+ toks = tokens(seg)
337
+ split = _git_split(toks)
338
+ if not split or split[2] != "commit":
339
+ continue
340
+ commits = True
341
+ parts.extend(_message_args(toks))
342
+ parts.extend(read_bounded(p, cwd) for p in message_files(toks))
343
+ if commits:
344
+ parts.extend(bodies)
345
+ return "\n".join(parts)
346
+
347
+
348
+ def commit_text(cmd, cwd):
349
+ """What a commit on this line records: every word of each git commit
350
+ segment (its -m, --author, --trailer and -c values), the -F files it
351
+ names, the line's heredoc bodies, and the values assigned to variables
352
+ on the line, which a message can expand. The other commands on the line
353
+ are left out, so an echo beside a commit is not its message, unless the
354
+ commit reads a message file the hook cannot judge from disk: one that does
355
+ not exist yet, or one the same line writes. Then the whole line counts,
356
+ since the text the file will hold is on it."""
357
+ stripped, bodies = strip_heredoc_bodies(cmd)
358
+ parts, assigned, others, files, commits = [], [], [], [], False
359
+ for seg in split_simple(stripped):
360
+ toks = tokens(seg)
361
+ assigned.extend(t.partition("=")[2] for t in toks if ASSIGN.match(t))
362
+ split = _git_split(toks)
363
+ if not split or split[2] != "commit":
364
+ others.append(seg)
365
+ continue
366
+ commits = True
367
+ parts.extend(toks)
368
+ for path in message_files(toks):
369
+ files.append(path)
370
+ parts.append(read_bounded(path, cwd))
371
+ if not commits:
372
+ return ""
373
+ if any(_unjudged_message_file(f, cwd, others) for f in files):
374
+ parts.append(stripped)
375
+ return "\n".join(parts + bodies + assigned)
376
+
377
+
378
+ def _unjudged_message_file(path, cwd, others):
379
+ """True when a -F file's text on disk is not what the commit will read:
380
+ it is missing, or another command on the line names it (a redirect or
381
+ tee writing it before the commit runs)."""
382
+ if path == "-":
383
+ return False
384
+ p = os.path.expanduser(path)
385
+ if not os.path.isabs(p):
386
+ p = os.path.join(cwd or os.getcwd(), p)
387
+ if not os.path.isfile(p):
388
+ return True
389
+ names = {path, os.path.basename(path)}
390
+ return any(n and n in seg for seg in others for n in names)
391
+
392
+
393
+ def ai_generated(cmd, cwd):
394
+ """True when the commit message says it was generated by an AI."""
395
+ return bool(AI_GENERATED.search(message_text(cmd, cwd)))
396
+
397
+
398
+ def read_bounded(path, cwd):
399
+ """The text of a message file, or "" when it cannot be read."""
400
+ p = os.path.expanduser(path)
401
+ if not os.path.isabs(p):
402
+ p = os.path.join(cwd or os.getcwd(), p)
403
+ try:
404
+ if not os.path.isfile(p):
405
+ return ""
406
+ with open(p, "rb") as fh:
407
+ return fh.read(MAX_FILE_BYTES).decode("utf-8", "replace")
408
+ except OSError:
409
+ return ""
410
+
411
+
412
+ def _flag_values(args, names, short=None):
413
+ vals, i = [], 0
414
+ while i < len(args):
415
+ a = args[i]
416
+ if a in names or (short and a == short):
417
+ if i + 1 < len(args):
418
+ vals.append(args[i + 1])
419
+ i += 2
420
+ continue
421
+ for n in names:
422
+ if a.startswith(n + "="):
423
+ vals.append(a.split("=", 1)[1])
424
+ i += 1
425
+ return vals
426
+
427
+
428
+ def _gh_ask(argv, cwd):
429
+ if not argv or os.path.basename(argv[0]) != "gh":
430
+ return None
431
+ args = argv[1:]
432
+ cleaned, i = [], 0
433
+ while i < len(args):
434
+ if args[i] in ("-R", "--repo"):
435
+ i += 2
436
+ continue
437
+ cleaned.append(args[i])
438
+ i += 1
439
+ plains = [a for a in cleaned if not a.startswith("-")]
440
+ if not plains:
441
+ return None
442
+ group = GH_ALIASES.get(plains[0], plains[0])
443
+ sub = plains[1] if len(plains) > 1 else ""
444
+ if group == "issue" and sub == "close":
445
+ return "gh issue close closes a tracked issue; the user confirms every issue closure"
446
+ if group == "pr" and sub in ("create", "edit"):
447
+ bodies = _flag_values(cleaned, {"--body"}, "-b")
448
+ bodies += [read_bounded(p, cwd) for p in _flag_values(cleaned, {"--body-file"}, "-F")]
449
+ if any(CLOSING.search(b or "") for b in bodies):
450
+ return (
451
+ f"gh pr {sub} with a closing keyword (Closes/Fixes/Resolves #N) closes the issue on merge; "
452
+ "use Ref: #N or Related: #N, or confirm the closure"
453
+ )
454
+ if group == "api":
455
+ joined = " ".join(cleaned)
456
+ if re.search(r"issues/\d+\b", joined) and re.search(r"\bstate=closed\b", joined):
457
+ return "gh api closing an issue (state=closed); the user confirms every issue closure"
458
+ return None
459
+
460
+
461
+ def _jira_ask(argv, seg):
462
+ if not argv or os.path.basename(argv[0]) not in HTTP_CLIENTS:
463
+ return None
464
+ if not JIRA_TRANSITION.search(seg):
465
+ return None
466
+ if POST_METHOD.search(seg) or BODY_FLAG.search(seg):
467
+ return "a Jira transition POST moves the issue through its workflow; the user confirms each transition"
468
+ return None
469
+
470
+
471
+ def ask_reason(toks, seg, cwd=None):
472
+ """Why a person should confirm this segment, or None."""
473
+ _, argv = command_word(toks)
474
+ return _gh_ask(argv, cwd) or _jira_ask(argv, seg)
475
+
476
+
477
+ # --- confirmations ---------------------------------------------------------------
478
+
479
+
480
+ def confirmations_dir(env=None):
481
+ env = os.environ if env is None else env
482
+ root = env.get("LOGS_ROOT") or os.path.join(os.path.expanduser("~"), ".claude", "logs", "multi-agent")
483
+ return os.path.join(root, "confirmations")
484
+
485
+
486
+ def command_digest(cmd):
487
+ return hashlib.sha256(cmd.encode("utf-8")).hexdigest()
488
+
489
+
490
+ def confirmed(cmd, env=None):
491
+ """True, and the record consumed, when confirm-command.mjs recorded the
492
+ user's yes for exactly this command less than CONFIRM_TTL_SECONDS ago."""
493
+ if not isinstance(cmd, str) or not cmd:
494
+ return False
495
+ path = os.path.join(confirmations_dir(env), command_digest(cmd) + ".json")
496
+ try:
497
+ fresh = time.time() - os.stat(path).st_mtime < CONFIRM_TTL_SECONDS
498
+ except OSError:
499
+ return False
500
+ try:
501
+ os.remove(path)
502
+ except OSError:
503
+ return False
504
+ return fresh
505
+
506
+
507
+ # --- read-only repositories ----------------------------------------------------
508
+
509
+
510
+ def prefs_candidates(env=None):
511
+ env = os.environ if env is None else env
512
+ home = env.get("HOME") or os.path.expanduser("~")
513
+ return [
514
+ p
515
+ for p in (
516
+ env.get("MULTI_AGENT_PREFS"),
517
+ os.path.join(home, ".claude", "multi-agent-preferences.json"),
518
+ os.path.join(home, ".config", "multi-agent-pipeline", "multi-agent-preferences.json"),
519
+ )
520
+ if p
521
+ ]
522
+
523
+
524
+ def read_only_roots(env=None):
525
+ """prefs.global.readOnlyRepos[], expanded and real-pathed."""
526
+ for p in prefs_candidates(env):
527
+ if not os.path.isfile(p):
528
+ continue
529
+ try:
530
+ with open(p, encoding="utf-8") as fh:
531
+ roots = (json.load(fh).get("global") or {}).get("readOnlyRepos") or []
532
+ except (OSError, ValueError, AttributeError):
533
+ return []
534
+ out = []
535
+ for r in roots if isinstance(roots, list) else []:
536
+ if isinstance(r, str) and r.strip():
537
+ out.append(os.path.realpath(os.path.expanduser(r.strip())))
538
+ return out
539
+ return []
540
+
541
+
542
+ def _under(path, root):
543
+ a = os.path.normcase(path)
544
+ b = os.path.normcase(root)
545
+ return a == b or a.startswith(b.rstrip(os.sep) + os.sep)
546
+
547
+
548
+ def read_only_root_of(path, roots):
549
+ """The read-only root that contains `path`, or None."""
550
+ if not path or not roots:
551
+ return None
552
+ real = os.path.realpath(os.path.expanduser(path))
553
+ for r in roots:
554
+ if _under(real, r):
555
+ return r
556
+ return None
557
+
558
+
559
+ def git_write_dir(toks, cwd):
560
+ """(subcommand, directory) for a git call that writes history or a remote."""
561
+ split = _git_split(toks)
562
+ if not split or split[2] not in WRITE_SUBS:
563
+ return None
564
+ _, opts, sub, _ = split
565
+ base = cwd or os.getcwd()
566
+ for key, val in opts:
567
+ if key in ("-C", "--work-tree") and val:
568
+ base = val if os.path.isabs(val) else os.path.join(base, val)
569
+ elif key == "--git-dir" and val:
570
+ base = os.path.dirname(val.rstrip("/")) if os.path.isabs(val) else os.path.join(base, val, "..")
571
+ return sub, os.path.normpath(base)
572
+
573
+
574
+ _ROOTS = {"loaded": False, "roots": []}
575
+
576
+
577
+ def cached_read_only_roots():
578
+ if not _ROOTS["loaded"]:
579
+ _ROOTS["roots"], _ROOTS["loaded"] = read_only_roots(), True
580
+ return _ROOTS["roots"]
581
+
582
+
583
+ def read_only_file_reason(path, cwd):
584
+ """Why an Edit / Write to `path` is refused, or None."""
585
+ if not isinstance(path, str) or not path:
586
+ return None
587
+ p = os.path.expanduser(path)
588
+ if not os.path.isabs(p):
589
+ p = os.path.join(cwd or os.getcwd(), p)
590
+ root = read_only_root_of(p, cached_read_only_roots())
591
+ if root:
592
+ return f"{path} is inside {root}, which prefs.global.readOnlyRepos lists as read-only"
593
+ return None
594
+
595
+
596
+ def read_only_git_reason(toks, cwd):
597
+ """Why a git commit / push (or another history write) is refused, or None."""
598
+ hit = git_write_dir(toks, cwd)
599
+ if not hit:
600
+ return None
601
+ sub, directory = hit
602
+ root = read_only_root_of(directory, cached_read_only_roots())
603
+ if root:
604
+ return f"git {sub} in {root}, which prefs.global.readOnlyRepos lists as read-only"
605
+ return None