@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,259 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file plan-complexity.mjs - how big each plan task is, measured rather than guessed.
5
+ *
6
+ * A planner asked "is this task too big" answers from the task's title. This
7
+ * answers from the repo: what the task touches and what depends on that. The
8
+ * score is a sum of points over five counted factors, so two runs over the
9
+ * same plan and graph give the same answer, and every point can be traced to
10
+ * a number.
11
+ *
12
+ * files files the task lists 0-4 points
13
+ * (1 | 2-3 | 4-5 | 6-8 | 9+)
14
+ * fanIn distinct non-test files that depend 0-3 points
15
+ * on the task's files' declarations, (0 | 1-3 | 4-10 | 11+)
16
+ * one ring out in the code graph,
17
+ * the task's own files excluded
18
+ * modules modules the task's non-test files 0-2 points
19
+ * span, beyond the first
20
+ * crossModuleEdges fan-in files outside every module 0-2 points
21
+ * the task touches (0 | 1-3 | 4+)
22
+ * testSurface test files the task lists plus the 0-2 points
23
+ * test files that depend on its (0-2 | 3-5 | 6+)
24
+ * files in the graph
25
+ *
26
+ * score = 1 + points, capped at 10. A task scoring above the split threshold
27
+ * (prefs.global.planComplexity.splitAbove, default 7) gets
28
+ * `recommendSplit: true`; Phase 1 splits it before the plan is approved.
29
+ *
30
+ * A module is the SPM target under a `Sources` / `Tests` segment
31
+ * (`Sources/<Target>/`, `Tests/<Target>Tests/`), else the directory above the
32
+ * first source-root segment (`src`, `test`, `tests`, `lib`, or a file loose
33
+ * under `Sources` / `Tests`), which is the package or Gradle module; a path
34
+ * with no such segment belongs to its first directory, and a top-level file
35
+ * to `.`.
36
+ *
37
+ * Without a code graph, fanIn and crossModuleEdges are null and score no
38
+ * points, and `graph` says `absent`: the score then reads the file list only,
39
+ * and says so instead of passing for a full measurement. A file the graph does
40
+ * not know (usually one the task creates) is counted in `notIndexed`.
41
+ *
42
+ * Reuses graph-affected.mjs's reverse walk as a module; nothing here runs git
43
+ * or reaches the network. Zero runtime dependencies (ADR-0004).
44
+ *
45
+ * Usage:
46
+ * plan-complexity.mjs <plan.json|-> [--graph <path>] [--split-above N]
47
+ * [--prefs <path>] [--write]
48
+ *
49
+ * --graph code graph; default ~/.claude/knowledge/<cwd name>/code-graph.json
50
+ * when it exists. A missing file means no graph.
51
+ * --split-above threshold; wins over the prefs file
52
+ * --prefs preferences file; default ~/.claude/multi-agent-preferences.json
53
+ * --write also store {score, recommendSplit, factors} as each task's
54
+ * `complexity` in the plan file (planning-output.schema.json)
55
+ *
56
+ * Output: {splitAbove, graph: "used"|"absent"|"unreadable", split: [ids],
57
+ * tasks: [{id, score, recommendSplit, factors, points}]}
58
+ *
59
+ * Exit codes: 0 scored, 1 plan unreadable or without tasks, 64 usage.
60
+ *
61
+ * @module pipeline/scripts/plan-complexity
62
+ */
63
+
64
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
65
+ import { homedir } from "node:os";
66
+ import { join } from "node:path";
67
+ import { affected } from "./graph-affected.mjs";
68
+ import { defaultGraphPath, loadGraph } from "./graph-query.mjs";
69
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
70
+
71
+ export const DEFAULT_SPLIT_ABOVE = 7;
72
+ const MAX_SCORE = 10;
73
+ const SOURCE_ROOTS = new Set(["Sources", "Tests", "src", "test", "tests", "lib"]);
74
+ const TEST_PATH = [
75
+ /Tests?\.(swift|kt|ts|tsx|js|mjs|py)$/i,
76
+ /(^|\/)(__tests__|Tests|tests|test|androidTest)\//,
77
+ /\.(test|spec)\.(ts|tsx|js|mjs|py)$/i,
78
+ /(^|\/)test_[^/]+\.py$/,
79
+ ];
80
+
81
+ const isTestPath = (path) => TEST_PATH.some((r) => r.test(path));
82
+
83
+ const SPM_ROOTS = new Set(["Sources", "Tests"]);
84
+
85
+ /**
86
+ * The module a repo-relative path belongs to. Under an SPM `Sources/` or
87
+ * `Tests/` root the target directory is the module (`Sources/<Target>/...`,
88
+ * `Tests/<Target>Tests/...`), so two targets of one package are two modules;
89
+ * otherwise the directory above the first source root, then the first
90
+ * directory, then the repository root.
91
+ */
92
+ export function moduleOf(path) {
93
+ const parts = String(path).split("/").filter(Boolean);
94
+ const at = parts.findIndex((p, i) => i < parts.length - 1 && SOURCE_ROOTS.has(p));
95
+ if (at !== -1 && SPM_ROOTS.has(parts[at]) && at + 1 < parts.length - 1) return parts[at + 1];
96
+ if (at > 0) return parts.slice(0, at).join("/");
97
+ if (at === 0 || parts.length < 2) return ".";
98
+ return parts[0];
99
+ }
100
+
101
+ const band = (n, limits) => limits.filter((l) => n >= l).length;
102
+
103
+ function points(f) {
104
+ return {
105
+ files: band(f.files, [2, 4, 6, 9]),
106
+ fanIn: f.fanIn == null ? 0 : band(f.fanIn, [1, 4, 11]),
107
+ modules: Math.min(2, Math.max(0, f.modules - 1)),
108
+ crossModuleEdges: f.crossModuleEdges == null ? 0 : band(f.crossModuleEdges, [1, 4]),
109
+ testSurface: band(f.testSurface, [3, 6]),
110
+ };
111
+ }
112
+
113
+ /** Dependent files of everything `files` declares, one ring out. */
114
+ function dependentsInGraph(graph, files) {
115
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
116
+ const seeds = [];
117
+ let notIndexed = 0;
118
+ for (const f of files) {
119
+ const node = byId.get(`file:${f}`);
120
+ if (node) seeds.push(node);
121
+ else notIndexed++;
122
+ }
123
+ const hits = seeds.length ? affected({ graph, seeds, depth: 1 }) : [];
124
+ const own = new Set(files);
125
+ const code = new Set();
126
+ const tests = new Set();
127
+ for (const h of hits) {
128
+ const p = h.node?.path;
129
+ if (!p || own.has(p)) continue;
130
+ (h.node.isTest || isTestPath(p) ? tests : code).add(p);
131
+ }
132
+ return { code, tests, notIndexed };
133
+ }
134
+
135
+ function scoreTask(task, graph, splitAbove) {
136
+ const files = [...new Set((Array.isArray(task.files) ? task.files : []).map(String))];
137
+ const ownTests = files.filter(isTestPath);
138
+ const modules = new Set(files.filter((f) => !isTestPath(f)).map(moduleOf));
139
+ const factors = {
140
+ files: files.length,
141
+ fanIn: null,
142
+ modules: modules.size,
143
+ crossModuleEdges: null,
144
+ testSurface: ownTests.length,
145
+ notIndexed: null,
146
+ };
147
+ if (graph) {
148
+ const d = dependentsInGraph(graph, files);
149
+ factors.fanIn = d.code.size;
150
+ factors.crossModuleEdges = [...d.code].filter((p) => !modules.has(moduleOf(p))).length;
151
+ factors.testSurface = new Set([...ownTests, ...d.tests]).size;
152
+ factors.notIndexed = d.notIndexed;
153
+ }
154
+ const pts = points(factors);
155
+ const score = Math.min(MAX_SCORE, 1 + Object.values(pts).reduce((s, n) => s + n, 0));
156
+ return {
157
+ id: String(task.id),
158
+ score,
159
+ recommendSplit: score > splitAbove,
160
+ factors,
161
+ points: pts,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * @param {Array<{id: string, files?: string[]}>} tasks
167
+ * @param {{graph?: object|null, splitAbove?: number, graphState?: string}} [opts]
168
+ */
169
+ export function planComplexity(tasks, opts = {}) {
170
+ const graph = opts.graph || null;
171
+ const splitAbove = Number.isInteger(opts.splitAbove) ? opts.splitAbove : DEFAULT_SPLIT_ABOVE;
172
+ const scored = (Array.isArray(tasks) ? tasks : [])
173
+ .filter((t) => t && t.id != null)
174
+ .map((t) => scoreTask(t, graph, splitAbove));
175
+ return {
176
+ splitAbove,
177
+ graph: opts.graphState || (graph ? "used" : "absent"),
178
+ split: scored.filter((t) => t.recommendSplit).map((t) => t.id),
179
+ tasks: scored,
180
+ };
181
+ }
182
+
183
+ /** prefs.global.planComplexity.splitAbove from a preferences file, or null. */
184
+ export function splitAboveFromPrefs(path) {
185
+ try {
186
+ const prefs = JSON.parse(readFileSync(path, "utf8"));
187
+ const v = prefs?.global?.planComplexity?.splitAbove;
188
+ return Number.isInteger(v) && v >= 1 && v <= MAX_SCORE ? v : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ function parseArgs(argv) {
195
+ const flags = {};
196
+ const positional = [];
197
+ for (let i = 0; i < argv.length; i++) {
198
+ const a = argv[i];
199
+ if (a === "--write") flags.write = true;
200
+ else if (["--graph", "--split-above", "--prefs"].includes(a)) flags[a.slice(2)] = argv[++i];
201
+ else positional.push(a);
202
+ }
203
+ return { flags, positional };
204
+ }
205
+
206
+ function main(argv) {
207
+ const { flags, positional } = parseArgs(argv);
208
+ const file = positional[0];
209
+ if (!file || (flags.write && file === "-")) {
210
+ process.stderr.write(
211
+ "usage: plan-complexity.mjs <plan.json|-> [--graph <path>] [--split-above N] [--prefs <path>] [--write]\n",
212
+ );
213
+ process.exitCode = 64;
214
+ return;
215
+ }
216
+ let plan;
217
+ try {
218
+ plan = JSON.parse(readFileSync(file === "-" ? 0 : file, "utf8"));
219
+ if (!Array.isArray(plan?.tasks) || plan.tasks.length === 0) throw new Error("no tasks[]");
220
+ } catch (e) {
221
+ process.stderr.write(`plan-complexity: ${e.message}\n`);
222
+ process.exitCode = 1;
223
+ return;
224
+ }
225
+
226
+ const cli = Number(flags["split-above"]);
227
+ const splitAbove = Number.isInteger(cli)
228
+ ? cli
229
+ : (splitAboveFromPrefs(
230
+ flags.prefs || join(homedir(), ".claude", "multi-agent-preferences.json"),
231
+ ) ?? DEFAULT_SPLIT_ABOVE);
232
+
233
+ const graphPath = flags.graph || defaultGraphPath();
234
+ let graph = null;
235
+ let graphState = "absent";
236
+ if (existsSync(graphPath)) {
237
+ try {
238
+ graph = loadGraph(graphPath);
239
+ graphState = "used";
240
+ } catch (e) {
241
+ graphState = "unreadable";
242
+ process.stderr.write(`plan-complexity: graph unreadable, scoring without it: ${e.message}\n`);
243
+ }
244
+ }
245
+
246
+ const result = planComplexity(plan.tasks, { graph, splitAbove, graphState });
247
+ if (flags.write) {
248
+ const byId = new Map(result.tasks.map((t) => [t.id, t]));
249
+ for (const task of plan.tasks) {
250
+ const t = byId.get(String(task.id));
251
+ if (t)
252
+ task.complexity = { score: t.score, recommendSplit: t.recommendSplit, factors: t.factors };
253
+ }
254
+ writeFileSync(file, `${JSON.stringify(plan, null, 2)}\n`);
255
+ }
256
+ process.stdout.write(`${JSON.stringify(result)}\n`);
257
+ }
258
+
259
+ if (invokedDirectly(import.meta.url)) main(process.argv.slice(2));
@@ -11,12 +11,9 @@
11
11
  # broken commit and npm.
12
12
  #
13
13
  # It therefore runs the CANONICAL chain, `npm test`, rather than a hand-picked
14
- # subset. The subset it used to run was missing eight of the eleven steps
15
- # (lint-skills, lint-mcp-refs, three of the four evals, validate-prefs,
16
- # scorecard) and drove the smoke suites through the bare
17
- # `for f in smoke-*.sh` loop that run-smokes.mjs was written to replace,
18
- # because that loop cannot tell a passing suite from one that exited 0 having
19
- # asserted nothing. A safety net with holes in it is worse than a known gap:
14
+ # subset. A subset drifts from the chain as steps are added, and a bare
15
+ # `for f in smoke-*.sh` loop cannot tell a passing suite from one that exited 0
16
+ # having asserted nothing; run-smokes.mjs can. A safety net with holes in it is worse than a known gap:
20
17
  # it is trusted.
21
18
  #
22
19
  # Two modes, because a gate that runs INSIDE the push is a gate that breaks
@@ -60,10 +60,11 @@
60
60
  import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
61
61
  import { isAbsolute, join, relative, resolve } from "node:path";
62
62
 
63
+ import { decisionEntry, withDecision } from "../lib/decisions.mjs";
63
64
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
64
65
  import { findingFingerprint, FINGERPRINT_RE, normalizeFile } from "./_fingerprint.mjs";
65
66
  import { compileMarkers, getAdapter, resolveStack } from "./_stack-adapter.mjs";
66
- import { gateContext, recordQuietly } from "./gate-ledger.mjs";
67
+ import { gateContext, recordQuietly, updateState } from "./gate-ledger.mjs";
67
68
  import { summarize } from "./test-summary.mjs";
68
69
 
69
70
  export const GATE = "review-decision";
@@ -327,6 +328,34 @@ function emit(json, result, stream) {
327
328
  writeFileSync(stream, `${lines.join("\n")}\n`);
328
329
  }
329
330
 
331
+ /**
332
+ * Each lowered blocker is a finding nobody was asked to waive, so it is listed
333
+ * in `decisions[]`, once: a re-run that lowers the same finding (same
334
+ * file:line, same reason) adds nothing. Best-effort: a state write problem
335
+ * never changes the gate's verdict.
336
+ */
337
+ async function recordDowngrades(statePath, downgraded) {
338
+ try {
339
+ await updateState(statePath, (s) =>
340
+ downgraded.reduce((acc, d) => {
341
+ const entry = decisionEntry({
342
+ what: `Lowered blocking finding ${d.file}:${d.line} to important`,
343
+ why: d.reason,
344
+ costIfWrong:
345
+ "a real blocker no longer stops the run; the rework still fixes it, but the commit is not held for it",
346
+ phase: 3,
347
+ });
348
+ const seen = (Array.isArray(acc?.decisions) ? acc.decisions : []).some(
349
+ (e) => e?.what === entry.what && e?.why === entry.why,
350
+ );
351
+ return seen ? acc : withDecision(acc, entry);
352
+ }, s),
353
+ );
354
+ } catch (err) {
355
+ process.stderr.write(`review-decision-gate: decisions not recorded: ${err.message}\n`);
356
+ }
357
+ }
358
+
330
359
  async function main(argv) {
331
360
  let opts;
332
361
  try {
@@ -417,6 +446,9 @@ async function main(argv) {
417
446
  repo: existsSync(root) ? root : null,
418
447
  statePath: ctx.statePath,
419
448
  });
449
+ if (decided && !opts.dryRun && ctx.statePath && result.downgraded?.length) {
450
+ await recordDowngrades(ctx.statePath, result.downgraded);
451
+ }
420
452
  emit(opts.json, result, result.verdict === "pass" ? 1 : 2);
421
453
  if (result.verdict === "pass") return 0;
422
454
  return result.verdict === "rebuttal-round" ? 1 : 3;
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rework-escalation.mjs - which rung the next Phase 2 rework dispatch runs on.
4
+ *
5
+ * A rework round that dispatches the same model with the same findings tends
6
+ * to produce the same fix. Two signals say the current rung is not enough:
7
+ *
8
+ * last-round this is the final rework the cap allows; a failure here
9
+ * ends the loop, so it is the round worth paying more for
10
+ * still-present review-delta.mjs found findings from the previous round's
11
+ * mandate still present after that round's fix
12
+ *
13
+ * On either signal the fresh dev agent goes one rung up the model-fallback
14
+ * ladder (fable -> opus -> sonnet -> haiku, top first), and only one. Every
15
+ * limit already in force holds it back, checked in this order:
16
+ *
17
+ * disabled prefs.global.reworkEscalation.enabled is false
18
+ * past-cap the iteration is beyond maxIterations; Phase 2 has stopped
19
+ * run-fell-back the run took a model_fallback; it is not re-upgraded
20
+ * mid-run (model-fallback.md, Non-goals)
21
+ * budget-* cost-budget-check.mjs is at warn, exceeded, or unreadable,
22
+ * or was not run while prefs.global.costBudget.enabled is true
23
+ * unknown-tier the current rung is not on the ladder (another host's model)
24
+ * top-of-ladder nothing above the current rung
25
+ * fable-off the next rung is fable and modelFallback.fableEnabled is not true
26
+ * at-ceiling the next rung is above prefs.global.reworkEscalation.maxTier
27
+ *
28
+ * A refusal is a decision too: the result carries the reason, the CLI records
29
+ * it on the latest review iteration, and the round runs on the current rung.
30
+ *
31
+ * Usage:
32
+ * rework-escalation.mjs --iteration <n> --current-tier <rung>
33
+ * [--max-iterations <n>] [--still-present <n>] [--budget-exit <code>]
34
+ * [--fallen-back] [--prefs <path>] [--state <path>]
35
+ *
36
+ * --budget-exit is cost-budget-check.mjs's exit code (0 ok, 10 warn,
37
+ * 11 exceeded, anything else unknown). Absent, the budget counts as disabled
38
+ * only when prefs.global.costBudget.enabled is not true; with the gate on it
39
+ * is `unchecked` and holds the rung, because an unrun check is no evidence
40
+ * that the budget has room. --prefs defaults to $MULTI_AGENT_PREFS, then
41
+ * ~/.claude/multi-agent-preferences.json.
42
+ * --state records the result as reviewIterations[-1].reworkEscalation and,
43
+ * in an autopilot run, an escalation as an entry in decisions[].
44
+ *
45
+ * Output: the decision as JSON on stdout.
46
+ * Exit: 0 decided (either way) · 2 usage · 3 state write failed
47
+ *
48
+ * @module pipeline/scripts/rework-escalation
49
+ */
50
+
51
+ import { writeFileSync } from "node:fs";
52
+ import { homedir } from "node:os";
53
+ import { join } from "node:path";
54
+
55
+ import { decisionEntry, withDecision } from "../lib/decisions.mjs";
56
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
57
+ import { readRegularJson } from "../lib/regular-file.mjs";
58
+ import { gatesActive } from "../lib/unattended.mjs";
59
+ import { updateState } from "./gate-ledger.mjs";
60
+
61
+ export const LADDER = Object.freeze(["fable", "opus", "sonnet", "haiku"]);
62
+ export const DEFAULT_MAX_TIER = "opus";
63
+ export const DEFAULT_MAX_ITERATIONS = 3;
64
+ const BUDGET_OK = new Set(["ok", "disabled"]);
65
+
66
+ /**
67
+ * cost-budget-check.mjs exit code -> budget state.
68
+ * @param {number|undefined|null} code
69
+ */
70
+ export function budgetFromExit(code) {
71
+ if (code === 0) return "ok";
72
+ if (code === 10) return "warn";
73
+ if (code === 11) return "exceeded";
74
+ return "unknown";
75
+ }
76
+
77
+ function settings(prefs) {
78
+ const g = prefs?.global ?? {};
79
+ const cfg = g.reworkEscalation ?? {};
80
+ return {
81
+ enabled: cfg.enabled !== false,
82
+ maxTier: LADDER.includes(cfg.maxTier) ? cfg.maxTier : DEFAULT_MAX_TIER,
83
+ fableEnabled: g.modelFallback?.fableEnabled === true,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Decide the rung for one rework dispatch. Pure.
89
+ *
90
+ * @param {{iteration: number, maxIterations?: number, stillPresent?: number,
91
+ * currentTier: string, ladder?: string[], budget?: string,
92
+ * prefs?: object, fallenBack?: boolean}} input
93
+ * @returns {{escalate: boolean, fromTier: string, toTier: string,
94
+ * trigger: "last-round"|"still-present"|null, reason: string}}
95
+ */
96
+ export function decide({
97
+ iteration,
98
+ maxIterations = DEFAULT_MAX_ITERATIONS,
99
+ stillPresent = 0,
100
+ currentTier,
101
+ ladder = LADDER,
102
+ budget = "disabled",
103
+ prefs = {},
104
+ fallenBack = false,
105
+ }) {
106
+ const cfg = settings(prefs);
107
+ const stay = (reason, trigger = null) => ({
108
+ escalate: false,
109
+ fromTier: currentTier,
110
+ toTier: currentTier,
111
+ trigger,
112
+ reason,
113
+ });
114
+
115
+ const trigger =
116
+ iteration > maxIterations
117
+ ? null
118
+ : stillPresent > 0
119
+ ? "still-present"
120
+ : iteration === maxIterations
121
+ ? "last-round"
122
+ : null;
123
+
124
+ if (!cfg.enabled) return stay("disabled", trigger);
125
+ if (iteration > maxIterations) return stay("past-cap");
126
+ if (!trigger) return stay("not-triggered");
127
+ if (fallenBack) return stay("run-fell-back", trigger);
128
+ if (!BUDGET_OK.has(budget)) {
129
+ return stay(
130
+ ["warn", "exceeded", "unchecked"].includes(budget) ? `budget-${budget}` : "budget-unknown",
131
+ trigger,
132
+ );
133
+ }
134
+
135
+ const at = ladder.indexOf(currentTier);
136
+ if (at < 0 || !LADDER.includes(currentTier)) return stay("unknown-tier", trigger);
137
+ if (at === 0) return stay("top-of-ladder", trigger);
138
+ const next = ladder[at - 1];
139
+ if (next === "fable" && !cfg.fableEnabled) return stay("fable-off", trigger);
140
+ if (LADDER.indexOf(next) < LADDER.indexOf(cfg.maxTier)) return stay("at-ceiling", trigger);
141
+
142
+ return { escalate: true, fromTier: currentTier, toTier: next, trigger, reason: "escalated" };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // CLI
147
+
148
+ class UsageError extends Error {}
149
+
150
+ const VALUE_FLAGS = new Set([
151
+ "iteration",
152
+ "max-iterations",
153
+ "still-present",
154
+ "current-tier",
155
+ "budget-exit",
156
+ "prefs",
157
+ "state",
158
+ ]);
159
+
160
+ function parse(argv) {
161
+ const opts = {};
162
+ for (let i = 0; i < argv.length; i++) {
163
+ const a = argv[i];
164
+ if (a === "--fallen-back") {
165
+ opts.fallenBack = true;
166
+ continue;
167
+ }
168
+ if (!a.startsWith("--")) throw new UsageError(`unexpected argument ${a}`);
169
+ const name = a.slice(2);
170
+ if (!VALUE_FLAGS.has(name)) throw new UsageError(`unknown flag ${a}`);
171
+ const v = argv[++i];
172
+ if (v === undefined) throw new UsageError(`${a} needs a value`);
173
+ if (!v.trim()) throw new UsageError(`${a} needs a non-empty value`);
174
+ opts[name] = v;
175
+ }
176
+ return opts;
177
+ }
178
+
179
+ function int(opts, name, fallback) {
180
+ if (opts[name] === undefined) return fallback;
181
+ const raw = opts[name].trim();
182
+ const n = Number(raw);
183
+ if (!/^[0-9]+$/.test(raw) || !Number.isInteger(n)) {
184
+ throw new UsageError(`--${name} must be a whole number`);
185
+ }
186
+ return n;
187
+ }
188
+
189
+ async function record(statePath, decision) {
190
+ const at = new Date().toISOString();
191
+ await updateState(statePath, (s) => {
192
+ const iterations = Array.isArray(s.reviewIterations) ? [...s.reviewIterations] : [];
193
+ if (iterations.length) {
194
+ iterations[iterations.length - 1] = {
195
+ ...iterations[iterations.length - 1],
196
+ reworkEscalation: { ...decision, at },
197
+ };
198
+ }
199
+ let next = { ...s, reviewIterations: iterations };
200
+ if (decision.escalate && gatesActive(s)) {
201
+ next = withDecision(
202
+ next,
203
+ decisionEntry({
204
+ what: `Ran the rework on a higher model (${decision.fromTier} -> ${decision.toTier})`,
205
+ why:
206
+ decision.trigger === "still-present"
207
+ ? "findings were still present after the previous fix"
208
+ : "this was the last rework round the cap allows",
209
+ costIfWrong: `one rework round billed at ${decision.toTier} rates`,
210
+ phase: 2,
211
+ at,
212
+ }),
213
+ );
214
+ }
215
+ return next;
216
+ });
217
+ }
218
+
219
+ async function main(argv) {
220
+ const opts = parse(argv);
221
+ if (!opts["current-tier"]) throw new UsageError("--current-tier is required");
222
+ const iteration = int(opts, "iteration", undefined);
223
+ if (iteration === undefined) throw new UsageError("--iteration is required");
224
+ const prefsPath =
225
+ opts.prefs ||
226
+ process.env.MULTI_AGENT_PREFS ||
227
+ join(homedir(), ".claude", "multi-agent-preferences.json");
228
+ const prefs = readRegularJson(prefsPath, {}) || {};
229
+ const decision = decide({
230
+ iteration,
231
+ maxIterations: int(opts, "max-iterations", DEFAULT_MAX_ITERATIONS),
232
+ stillPresent: int(opts, "still-present", 0),
233
+ currentTier: opts["current-tier"],
234
+ budget:
235
+ opts["budget-exit"] !== undefined
236
+ ? budgetFromExit(int(opts, "budget-exit", undefined))
237
+ : prefs.global?.costBudget?.enabled === true
238
+ ? "unchecked"
239
+ : "disabled",
240
+ prefs,
241
+ fallenBack: opts.fallenBack === true,
242
+ });
243
+ if (opts.state) {
244
+ try {
245
+ await record(opts.state, decision);
246
+ } catch (err) {
247
+ writeFileSync(1, `${JSON.stringify(decision)}\n`);
248
+ process.stderr.write(`rework-escalation: not recorded: ${err.message}\n`);
249
+ return 3;
250
+ }
251
+ }
252
+ writeFileSync(1, `${JSON.stringify(decision)}\n`);
253
+ return 0;
254
+ }
255
+
256
+ if (invokedDirectly(import.meta.url)) {
257
+ main(process.argv.slice(2)).then(
258
+ (code) => process.exit(code),
259
+ (err) => {
260
+ process.stderr.write(`rework-escalation: ${err.message}\n`);
261
+ process.exit(err instanceof UsageError ? 2 : 3);
262
+ },
263
+ );
264
+ }
@@ -102,9 +102,10 @@ export function renderTestCommand(template, file) {
102
102
 
103
103
  /**
104
104
  * Run one shell command in its own process group so a timeout kills the whole
105
- * tree, not only the shell.
105
+ * tree, not only the shell. `onSpawn` receives the group's pid, so a caller
106
+ * that exits on a signal can kill the group first.
106
107
  */
107
- function runCommand(command, cwd, timeoutMs) {
108
+ export function runCommand(command, cwd, timeoutMs, { onSpawn } = {}) {
108
109
  // NODE_TEST_CONTEXT is set when this script itself runs under `node --test`;
109
110
  // inherited, it turns the inner `node --test` into a reporting child that
110
111
  // exits 0 whatever its tests did.
@@ -116,6 +117,7 @@ function runCommand(command, cwd, timeoutMs) {
116
117
  stdio: ["ignore", "pipe", "pipe"],
117
118
  env: { ...env, CI: "1" },
118
119
  });
120
+ if (onSpawn && child.pid) onSpawn(child.pid);
119
121
  let output = "";
120
122
  const take = (chunk) => {
121
123
  if (output.length < MAX_OUTPUT) output += chunk.toString("utf8");
@@ -183,7 +185,7 @@ const WORKTREE_HEAD = "worktree";
183
185
  * Built in a private index file, so the caller's index is never written, and
184
186
  * never referenced by a ref: nothing but this run can see it.
185
187
  */
186
- function snapshotWorktree(root) {
188
+ export function snapshotWorktree(root) {
187
189
  const holder = mkdtempSync(join(tmpdir(), "test-strength-index-"));
188
190
  const env = {
189
191
  ...process.env,