@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.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -6
- package/README.tr.md +6 -5
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
- package/docs/architecture.md +4 -4
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +2 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/index.mjs +0 -2
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +101 -67
- package/package.json +4 -1
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/test-gen-analyst.md +98 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
- package/pipeline/multi-agent-refs/features/doctor.md +24 -0
- package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/operations.md +19 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
- package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
- package/pipeline/preferences-template.json +10 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +51 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
- package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
- package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
- package/pipeline/schemas/test-gen-results.schema.json +44 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_handle-patterns.mjs +166 -0
- package/pipeline/scripts/_http-post.mjs +141 -0
- package/pipeline/scripts/_stack-routing.mjs +4 -0
- package/pipeline/scripts/_test-gaming.mjs +227 -0
- package/pipeline/scripts/_unified-diff.mjs +104 -0
- package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +105 -0
- package/pipeline/scripts/evidence-gate.mjs +82 -7
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/handle-diff-audit.mjs +188 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +5 -1
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/run-ui-tests.sh +27 -3
- package/pipeline/scripts/test-classify.mjs +296 -0
- package/pipeline/scripts/test-gen-gate.mjs +409 -0
- package/pipeline/scripts/test-gen-state.mjs +313 -0
- package/pipeline/scripts/test-integrity-gate.mjs +76 -11
- package/pipeline/scripts/uninstall.mjs +2 -0
- package/pipeline/scripts/usage-register-beacon.mjs +239 -0
- package/pipeline/scripts/usage-register.mjs +128 -33
- package/pipeline/scripts/usage-report.mjs +43 -33
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +23 -3
- package/pipeline/skills/.skills-index.json +57 -2
- package/pipeline/skills/shared/README.md +10 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
- package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
- package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
- package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
- package/pipeline/skills/skills-index.md +7 -2
- 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
|