@mmerterden/multi-agent-pipeline 20.23.2 → 20.25.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 (123) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +8 -6
  3. package/README.tr.md +7 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -6
  7. package/docs/features.md +9 -4
  8. package/install/catalog-history.json +1 -1
  9. package/install/templates/claude-hooks.json +14 -1
  10. package/install/templates/codex-hooks.json +18 -0
  11. package/manifest.json +126 -98
  12. package/package.json +1 -1
  13. package/pipeline/agents/task-clarifier.md +1 -1
  14. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
  15. package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
  16. package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
  17. package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
  18. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  19. package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
  20. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  21. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  22. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
  23. package/pipeline/contract/fixtures/runs-failed.json +1 -1
  24. package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
  25. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
  26. package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
  27. package/pipeline/contract/fixtures/runs-running.json +1 -1
  28. package/pipeline/lib/analysis-jira-write.sh +70 -0
  29. package/pipeline/lib/credential-inventory.sh +5 -5
  30. package/pipeline/lib/decisions.mjs +130 -0
  31. package/pipeline/lib/fetch-crashlytics.sh +6 -8
  32. package/pipeline/lib/model-rung.sh +1 -1
  33. package/pipeline/lib/plan-todos.sh +177 -37
  34. package/pipeline/lib/post-pr-review.sh +4 -6
  35. package/pipeline/lib/route-state.sh +1 -1
  36. package/pipeline/lib/shadow-git.sh +4 -5
  37. package/pipeline/lib/task-graph.mjs +446 -0
  38. package/pipeline/lib/tdd-evidence.mjs +579 -0
  39. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  40. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  41. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -11
  42. package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
  43. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  44. package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
  45. package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
  46. package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
  47. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  48. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  49. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  50. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  51. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  52. package/pipeline/multi-agent-refs/help/en.md +2 -0
  53. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  54. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  55. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  56. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
  57. package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
  58. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  59. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  60. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  61. package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
  62. package/pipeline/rules/response-shape.md +18 -0
  63. package/pipeline/schemas/agent-state.schema.json +36 -1
  64. package/pipeline/schemas/phases.json +4 -0
  65. package/pipeline/schemas/plan-todos.schema.json +104 -3
  66. package/pipeline/schemas/planning-output.schema.json +46 -0
  67. package/pipeline/schemas/prefs.schema.json +122 -3
  68. package/pipeline/scripts/_code-graph.mjs +2 -2
  69. package/pipeline/scripts/_jsonl.mjs +151 -0
  70. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  71. package/pipeline/scripts/_redact.mjs +124 -10
  72. package/pipeline/scripts/_transcript-hosts.mjs +406 -0
  73. package/pipeline/scripts/analysis-story-tree.mjs +153 -8
  74. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  75. package/pipeline/scripts/build-stack-plugins.mjs +3 -3
  76. package/pipeline/scripts/capture-flush.sh +7 -7
  77. package/pipeline/scripts/code-graph-rules/android.json +1 -1
  78. package/pipeline/scripts/cost-analyze.mjs +32 -43
  79. package/pipeline/scripts/cost-table.json +35 -19
  80. package/pipeline/scripts/decisions-log.mjs +118 -0
  81. package/pipeline/scripts/dep-bump.mjs +1556 -0
  82. package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
  83. package/pipeline/scripts/edit-syntax-gate.sh +32 -0
  84. package/pipeline/scripts/find-polluter.mjs +281 -0
  85. package/pipeline/scripts/gate-ledger.mjs +46 -13
  86. package/pipeline/scripts/gc-abandoned.sh +2 -2
  87. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  88. package/pipeline/scripts/git-path.mjs +1 -2
  89. package/pipeline/scripts/host-hooks.mjs +265 -0
  90. package/pipeline/scripts/jira-attach.sh +1 -1
  91. package/pipeline/scripts/keychain.py +3 -3
  92. package/pipeline/scripts/mutation-probe.mjs +484 -0
  93. package/pipeline/scripts/output-quality-check.sh +1 -1
  94. package/pipeline/scripts/phase-tracker.sh +5 -6
  95. package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
  96. package/pipeline/scripts/plan-complexity.mjs +259 -0
  97. package/pipeline/scripts/pre-push-check.sh +3 -6
  98. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  99. package/pipeline/scripts/rework-escalation.mjs +264 -0
  100. package/pipeline/scripts/test-strength.mjs +5 -3
  101. package/pipeline/scripts/transcript-index.mjs +1131 -0
  102. package/pipeline/scripts/uninstall.mjs +90 -4
  103. package/pipeline/scripts/update-issue-progress.sh +4 -6
  104. package/pipeline/scripts/usage-report.mjs +2 -2
  105. package/pipeline/scripts/validate-planning.mjs +48 -6
  106. package/pipeline/skills/.skill-manifest.json +20 -12
  107. package/pipeline/skills/.skills-index.json +23 -1
  108. package/pipeline/skills/shared/README.md +5 -3
  109. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
  110. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
  111. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
  112. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
  113. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
  114. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  115. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
  116. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
  117. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
  118. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
  119. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  120. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  121. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  122. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  123. package/pipeline/skills/skills-index.md +3 -1
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mutation-probe.mjs - would the tests notice if a changed line were wrong?
4
+ *
5
+ * A passing suite says the changed code runs, not that the tests check what it
6
+ * does. This makes a handful of small textual mutants, only on lines the diff
7
+ * added or changed, runs the test command against each, and reports every
8
+ * mutant the tests did not catch. A survivor is a line whose behaviour can be
9
+ * inverted without one test going red.
10
+ *
11
+ * Mutations are textual and limited to patterns that read the same in most
12
+ * languages, each needing whitespace on both sides of the operator so generics,
13
+ * arrows and unary signs are never touched:
14
+ *
15
+ * comparison == != === !== < <= > >= flipped to the opposite
16
+ * boolean true/false, True/False negated
17
+ * arithmetic binary + and - swapped
18
+ * return `return <number>` 0 becomes 1, anything else 0
19
+ *
20
+ * Comment lines and matches inside a string literal or after `//` are skipped.
21
+ * Only source files are mutated (SOURCE_EXTENSIONS): docs and config such as
22
+ * package.json, Markdown or YAML carry `true` and ` - ` too, but flipping them
23
+ * says nothing about the tests. Test files (every stack adapter's
24
+ * testFilePatterns) are never mutated, and neither is a symlink or any path
25
+ * whose real location is outside the temporary worktree.
26
+ * Mutants are spread across lines: one per changed line in diff order, then a
27
+ * second per line, until --max-mutants.
28
+ *
29
+ * The caller's checkout is never written: mutants are applied in a detached
30
+ * worktree under --work-root (the system temp directory by default, never
31
+ * inside the repo), which is removed again, including on SIGINT/SIGTERM. Only
32
+ * `.git/worktrees` metadata is written, and it is pruned on the way out.
33
+ *
34
+ * Usage:
35
+ * node mutation-probe.mjs --repo <dir> --base <ref> --test-cmd "<command>"
36
+ * [--head <ref>|worktree] [--max-mutants n] [--timeout-sec n]
37
+ * [--link <repo-relative path>]... [--work-root <dir>] [--out <file>] [--json]
38
+ *
39
+ * The test command runs from the worktree root through /bin/sh. --head
40
+ * worktree probes the uncommitted working tree (test-strength.mjs snapshot).
41
+ * --max-mutants and --timeout-sec default to `mutationProbe.maxMutants` and
42
+ * `mutationProbe.timeoutSec` in the prefs file ($MULTI_AGENT_PREFS, else
43
+ * ~/.claude/multi-agent-preferences.json), then to 5 and 120.
44
+ *
45
+ * Output: {base, head, testCommand, baseline, mutants[], findings[]}. Each
46
+ * finding is reviewer-shaped (severity, file, line, issue, fix) plus
47
+ * `mutation`, `testCommand` and `tag: "mutation_probe"`, so Phase 3 Step 3.0
48
+ * merges it like the other deterministic findings.
49
+ *
50
+ * Exit: 0 every mutant killed (or timed out) · 1 at least one survivor ·
51
+ * 2 usage or setup error · 4 nothing judged (no mutable changed line,
52
+ * or the test command does not pass before mutation)
53
+ *
54
+ * @module pipeline/scripts/mutation-probe
55
+ */
56
+
57
+ import { spawnSync } from "node:child_process";
58
+ import {
59
+ existsSync,
60
+ lstatSync,
61
+ mkdtempSync,
62
+ readFileSync,
63
+ realpathSync,
64
+ rmSync,
65
+ symlinkSync,
66
+ writeFileSync,
67
+ } from "node:fs";
68
+ import { homedir, tmpdir } from "node:os";
69
+ import { extname, join, relative, resolve, sep } from "node:path";
70
+
71
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
72
+ import { parseDiffFiles } from "./_diff-impact.mjs";
73
+ import { testFileMatcher } from "./_stack-adapter.mjs";
74
+ import { runCommand, snapshotWorktree } from "./test-strength.mjs";
75
+
76
+ const DEFAULT_MAX_MUTANTS = 5;
77
+ const DEFAULT_TIMEOUT_SEC = 120;
78
+ const WORKTREE_HEAD = "worktree";
79
+
80
+ class UsageError extends Error {}
81
+
82
+ const COMPARISON = [
83
+ ["===", "!=="],
84
+ ["!==", "==="],
85
+ ["==", "!="],
86
+ ["!=", "=="],
87
+ ["<=", ">"],
88
+ [">=", "<"],
89
+ ["<", ">="],
90
+ [">", "<="],
91
+ ];
92
+
93
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+
95
+ const RULES = [
96
+ ...COMPARISON.map(([from, to]) => ({
97
+ re: new RegExp(`(?<=\\s)${escapeRe(from)}(?=\\s)`, "g"),
98
+ to,
99
+ })),
100
+ { re: /\btrue\b/g, to: "false" },
101
+ { re: /\bfalse\b/g, to: "true" },
102
+ { re: /\bTrue\b/g, to: "False" },
103
+ { re: /\bFalse\b/g, to: "True" },
104
+ { re: /(?<=\s)\+(?=\s)/g, to: "-" },
105
+ { re: /(?<=\s)-(?=\s)/g, to: "+" },
106
+ ];
107
+
108
+ const SOURCE_EXTENSIONS = new Set(
109
+ "swift kt kts java js jsx mjs cjs ts tsx mts cts py go rb rs c h cc cpp hpp m mm cs dart php scala"
110
+ .split(" ")
111
+ .map((e) => `.${e}`),
112
+ );
113
+
114
+ /** True for a path whose extension names a programming-language source file. */
115
+ export function isSourceFile(path) {
116
+ return SOURCE_EXTENSIONS.has(extname(path).toLowerCase());
117
+ }
118
+
119
+ const RETURN_NUMBER = /\breturn\s+(-?\d+(?:\.\d+)?)\b/g;
120
+ const COMMENT_START = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
121
+
122
+ /** True when `index` sits inside a quoted string or after a `//` comment. */
123
+ function shielded(line, index) {
124
+ let quote = null;
125
+ for (let i = 0; i < index; i++) {
126
+ const c = line[i];
127
+ if (quote) {
128
+ if (c === "\\") i++;
129
+ else if (c === quote) quote = null;
130
+ continue;
131
+ }
132
+ if (c === '"' || c === "'" || c === "`") quote = c;
133
+ else if (c === "/" && line[i + 1] === "/") return true;
134
+ }
135
+ return quote !== null;
136
+ }
137
+
138
+ /**
139
+ * Every single-site mutation of one source line, in rule order.
140
+ * @param {string} line
141
+ * @returns {Array<{index: number, from: string, to: string, replacement: string}>}
142
+ */
143
+ export function candidateMutations(line) {
144
+ if (COMMENT_START.test(line)) return [];
145
+ const out = [];
146
+ const add = (index, from, to) => {
147
+ if (shielded(line, index)) return;
148
+ out.push({
149
+ index,
150
+ from,
151
+ to,
152
+ replacement: line.slice(0, index) + to + line.slice(index + from.length),
153
+ });
154
+ };
155
+ for (const { re, to } of RULES) {
156
+ for (const m of line.matchAll(re)) add(m.index, m[0], to);
157
+ }
158
+ for (const m of line.matchAll(RETURN_NUMBER)) {
159
+ const num = m[1];
160
+ const at = m.index + m[0].length - num.length;
161
+ add(at, num, Number(num) === 0 ? "1" : "0");
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * New-side lines the diff added or changed, per surviving file.
168
+ * @param {string} diffText
169
+ * @returns {Array<{file: string, lines: number[]}>}
170
+ */
171
+ export function changedLines(diffText) {
172
+ const out = [];
173
+ for (const f of parseDiffFiles(diffText)) {
174
+ if (!f.newPath) continue;
175
+ const lines = [];
176
+ for (const r of f.newRanges) {
177
+ if (r.gap) continue;
178
+ for (let l = r.start; l <= r.end; l++) lines.push(l);
179
+ }
180
+ if (lines.length) out.push({ file: f.newPath, lines });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Up to `max` mutants, one per changed line in diff order before any line gets
187
+ * a second.
188
+ * @param {Array<{file: string, lines: number[]}>} changed
189
+ * @param {Map<string, string>} sources file -> content
190
+ * @param {number} max
191
+ */
192
+ export function pickMutants(changed, sources, max) {
193
+ const perLine = [];
194
+ for (const { file, lines } of changed) {
195
+ const text = sources.get(file);
196
+ if (typeof text !== "string") continue;
197
+ const rows = text.split("\n");
198
+ for (const line of lines) {
199
+ const src = rows[line - 1];
200
+ if (src === undefined) continue;
201
+ const c = candidateMutations(src);
202
+ if (c.length) perLine.push({ file, line, candidates: c });
203
+ }
204
+ }
205
+ const picked = [];
206
+ for (let round = 0; picked.length < max; round++) {
207
+ let any = false;
208
+ for (const entry of perLine) {
209
+ const c = entry.candidates[round];
210
+ if (!c) continue;
211
+ any = true;
212
+ picked.push({
213
+ file: entry.file,
214
+ line: entry.line,
215
+ mutation: `"${c.from}" -> "${c.to}"`,
216
+ replacement: c.replacement,
217
+ });
218
+ if (picked.length >= max) break;
219
+ }
220
+ if (!any) break;
221
+ }
222
+ return picked;
223
+ }
224
+
225
+ function git(cwd, args, { timeout = 60000 } = {}) {
226
+ const res = spawnSync("git", ["-C", cwd, ...args], {
227
+ encoding: "utf8",
228
+ timeout,
229
+ maxBuffer: 64 * 1024 * 1024,
230
+ });
231
+ return { ok: res.status === 0, out: (res.stdout || "").trim(), err: (res.stderr || "").trim() };
232
+ }
233
+
234
+ function readProbePrefs() {
235
+ const candidates = [
236
+ process.env.MULTI_AGENT_PREFS,
237
+ join(process.env.HOME || homedir(), ".claude", "multi-agent-preferences.json"),
238
+ ].filter(Boolean);
239
+ for (const p of candidates) {
240
+ if (!existsSync(p)) continue;
241
+ try {
242
+ const g = JSON.parse(readFileSync(p, "utf8"))?.global ?? {};
243
+ return {
244
+ maxMutants: g.mutationProbe?.maxMutants,
245
+ timeoutSec: g.mutationProbe?.timeoutSec,
246
+ };
247
+ } catch {
248
+ return {};
249
+ }
250
+ }
251
+ return {};
252
+ }
253
+
254
+ const positive = (v) => (Number.isInteger(v) && v > 0 ? v : undefined);
255
+
256
+ function parseArgs(argv) {
257
+ const opts = { links: [], head: "HEAD" };
258
+ for (let i = 0; i < argv.length; i++) {
259
+ const a = argv[i];
260
+ const val = () => {
261
+ const v = argv[++i];
262
+ if (v === undefined) throw new UsageError(`${a} needs a value`);
263
+ return v;
264
+ };
265
+ if (a === "--repo") opts.repo = val();
266
+ else if (a === "--base") opts.base = val();
267
+ else if (a === "--head") opts.head = val();
268
+ else if (a === "--test-cmd") opts.testCmd = val();
269
+ else if (a === "--max-mutants") opts.maxMutants = Number(val());
270
+ else if (a === "--timeout-sec") opts.timeoutSec = Number(val());
271
+ else if (a === "--link") opts.links.push(val());
272
+ else if (a === "--work-root") opts.workRoot = val();
273
+ else if (a === "--out") opts.out = val();
274
+ else if (a === "--json") opts.json = true;
275
+ else throw new UsageError(`unexpected argument ${a}`);
276
+ }
277
+ if (!opts.base) throw new UsageError("--base is required");
278
+ if (!opts.testCmd) throw new UsageError("--test-cmd is required");
279
+ const prefs = readProbePrefs();
280
+ opts.maxMutants ??= positive(prefs.maxMutants) ?? DEFAULT_MAX_MUTANTS;
281
+ opts.timeoutSec ??= positive(prefs.timeoutSec) ?? DEFAULT_TIMEOUT_SEC;
282
+ if (!positive(opts.maxMutants)) throw new UsageError("--max-mutants must be a positive integer");
283
+ if (!(opts.timeoutSec > 0)) throw new UsageError("--timeout-sec must be a positive number");
284
+ return opts;
285
+ }
286
+
287
+ function within(child, parent) {
288
+ const rel = relative(parent, child);
289
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && rel !== "..");
290
+ }
291
+
292
+ /**
293
+ * True when `full` is a regular file (not a symlink) whose real path lies
294
+ * inside `realRoot`, so writing it cannot reach anything outside the worktree.
295
+ */
296
+ function writableInside(full, realRoot) {
297
+ let st;
298
+ try {
299
+ st = lstatSync(full);
300
+ } catch {
301
+ return false;
302
+ }
303
+ if (!st.isFile()) return false;
304
+ let real;
305
+ try {
306
+ real = realpathSync(full);
307
+ } catch {
308
+ return false;
309
+ }
310
+ return real !== realRoot && within(real, realRoot);
311
+ }
312
+
313
+ function prepare(opts) {
314
+ const repoDir = resolve(opts.repo || process.cwd());
315
+ const top = git(repoDir, ["rev-parse", "--show-toplevel"]);
316
+ if (!top.ok) throw new UsageError(`not a git repository: ${repoDir}`);
317
+ const root = realpathSync(top.out);
318
+ const sha = (ref) => {
319
+ const r = git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
320
+ if (!r.ok) throw new UsageError(`cannot resolve ${ref}`);
321
+ return r.out;
322
+ };
323
+ const base = sha(opts.base);
324
+ const head = opts.head === WORKTREE_HEAD ? snapshotWorktree(root) : sha(opts.head);
325
+ const workRoot = realpathSync(resolve(opts.workRoot || tmpdir()));
326
+ if (within(workRoot, root)) {
327
+ throw new UsageError(`--work-root ${workRoot} is inside the repository; refusing`);
328
+ }
329
+ return { root, base, head, workRoot };
330
+ }
331
+
332
+ /**
333
+ * @returns {Promise<{exit: number, report: object}>}
334
+ */
335
+ export async function mutationProbe(argv) {
336
+ const opts = parseArgs(argv);
337
+ const { root, base, head, workRoot } = prepare(opts);
338
+ const timeoutMs = Math.round(opts.timeoutSec * 1000);
339
+ const isTest = testFileMatcher();
340
+
341
+ const diff = git(root, ["diff", "--no-renames", "-U0", base, head]);
342
+ if (!diff.ok) throw new UsageError(`git diff failed: ${diff.err}`);
343
+ const changed = changedLines(diff.out).filter((c) => isSourceFile(c.file) && !isTest(c.file));
344
+
345
+ const report = {
346
+ base,
347
+ head,
348
+ testCommand: opts.testCmd,
349
+ maxMutants: opts.maxMutants,
350
+ baseline: null,
351
+ mutants: [],
352
+ findings: [],
353
+ worktreeRemoved: null,
354
+ };
355
+
356
+ const holder = mkdtempSync(join(workRoot, "mutation-probe-"));
357
+ const worktree = join(holder, "wt");
358
+ const cleanup = () => {
359
+ git(root, ["worktree", "remove", "--force", worktree]);
360
+ rmSync(holder, { recursive: true, force: true });
361
+ git(root, ["worktree", "prune"]);
362
+ };
363
+ let activeGroup = 0;
364
+ const track = { onSpawn: (pid) => (activeGroup = pid) };
365
+ const onSignal = (sig) => {
366
+ // The test command runs detached, in its own process group, so it would
367
+ // outlive this process and keep writing into the worktree being removed.
368
+ if (activeGroup) {
369
+ try {
370
+ process.kill(-activeGroup, "SIGKILL");
371
+ } catch (err) {
372
+ if (err.code !== "ESRCH") throw err;
373
+ }
374
+ }
375
+ cleanup();
376
+ process.exit(sig === "SIGINT" ? 130 : 143);
377
+ };
378
+ process.once("SIGINT", onSignal);
379
+ process.once("SIGTERM", onSignal);
380
+
381
+ let exit;
382
+ try {
383
+ const add = git(root, ["worktree", "add", "--detach", worktree, head], { timeout: 120000 });
384
+ if (!add.ok) throw new Error(`git worktree add failed: ${add.err}`);
385
+ for (const rel of opts.links) {
386
+ const src = join(root, rel);
387
+ const dst = join(worktree, rel);
388
+ if (existsSync(src) && !existsSync(dst) && within(dst, worktree)) symlinkSync(src, dst);
389
+ }
390
+
391
+ const realWorktree = realpathSync(worktree);
392
+ const sources = new Map();
393
+ for (const { file } of changed) {
394
+ const full = join(worktree, file);
395
+ if (writableInside(full, realWorktree)) sources.set(file, readFileSync(full, "utf8"));
396
+ }
397
+ const mutants = pickMutants(changed, sources, opts.maxMutants);
398
+ if (mutants.length === 0) {
399
+ report.reason = "no changed line carries a supported mutation";
400
+ return { exit: 4, report };
401
+ }
402
+
403
+ const base0 = await runCommand(opts.testCmd, worktree, timeoutMs, track);
404
+ activeGroup = 0;
405
+ report.baseline = base0.timedOut ? "timeout" : base0.code === 0 ? "pass" : "fail";
406
+ if (report.baseline !== "pass") {
407
+ report.reason = "the test command does not pass before mutation, so a red run proves nothing";
408
+ return { exit: 4, report };
409
+ }
410
+
411
+ for (const m of mutants) {
412
+ const full = join(worktree, m.file);
413
+ if (!writableInside(full, realWorktree)) continue;
414
+ const original = sources.get(m.file);
415
+ const rows = original.split("\n");
416
+ rows[m.line - 1] = m.replacement;
417
+ writeFileSync(full, rows.join("\n"));
418
+ let run;
419
+ try {
420
+ run = await runCommand(opts.testCmd, worktree, timeoutMs, track);
421
+ } finally {
422
+ activeGroup = 0;
423
+ writeFileSync(full, original);
424
+ }
425
+ const status = run.timedOut
426
+ ? "timeout"
427
+ : run.code === null
428
+ ? "error"
429
+ : run.code === 0
430
+ ? "survived"
431
+ : "killed";
432
+ report.mutants.push({ file: m.file, line: m.line, mutation: m.mutation, status });
433
+ if (status === "survived") {
434
+ report.findings.push({
435
+ severity: "important",
436
+ file: m.file,
437
+ line: m.line,
438
+ issue: `Mutant survived: ${m.mutation} on this changed line and \`${opts.testCmd}\` still passes, so no test checks what this line decides.`,
439
+ fix: "Add an assertion that fails when this line's operator or value is inverted.",
440
+ mutation: m.mutation,
441
+ testCommand: opts.testCmd,
442
+ tag: "mutation_probe",
443
+ });
444
+ }
445
+ }
446
+ exit = report.findings.length > 0 ? 1 : 0;
447
+ } finally {
448
+ cleanup();
449
+ process.removeListener("SIGINT", onSignal);
450
+ process.removeListener("SIGTERM", onSignal);
451
+ report.worktreeRemoved = !existsSync(worktree) && !existsSync(holder);
452
+ }
453
+ return { exit, report };
454
+ }
455
+
456
+ async function main(argv) {
457
+ let result;
458
+ try {
459
+ result = await mutationProbe(argv);
460
+ } catch (err) {
461
+ process.stderr.write(`mutation-probe: ${err.message}\n`);
462
+ if (err instanceof UsageError) {
463
+ process.stderr.write(
464
+ "usage: mutation-probe.mjs --repo <dir> --base <ref> --test-cmd <cmd> [--head <ref>|worktree] [--max-mutants n] [--timeout-sec n] [--link path] [--work-root dir] [--out file] [--json]\n",
465
+ );
466
+ }
467
+ return 2;
468
+ }
469
+ const { exit, report } = result;
470
+ const outIdx = argv.indexOf("--out");
471
+ if (outIdx !== -1) writeFileSync(argv[outIdx + 1], `${JSON.stringify(report, null, 2)}\n`);
472
+ if (argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
473
+ else {
474
+ for (const m of report.mutants) {
475
+ process.stdout.write(`${m.status.padEnd(8)} ${m.file}:${m.line} ${m.mutation}\n`);
476
+ }
477
+ if (report.reason) process.stdout.write(`${report.reason}\n`);
478
+ }
479
+ return exit;
480
+ }
481
+
482
+ if (invokedDirectly(import.meta.url)) {
483
+ main(process.argv.slice(2)).then((code) => process.exit(code));
484
+ }
@@ -121,7 +121,7 @@ try:
121
121
  except Exception:
122
122
  sys.exit(0)
123
123
  # named, decimal and hex - a pattern covering only the named forms would miss
124
- # exactly the two spellings the first version of the decoder missed.
124
+ # the decimal and hex spellings.
125
125
  pat = re.compile(r'&(amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|#[0-9]+|#[xX][0-9a-fA-F]+);')
126
126
  for p in d.get('phases', []):
127
127
  n = p.get('name', '')
@@ -230,8 +230,8 @@ usage_live_ping() {
230
230
  #
231
231
  # The program is fed through a QUOTED heredoc, not `node -e '...'`. Inside a
232
232
  # single-quoted shell string every apostrophe in the JS (and in its comments)
233
- # terminates the string early; shellcheck flags it as SC2140 and the first version
234
- # here worked only by accident of where the quotes happened to fall. A quoted
233
+ # terminates the string early; shellcheck flags it as SC2140, and such code works
234
+ # only by accident of where the quotes happen to fall. A quoted
235
235
  # heredoc passes the body through verbatim, so the JS can use any quote it likes.
236
236
  decode_entities() {
237
237
  local raw="$1" out
@@ -561,7 +561,7 @@ release_state_lock() {
561
561
  trap release_state_lock EXIT
562
562
 
563
563
  # The cost table is keyed by family ("opus", "sonnet", "gpt-5.6"), but the name
564
- # an agent has at hand is the full id ("claude-opus-5"). Storing the id verbatim
564
+ # an agent has at hand is the full id ("claude-opus-5-5"). Storing the id verbatim
565
565
  # priced every phase at "-" while the tracker looked perfectly healthy, so the
566
566
  # family is resolved here, once, and an unresolvable name is said out loud
567
567
  # instead of quietly costing nothing.
@@ -1183,9 +1183,8 @@ case "$ACTION" in
1183
1183
  esac
1184
1184
  # Accounting gate. A phase that ran LLM work and recorded nothing produces a
1185
1185
  # completion line with nothing to say and a cost breakdown that reads as
1186
- # unavailable - which is exactly how per-phase token reporting went missing
1187
- # for months while every gate stayed green, because the gates lint the docs
1188
- # for the CALL and nothing checks that the call happened. Refusing the
1186
+ # unavailable. The gates lint the docs for the CALL; only this point can
1187
+ # check that the call happened. Refusing the
1189
1188
  # completion is recoverable (record, then re-run) and loses no work; a
1190
1189
  # phase that genuinely made no LLM call says so with --no-llm.
1191
1190
  if [ "$STATUS" = "completed" ] && [ "$NO_LLM" -eq 0 ]; then
@@ -146,9 +146,9 @@ export function evaluate(state, extraInput = "", { env = process.env } = {}) {
146
146
  );
147
147
  }
148
148
 
149
- // Step 3 picks the base branch and Step 8 creates the worktree. A run that skipped
150
- // both develops on whatever the checkout happened to have, which is how a Jira task
151
- // got implemented on a stale local branch with no picker ever shown. The state file
149
+ // Step 3 picks the base branch and Step 8 creates the worktree. A run that skips
150
+ // both develops on whatever the checkout happens to have: a Jira task implemented
151
+ // on a stale local branch with no picker ever shown. The state file
152
152
  // is the only durable evidence those steps ran, so it has to carry their output.
153
153
  //
154
154
  // `local` provider runs still choose a base branch; what they skip is push and PR.