@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.
- package/CHANGELOG.md +46 -0
- package/README.md +8 -6
- package/README.tr.md +7 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -6
- package/docs/features.md +9 -4
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/install/templates/codex-hooks.json +18 -0
- package/manifest.json +126 -98
- package/package.json +1 -1
- package/pipeline/agents/task-clarifier.md +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
- package/pipeline/contract/fixtures/runs-failed.json +1 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
- package/pipeline/contract/fixtures/runs-running.json +1 -1
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/credential-inventory.sh +5 -5
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/fetch-crashlytics.sh +6 -8
- package/pipeline/lib/model-rung.sh +1 -1
- package/pipeline/lib/plan-todos.sh +177 -37
- package/pipeline/lib/post-pr-review.sh +4 -6
- package/pipeline/lib/route-state.sh +1 -1
- package/pipeline/lib/shadow-git.sh +4 -5
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -11
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
- package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +36 -1
- package/pipeline/schemas/phases.json +4 -0
- package/pipeline/schemas/plan-todos.schema.json +104 -3
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +122 -3
- package/pipeline/scripts/_code-graph.mjs +2 -2
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/_transcript-hosts.mjs +406 -0
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/build-stack-plugins.mjs +3 -3
- package/pipeline/scripts/capture-flush.sh +7 -7
- package/pipeline/scripts/code-graph-rules/android.json +1 -1
- package/pipeline/scripts/cost-analyze.mjs +32 -43
- package/pipeline/scripts/cost-table.json +35 -19
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/dep-bump.mjs +1556 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
- package/pipeline/scripts/edit-syntax-gate.sh +32 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/gc-abandoned.sh +2 -2
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/git-path.mjs +1 -2
- package/pipeline/scripts/host-hooks.mjs +265 -0
- package/pipeline/scripts/jira-attach.sh +1 -1
- package/pipeline/scripts/keychain.py +3 -3
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/output-quality-check.sh +1 -1
- package/pipeline/scripts/phase-tracker.sh +5 -6
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/pre-push-check.sh +3 -6
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +1131 -0
- package/pipeline/scripts/uninstall.mjs +90 -4
- package/pipeline/scripts/update-issue-progress.sh +4 -6
- package/pipeline/scripts/usage-report.mjs +2 -2
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +20 -12
- package/pipeline/skills/.skills-index.json +23 -1
- package/pipeline/skills/shared/README.md +5 -3
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- 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.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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,
|