@mmerterden/multi-agent-pipeline 20.20.0 → 20.22.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 +58 -0
- package/README.md +24 -13
- package/README.tr.md +21 -10
- package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/ecosystem.md +7 -5
- package/docs/facts.json +6 -4
- package/docs/features.md +1 -0
- package/install/catalog-history.json +1 -1
- package/install/templates/copilot-instructions.md +1 -0
- package/manifest.json +71 -64
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/route-on/SKILL.md +32 -8
- package/pipeline/commands/multi-agent/route-status/SKILL.md +12 -2
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -16
- package/pipeline/lib/model-dispatch.sh +72 -24
- package/pipeline/lib/outbound-gate.mjs +2 -0
- package/pipeline/lib/route-state.sh +118 -10
- package/pipeline/lib/toolkit-llm.sh +30 -0
- package/pipeline/lib/unattended-profile-check.mjs +23 -2
- package/pipeline/multi-agent-refs/_dev-context.md +15 -0
- package/pipeline/multi-agent-refs/analysis/evidence.md +7 -3
- package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +9 -3
- package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/free-models.md +111 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -15
- package/pipeline/multi-agent-refs/features/product-context.md +238 -0
- package/pipeline/multi-agent-refs/features/unattended-security.md +4 -2
- package/pipeline/multi-agent-refs/knowledge.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +7 -12
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
- package/pipeline/multi-agent-refs/platform-parity.md +23 -9
- package/pipeline/multi-agent-refs/setup/free-models.md +22 -0
- package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/agent-state.schema.json +31 -0
- package/pipeline/schemas/analysis-spec.schema.json +14 -0
- package/pipeline/schemas/code-graph.schema.json +12 -1
- package/pipeline/schemas/diff-risk.schema.json +2 -1
- package/pipeline/schemas/prefs.schema.json +68 -1
- package/pipeline/schemas/route-config.schema.json +33 -8
- package/pipeline/schemas/secret-patterns.json +14 -0
- package/pipeline/scripts/_code-graph.mjs +76 -3
- package/pipeline/scripts/_diff-impact.mjs +312 -0
- package/pipeline/scripts/bulk-read.sh +98 -12
- package/pipeline/scripts/capture-flush.sh +58 -3
- package/pipeline/scripts/cost-table.json +9 -1
- package/pipeline/scripts/diff-risk-score.mjs +80 -9
- package/pipeline/scripts/doctor.mjs +100 -0
- package/pipeline/scripts/graph-affected.mjs +167 -36
- package/pipeline/scripts/graph-query.mjs +16 -4
- package/pipeline/scripts/pre-commit-check.sh +1 -1
- package/pipeline/scripts/product-context.mjs +539 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
- package/pipeline/scripts/unattended_policy.py +20 -0
- package/pipeline/scripts/validate-code-graph.mjs +2 -1
- package/pipeline/scripts/validate-diff-risk.mjs +1 -0
- package/pipeline/skills/.skill-manifest.json +6 -6
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +2 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +31 -7
- package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +12 -2
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +9 -6
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file _diff-impact.mjs - a diff, mapped onto the code graph.
|
|
3
|
+
*
|
|
4
|
+
* Three questions a reviewer or a planner asks of a change, answered from the
|
|
5
|
+
* graph without a model:
|
|
6
|
+
*
|
|
7
|
+
* parseHunkRanges which lines of each file the diff touched, on the side
|
|
8
|
+
* the graph was built from (graphSide)
|
|
9
|
+
* changedSymbols which declarations those lines fall in, innermost first,
|
|
10
|
+
* and which files the graph does not index at all
|
|
11
|
+
* graphFreshness how far the graph is behind the tree it describes
|
|
12
|
+
*
|
|
13
|
+
* The reverse walk itself is graph-affected.mjs; this module only produces its
|
|
14
|
+
* seeds. A graph built before definition spans existed (schema 1.0.0) has no
|
|
15
|
+
* `endLine`, so a hunk in such a file seeds the whole file and the file is
|
|
16
|
+
* listed in `fileLevel`: a coarser answer that says it is coarse.
|
|
17
|
+
*
|
|
18
|
+
* Zero runtime dependencies (ADR-0004).
|
|
19
|
+
*
|
|
20
|
+
* @module pipeline/scripts/_diff-impact
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { existsSync, statSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { unquotePathIfNeeded } from "./git-path.mjs";
|
|
27
|
+
|
|
28
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
29
|
+
|
|
30
|
+
/** The path of a `---`/`+++` line: git-quoted or not, one-letter prefix removed. */
|
|
31
|
+
function sidePath(raw, prefix) {
|
|
32
|
+
const text = raw.startsWith('"') ? raw : raw.replace(/\t.*$/, "");
|
|
33
|
+
const path = unquotePathIfNeeded(text);
|
|
34
|
+
if (path === "/dev/null") return null;
|
|
35
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addLine(set, line) {
|
|
39
|
+
set.add(Math.max(1, line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toRanges(lines) {
|
|
43
|
+
const sorted = [...lines].sort((a, b) => a - b);
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const l of sorted) {
|
|
46
|
+
const last = out[out.length - 1];
|
|
47
|
+
if (last && l === last.end + 1) last.end = l;
|
|
48
|
+
else if (!last || l > last.end) out.push({ start: l, end: l });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every file of a unified diff with the lines it touched on each side.
|
|
55
|
+
*
|
|
56
|
+
* New side: an added line counts as itself, and a deletion as the line now
|
|
57
|
+
* standing where the removed lines were. Old side: a removed line counts as
|
|
58
|
+
* itself, and an insertion as the old line standing where it went in. Either way a hunk that
|
|
59
|
+
* only adds or only removes still lands inside the declaration it changed.
|
|
60
|
+
* Hunks are counted from their headers, so concatenated `diff -u` output with
|
|
61
|
+
* no `diff --git` lines parses too, CRLF included, at any context size.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} diffText - unified diff
|
|
64
|
+
* @returns {Array<{oldPath: string|null, newPath: string|null, oldRanges: Array<{start: number, end: number}>, newRanges: Array<{start: number, end: number}>}>}
|
|
65
|
+
*/
|
|
66
|
+
export function parseDiffFiles(diffText) {
|
|
67
|
+
const files = [];
|
|
68
|
+
let cur = null;
|
|
69
|
+
let pendingOld;
|
|
70
|
+
let oldLine = 0;
|
|
71
|
+
let newLine = 0;
|
|
72
|
+
let oldLeft = 0;
|
|
73
|
+
let newLeft = 0;
|
|
74
|
+
for (const line of String(diffText).split("\n")) {
|
|
75
|
+
const raw = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
76
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
77
|
+
if (raw.startsWith("+")) {
|
|
78
|
+
addLine(cur.newLines, newLine);
|
|
79
|
+
addLine(cur.oldLines, oldLine);
|
|
80
|
+
newLine++;
|
|
81
|
+
newLeft--;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (raw.startsWith("-")) {
|
|
85
|
+
addLine(cur.oldLines, oldLine);
|
|
86
|
+
addLine(cur.newLines, newLine);
|
|
87
|
+
oldLine++;
|
|
88
|
+
oldLeft--;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (raw.startsWith(" ") || raw === "") {
|
|
92
|
+
oldLine++;
|
|
93
|
+
newLine++;
|
|
94
|
+
oldLeft--;
|
|
95
|
+
newLeft--;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (raw.startsWith("\\")) continue;
|
|
99
|
+
oldLeft = 0;
|
|
100
|
+
newLeft = 0;
|
|
101
|
+
}
|
|
102
|
+
if (raw.startsWith("diff --git ")) {
|
|
103
|
+
pendingOld = undefined;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (raw.startsWith("--- ")) {
|
|
107
|
+
pendingOld = sidePath(raw.slice(4), "a/");
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (raw.startsWith("+++ ")) {
|
|
111
|
+
cur = {
|
|
112
|
+
oldPath: pendingOld === undefined ? null : pendingOld,
|
|
113
|
+
newPath: sidePath(raw.slice(4), "b/"),
|
|
114
|
+
oldLines: new Set(),
|
|
115
|
+
newLines: new Set(),
|
|
116
|
+
};
|
|
117
|
+
files.push(cur);
|
|
118
|
+
pendingOld = undefined;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const hunk = HUNK_RE.exec(raw);
|
|
122
|
+
if (hunk && cur) {
|
|
123
|
+
const oldCount = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
124
|
+
const newCount = hunk[4] === undefined ? 1 : Number(hunk[4]);
|
|
125
|
+
oldLine = Number(hunk[1]);
|
|
126
|
+
newLine = Number(hunk[3]);
|
|
127
|
+
oldLeft = oldCount;
|
|
128
|
+
newLeft = newCount;
|
|
129
|
+
if (newCount === 0) addLine(cur.newLines, newLine);
|
|
130
|
+
if (oldCount === 0) addLine(cur.oldLines, oldLine);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return files
|
|
134
|
+
.filter((f) => f.oldPath || f.newPath)
|
|
135
|
+
.map((f) => ({
|
|
136
|
+
oldPath: f.oldPath,
|
|
137
|
+
newPath: f.newPath,
|
|
138
|
+
oldRanges: toRanges(f.oldLines),
|
|
139
|
+
newRanges: toRanges(f.newLines),
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Line ranges per file on one side of the diff. `new` (default) keys by the
|
|
145
|
+
* new path; a deleted file falls back to its old path. `old` keys by the old
|
|
146
|
+
* path; an added file falls back to its new path. Pick the side the graph was
|
|
147
|
+
* built from (`graphSide`): its spans are line numbers in that tree.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} diffText - unified diff
|
|
150
|
+
* @param {"new"|"old"} [side]
|
|
151
|
+
* @returns {Map<string, Array<{start: number, end: number}>>} in diff order
|
|
152
|
+
*/
|
|
153
|
+
export function parseHunkRanges(diffText, side = "new") {
|
|
154
|
+
const out = new Map();
|
|
155
|
+
for (const f of parseDiffFiles(diffText)) {
|
|
156
|
+
const path = side === "old" ? f.oldPath || f.newPath : f.newPath || f.oldPath;
|
|
157
|
+
const ranges = side === "old" ? f.oldRanges : f.newRanges;
|
|
158
|
+
if (ranges.length) out.set(path, ranges);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Declarations a set of ranges falls in.
|
|
165
|
+
*
|
|
166
|
+
* `changed` holds the innermost declaration per range - what a person would
|
|
167
|
+
* call "the function I edited". `seeds` adds every enclosing declaration, since
|
|
168
|
+
* references in the graph point at types, not at the methods inside them, and a
|
|
169
|
+
* walk seeded with the method alone would find no dependents. A range that hits
|
|
170
|
+
* no declaration (top-level code) seeds its file.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} graph
|
|
173
|
+
* @param {Map<string, Array<{start: number, end: number}>>} ranges
|
|
174
|
+
* @returns {{changed: object[], seeds: object[], fileLevel: string[], notIndexed: string[]}}
|
|
175
|
+
*/
|
|
176
|
+
export function changedSymbols(graph, ranges) {
|
|
177
|
+
const files = new Map();
|
|
178
|
+
const symbolsByPath = new Map();
|
|
179
|
+
for (const n of graph.nodes) {
|
|
180
|
+
if (n.kind === "file") files.set(n.path, n);
|
|
181
|
+
else if (n.kind === "symbol" && n.path) {
|
|
182
|
+
if (!symbolsByPath.has(n.path)) symbolsByPath.set(n.path, []);
|
|
183
|
+
symbolsByPath.get(n.path).push(n);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const changed = new Map();
|
|
188
|
+
const seeds = new Map();
|
|
189
|
+
const fileLevel = [];
|
|
190
|
+
const notIndexed = [];
|
|
191
|
+
|
|
192
|
+
for (const [path, list] of ranges) {
|
|
193
|
+
const fileNode = files.get(path);
|
|
194
|
+
if (!fileNode) {
|
|
195
|
+
notIndexed.push(path);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const symbols = symbolsByPath.get(path) || [];
|
|
199
|
+
const spanned = symbols.filter((s) => Number.isInteger(s.endLine));
|
|
200
|
+
if (symbols.length && !spanned.length) {
|
|
201
|
+
fileLevel.push(path);
|
|
202
|
+
seeds.set(fileNode.id, fileNode);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
for (const r of list) {
|
|
206
|
+
const containing = spanned
|
|
207
|
+
.filter((s) => (s.startLine ?? s.line) <= r.end && s.endLine >= r.start)
|
|
208
|
+
.sort((a, b) => b.line - a.line);
|
|
209
|
+
if (!containing.length) {
|
|
210
|
+
seeds.set(fileNode.id, fileNode);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const inner = containing[0];
|
|
214
|
+
changed.set(inner.id, inner);
|
|
215
|
+
for (const s of containing) seeds.set(s.id, s);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { changed: [...changed.values()], seeds: [...seeds.values()], fileLevel, notIndexed };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function gitOut(root, args) {
|
|
223
|
+
try {
|
|
224
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
225
|
+
encoding: "utf8",
|
|
226
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
227
|
+
}).trim();
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* How far the graph is behind the tree. `changedFiles` compares each indexed
|
|
235
|
+
* file's size and mtime with the manifest written at build time, so an edit
|
|
236
|
+
* that was never committed counts; `commitMoved` compares the build commit with
|
|
237
|
+
* HEAD. Either makes the graph stale - an answer from it is about the tree as
|
|
238
|
+
* it was, and callers say so instead of presenting it as current.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} graph
|
|
241
|
+
* @param {{root?: string}} [opts] - defaults to the root recorded in the graph
|
|
242
|
+
* @returns {{stale: boolean, commitMoved: boolean|null, baseCommit: string|null, head: string|null, changedFiles: string[], removedFiles: string[]}}
|
|
243
|
+
*/
|
|
244
|
+
export function graphFreshness(graph, { root = graph.root } = {}) {
|
|
245
|
+
const manifest = graph.manifest || {};
|
|
246
|
+
const changedFiles = [];
|
|
247
|
+
const removedFiles = [];
|
|
248
|
+
for (const [path, prev] of Object.entries(manifest)) {
|
|
249
|
+
const abs = join(root, path);
|
|
250
|
+
if (!existsSync(abs)) {
|
|
251
|
+
removedFiles.push(path);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const st = statSync(abs);
|
|
255
|
+
if (st.size !== prev.size || Math.floor(st.mtimeMs) !== prev.mtimeMs) changedFiles.push(path);
|
|
256
|
+
}
|
|
257
|
+
const head = root ? gitOut(root, ["rev-parse", "HEAD"]) : null;
|
|
258
|
+
const baseCommit = graph.baseCommit || null;
|
|
259
|
+
const commitMoved = head && baseCommit ? head !== baseCommit : null;
|
|
260
|
+
return {
|
|
261
|
+
stale: Boolean(commitMoved) || changedFiles.length > 0 || removedFiles.length > 0,
|
|
262
|
+
commitMoved,
|
|
263
|
+
baseCommit,
|
|
264
|
+
head,
|
|
265
|
+
changedFiles: changedFiles.sort(),
|
|
266
|
+
removedFiles: removedFiles.sort(),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Which side of a diff the graph's line numbers describe. A graph that matches
|
|
272
|
+
* the tree at `root` (same HEAD, no file edited since the build) describes the
|
|
273
|
+
* new side; any other graph was built before the change - on the base checkout,
|
|
274
|
+
* or before the edit - and its spans are old-side line numbers.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} graph
|
|
277
|
+
* @param {{root?: string}} [opts]
|
|
278
|
+
* @returns {"new"|"old"}
|
|
279
|
+
*/
|
|
280
|
+
export function graphSide(graph, opts = {}) {
|
|
281
|
+
return graphFreshness(graph, opts).stale ? "old" : "new";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const REF_RE = /^(?!-)[A-Za-z0-9._/~^@{}-]{1,200}$/;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The unified diff for `<base>` (working tree against base, uncommitted work
|
|
288
|
+
* included) or `<base>..<head>` (two commits), with no context lines.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} root
|
|
291
|
+
* @param {string} spec
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
export function diffFor(root, spec) {
|
|
295
|
+
const parts = String(spec).split("..");
|
|
296
|
+
if (parts.length > 2 || parts.some((p) => !REF_RE.test(p))) {
|
|
297
|
+
throw new Error(`not a ref or ref range: ${JSON.stringify(String(spec).slice(0, 80))}`);
|
|
298
|
+
}
|
|
299
|
+
const args = [
|
|
300
|
+
"-C",
|
|
301
|
+
root,
|
|
302
|
+
"diff",
|
|
303
|
+
"-U0",
|
|
304
|
+
"--no-color",
|
|
305
|
+
"--no-ext-diff",
|
|
306
|
+
"--src-prefix=a/",
|
|
307
|
+
"--dst-prefix=b/",
|
|
308
|
+
"--end-of-options",
|
|
309
|
+
...parts,
|
|
310
|
+
];
|
|
311
|
+
return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
312
|
+
}
|
|
@@ -91,11 +91,12 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
91
91
|
fi
|
|
92
92
|
[ -n "$MODEL" ] || MODEL="${PREF_MODEL:-haiku}"
|
|
93
93
|
|
|
94
|
-
# Routing picks the rung
|
|
95
|
-
# `
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# account that owns it,
|
|
94
|
+
# Routing picks the rung. An Anthropic rung goes to the `claude` CLI below. The
|
|
95
|
+
# `free` rung (route-on --free) goes to the multi-agent-toolkit's verified
|
|
96
|
+
# free-model chain first, and the Anthropic default answers whenever that chain
|
|
97
|
+
# escalates or is unreachable. Sending this read to a free provider puts the
|
|
98
|
+
# file's text outside the account that owns it, which is why it happens only
|
|
99
|
+
# after a user turned it on, and only to providers the data policy allows.
|
|
99
100
|
# An explicit --model still wins: a caller that named a rung asked for that rung.
|
|
100
101
|
if [ -z "${MODEL_EXPLICIT:-}" ] && [ -x "$HERE/../lib/model-dispatch.sh" ]; then
|
|
101
102
|
MODEL=$("$HERE/../lib/model-dispatch.sh" bulk-read \
|
|
@@ -104,7 +105,9 @@ fi
|
|
|
104
105
|
case "$TIMEOUT" in "" ) TIMEOUT="$PREF_TIMEOUT" ;; esac
|
|
105
106
|
case "$TIMEOUT" in ""|*[!0-9]*|0) TIMEOUT=60 ;; esac
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
FALLBACK_MODEL="${PREF_MODEL:-haiku}"
|
|
109
|
+
[ "$FALLBACK_MODEL" = "free" ] && FALLBACK_MODEL="haiku"
|
|
110
|
+
[ "$MODEL" = "free" ] || command -v claude >/dev/null 2>&1 || degrade "the claude CLI is not on PATH"
|
|
108
111
|
|
|
109
112
|
LINES=$(wc -l < "$FILE" | tr -d ' ')
|
|
110
113
|
BYTES=$(wc -c < "$FILE" | tr -d ' ')
|
|
@@ -163,7 +166,8 @@ fi
|
|
|
163
166
|
|
|
164
167
|
PROMPT_FILE="$(mktemp -t bulk-read.XXXXXX)"
|
|
165
168
|
OUT_FILE="$(mktemp -t bulk-read-out.XXXXXX)"
|
|
166
|
-
|
|
169
|
+
FREE_DIR="$(mktemp -d -t bulk-read-free.XXXXXX)"
|
|
170
|
+
trap 'rm -rf "$PROMPT_FILE" "$OUT_FILE" "$FREE_DIR"' EXIT
|
|
167
171
|
|
|
168
172
|
# The worker gets numbered lines because every claim it makes has to be
|
|
169
173
|
# addressable afterwards. `nl -ba` numbers blank lines too, so the numbers match
|
|
@@ -191,6 +195,90 @@ done
|
|
|
191
195
|
} > "$PROMPT_FILE"
|
|
192
196
|
|
|
193
197
|
START=$(date +%s)
|
|
198
|
+
TIMEOUT_BIN=""
|
|
199
|
+
for candidate in timeout gtimeout; do
|
|
200
|
+
command -v "$candidate" >/dev/null 2>&1 && { TIMEOUT_BIN="$candidate"; break; }
|
|
201
|
+
done
|
|
202
|
+
|
|
203
|
+
# The free chain gets the same persona, question and numbered lines, and the
|
|
204
|
+
# bulk-reader output schema. The toolkit verifies the answer
|
|
205
|
+
# against the numbered text before it returns it; the range check below adds
|
|
206
|
+
# what only this script knows, the file's line count. Anything short of a
|
|
207
|
+
# verified, in-range answer hands the read to the Anthropic default.
|
|
208
|
+
FREE_DONE=0
|
|
209
|
+
if [ "$MODEL" = "free" ]; then
|
|
210
|
+
FREE_REASON=""
|
|
211
|
+
_MA_TL="$(dirname "${BASH_SOURCE[0]}")/../lib/toolkit-llm.sh"
|
|
212
|
+
[ -f "$_MA_TL" ] || _MA_TL="$HOME/.claude/lib/toolkit-llm.sh"
|
|
213
|
+
FREE_SCHEMA="$HERE/../schemas/bulk-read-output.schema.json"
|
|
214
|
+
if [ -f "$_MA_TL" ] && [ -f "$FREE_SCHEMA" ]; then
|
|
215
|
+
. "$_MA_TL"
|
|
216
|
+
# The sandbox of an unattended run denies writes under ~/.claude, where the
|
|
217
|
+
# toolkit keeps its quota and usage files by default.
|
|
218
|
+
if [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ] && [ -z "${MCP_TOOLKIT_INDEX_DIR:-}" ]; then
|
|
219
|
+
export MCP_TOOLKIT_INDEX_DIR="$HOME/.multi-agent-unattended/toolkit-index"
|
|
220
|
+
fi
|
|
221
|
+
nl -ba "$FILE" > "$FREE_DIR/numbered.txt"
|
|
222
|
+
FREE_PROMPT="$(awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$PERSONA")
|
|
223
|
+
<question>
|
|
224
|
+
$QUESTION
|
|
225
|
+
</question>
|
|
226
|
+
The SOURCE is the file $FILE with its $LINES lines numbered."
|
|
227
|
+
ALLOW_FLAG=""
|
|
228
|
+
for prefs in "${MULTI_AGENT_PREFS:-}" "$HOME/.claude/multi-agent-preferences.json" "$HOME/.config/multi-agent-pipeline/multi-agent-preferences.json"; do
|
|
229
|
+
[ -n "$prefs" ] && [ -f "$prefs" ] || continue
|
|
230
|
+
[ "$(jq -r '.global.modelRouting.free.allowTraining // false' "$prefs" 2>/dev/null)" = "true" ] && ALLOW_FLAG="--allow-training"
|
|
231
|
+
break
|
|
232
|
+
done
|
|
233
|
+
if [ -n "$TIMEOUT_BIN" ]; then
|
|
234
|
+
"$TIMEOUT_BIN" "$TIMEOUT" bash -c '. "$1"; shift; ma_toolkit_llm "$@"' _ "$_MA_TL" delegate --task summarize \
|
|
235
|
+
--prompt "$FREE_PROMPT" --path "$FREE_DIR/numbered.txt" --schema "$FREE_SCHEMA" \
|
|
236
|
+
--max-tokens 2048 $ALLOW_FLAG > "$FREE_DIR/result.json" 2>/dev/null
|
|
237
|
+
else
|
|
238
|
+
ma_toolkit_llm delegate --task summarize --prompt "$FREE_PROMPT" --path "$FREE_DIR/numbered.txt" \
|
|
239
|
+
--schema "$FREE_SCHEMA" --max-tokens 2048 $ALLOW_FLAG > "$FREE_DIR/result.json" 2>/dev/null
|
|
240
|
+
fi
|
|
241
|
+
FREE_STATUS=$?
|
|
242
|
+
case "$FREE_STATUS" in
|
|
243
|
+
0)
|
|
244
|
+
if LINES="$LINES" python3 - "$FREE_DIR/result.json" "$OUT_FILE" <<'PYEOF'
|
|
245
|
+
import json, os, sys
|
|
246
|
+
r = json.load(open(sys.argv[1]))
|
|
247
|
+
v = r.get("value")
|
|
248
|
+
n = int(os.environ["LINES"])
|
|
249
|
+
ok = isinstance(v, dict)
|
|
250
|
+
for reg in (v or {}).get("regions") or []:
|
|
251
|
+
ok = ok and 1 <= reg.get("start", 0) <= reg.get("end", 0) <= n
|
|
252
|
+
for sym in (v or {}).get("symbols") or []:
|
|
253
|
+
ok = ok and 1 <= sym.get("line", 0) <= n
|
|
254
|
+
if not ok:
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
json.dump(v, open(sys.argv[2], "w"))
|
|
257
|
+
open(sys.argv[2] + ".via", "w").write("free:%s/%s" % (r.get("provider"), r.get("model")))
|
|
258
|
+
PYEOF
|
|
259
|
+
then
|
|
260
|
+
FREE_DONE=1
|
|
261
|
+
MODEL=$(cat "$OUT_FILE.via"); rm -f "$OUT_FILE.via"
|
|
262
|
+
else
|
|
263
|
+
FREE_REASON="the free answer cited lines outside 1-$LINES"
|
|
264
|
+
fi ;;
|
|
265
|
+
3) FREE_REASON="no free provider produced a verified answer" ;;
|
|
266
|
+
124) FREE_REASON="the free chain did not answer within ${TIMEOUT}s" ;;
|
|
267
|
+
*) FREE_REASON="the free chain is unavailable (exit $FREE_STATUS)" ;;
|
|
268
|
+
esac
|
|
269
|
+
else
|
|
270
|
+
FREE_REASON="toolkit-llm.sh or the bulk-read output schema is not installed"
|
|
271
|
+
fi
|
|
272
|
+
[ -x "$HERE/log-metric.sh" ] && \
|
|
273
|
+
"$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.free \
|
|
274
|
+
outcome="$([ "$FREE_DONE" = 1 ] && echo verified || echo escalated)" reason="${FREE_REASON:-ok}" >/dev/null 2>&1
|
|
275
|
+
if [ "$FREE_DONE" != 1 ]; then
|
|
276
|
+
echo "bulk-read: free chain not used ($FREE_REASON); reading on $FALLBACK_MODEL" >&2
|
|
277
|
+
MODEL="$FALLBACK_MODEL"
|
|
278
|
+
command -v claude >/dev/null 2>&1 || degrade "the claude CLI is not on PATH"
|
|
279
|
+
fi
|
|
280
|
+
fi
|
|
281
|
+
|
|
194
282
|
# `claude -p` reads the prompt from stdin; the model flag names the rung. A
|
|
195
283
|
# non-zero exit, an empty answer and a non-JSON answer are all degradations, and
|
|
196
284
|
# each one names itself rather than falling through to a generic failure.
|
|
@@ -198,11 +286,9 @@ START=$(date +%s)
|
|
|
198
286
|
# GNU spelling would leave the budget silently unenforced on the platform most of
|
|
199
287
|
# these runs happen on - a pref that does nothing, which is the class
|
|
200
288
|
# smoke-prefs-consumed exists to catch.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
done
|
|
205
|
-
if [ -n "$TIMEOUT_BIN" ]; then
|
|
289
|
+
if [ "$FREE_DONE" = 1 ]; then
|
|
290
|
+
STATUS=0
|
|
291
|
+
elif [ -n "$TIMEOUT_BIN" ]; then
|
|
206
292
|
"$TIMEOUT_BIN" "$TIMEOUT" claude -p --model "$MODEL" < "$PROMPT_FILE" > "$OUT_FILE" 2>/dev/null
|
|
207
293
|
STATUS=$?
|
|
208
294
|
else
|
|
@@ -110,8 +110,37 @@ fi
|
|
|
110
110
|
# Phase 4 removes the worktree once the PR is open, so the salvaged copy under
|
|
111
111
|
# artifactsPath is tried FIRST. Reading the worktree path first would degrade
|
|
112
112
|
# silently for exactly the runs this script exists to rescue.
|
|
113
|
+
#
|
|
114
|
+
# After those, each worktree's newest `.pipeline/triage-round-<N>.json`: Phase 3
|
|
115
|
+
# writes every round there itself, and the copy to triage-output.json is a
|
|
116
|
+
# separate step a run can die before. A multi-repo run names its worktrees only
|
|
117
|
+
# under projects[], so those are read too, and the state directory last.
|
|
118
|
+
newest_round() {
|
|
119
|
+
[ -d "$1/.pipeline" ] || return 0
|
|
120
|
+
ls -t "$1"/.pipeline/triage-round-*.json 2>/dev/null | head -1
|
|
121
|
+
}
|
|
122
|
+
WORKTREES=$( { [ -n "$WORKTREE" ] && printf '%s\n' "$WORKTREE"; jq -r '.projects[]?.worktreePath // empty' "$STATE" 2>/dev/null; } | awk 'NF && !seen[$0]++')
|
|
113
123
|
TRIAGE=""
|
|
114
|
-
|
|
124
|
+
CANDIDATES=("${ARTIFACTS:+$ARTIFACTS/triage-output.json}")
|
|
125
|
+
# Within one worktree the newer of the copy and the newest round wins: a run
|
|
126
|
+
# that died before the copy step leaves the previous round in the copy.
|
|
127
|
+
newest_in_worktree() {
|
|
128
|
+
local copy="$1/triage-output.json" round
|
|
129
|
+
round=$(newest_round "$1")
|
|
130
|
+
if [ -f "$copy" ] && [ -n "$round" ]; then
|
|
131
|
+
ls -t "$copy" "$round" 2>/dev/null | head -1
|
|
132
|
+
elif [ -f "$copy" ]; then
|
|
133
|
+
printf '%s\n' "$copy"
|
|
134
|
+
else
|
|
135
|
+
printf '%s\n' "$round"
|
|
136
|
+
fi
|
|
137
|
+
}
|
|
138
|
+
while IFS= read -r wt; do
|
|
139
|
+
[ -n "$wt" ] || continue
|
|
140
|
+
CANDIDATES+=("$(newest_in_worktree "$wt")")
|
|
141
|
+
done <<< "$WORKTREES"
|
|
142
|
+
CANDIDATES+=("$(dirname "$STATE")/triage-output.json")
|
|
143
|
+
for cand in "${CANDIDATES[@]}"; do
|
|
115
144
|
[ -n "$cand" ] && [ -f "$cand" ] && { TRIAGE="$cand"; break; }
|
|
116
145
|
done
|
|
117
146
|
|
|
@@ -136,12 +165,38 @@ run_store() {
|
|
|
136
165
|
esac
|
|
137
166
|
}
|
|
138
167
|
|
|
168
|
+
# The store is named after the run's repository, never after the directory this
|
|
169
|
+
# script happens to run in: a SessionEnd hook fires wherever the session was.
|
|
170
|
+
# A live worktree resolves to its main checkout's folder, the same name the
|
|
171
|
+
# readers derive; otherwise the project recorded in the state.
|
|
172
|
+
slug_of_worktree() {
|
|
173
|
+
[ -d "$1" ] || return 0
|
|
174
|
+
local main
|
|
175
|
+
main=$(git -C "$1" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
|
|
176
|
+
[ -n "$main" ] || return 0
|
|
177
|
+
basename "$main" .git
|
|
178
|
+
}
|
|
179
|
+
SLUG=""
|
|
180
|
+
while IFS= read -r wt; do
|
|
181
|
+
[ -n "$wt" ] && [ -z "$SLUG" ] && SLUG=$(slug_of_worktree "$wt")
|
|
182
|
+
done <<< "$WORKTREES"
|
|
183
|
+
plain_slug() {
|
|
184
|
+
case "$1" in
|
|
185
|
+
"" | .* | *[!A-Za-z0-9._-]*) return 0 ;;
|
|
186
|
+
esac
|
|
187
|
+
printf '%s' "$1"
|
|
188
|
+
}
|
|
189
|
+
SLUG=$(plain_slug "$SLUG")
|
|
190
|
+
[ -n "$SLUG" ] || SLUG=$(plain_slug "$(jq -r '.project // .projects[0].name // empty' "$STATE" 2>/dev/null)")
|
|
191
|
+
SLUG_ARGS=()
|
|
192
|
+
[ -n "$SLUG" ] && SLUG_ARGS=(--repo-slug "$SLUG")
|
|
193
|
+
|
|
139
194
|
if [ -n "$TRIAGE" ]; then
|
|
140
195
|
INGESTED=$(run_store ingest \
|
|
141
|
-
node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE")
|
|
196
|
+
node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
|
|
142
197
|
if [ -n "$TASK_ID" ]; then
|
|
143
198
|
DISTILLED=$(run_store distill \
|
|
144
|
-
node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID")
|
|
199
|
+
node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
|
|
145
200
|
fi
|
|
146
201
|
fi
|
|
147
202
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-07-28 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site
|
|
2
|
+
"_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-07-28 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits (~10% of inPerMtok); the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
|
|
3
3
|
"schemaVersion": "1.1.0",
|
|
4
4
|
"prices": {
|
|
5
5
|
"fable": {
|
|
@@ -57,6 +57,14 @@
|
|
|
57
57
|
"modelId": "gpt-5.6-terra",
|
|
58
58
|
"provider": "openai",
|
|
59
59
|
"note": "Codex CLI floor tier - speed-optimised, maps the haiku rung of the Codex persona tier map (task-clarifier). Approximate; verify against OpenAI pricing before relying on totals."
|
|
60
|
+
},
|
|
61
|
+
"free": {
|
|
62
|
+
"inPerMtok": 0.0,
|
|
63
|
+
"outPerMtok": 0.0,
|
|
64
|
+
"cacheReadPerMtok": 0.0,
|
|
65
|
+
"modelId": "free-chain",
|
|
66
|
+
"provider": "free-chain",
|
|
67
|
+
"note": "Layer 2: the multi-agent-toolkit's verified free-model chain (llm_delegate / multi-agent-toolkit-llm) over free tiers - Groq, Mistral, Gemini, OpenRouter free models, Z.AI GLM Flash, local Ollama. Priced at zero because the provider bills nothing on a free tier; what it saves is reported by route-status from the toolkit's usage log at the rate of the rung it replaced."
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
}
|