@mmerterden/multi-agent-pipeline 16.19.0 → 16.21.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 (61) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/docs/features.md +1 -0
  5. package/install/_codex-agents.mjs +2 -2
  6. package/install/_dev-only-files.mjs +1 -0
  7. package/install/templates/claude-hooks.json +12 -1
  8. package/package.json +4 -3
  9. package/pipeline/agents/android-architect.md +1 -0
  10. package/pipeline/agents/backend-architect.md +1 -0
  11. package/pipeline/agents/bulk-reader.md +57 -0
  12. package/pipeline/agents/code-reviewer.md +1 -0
  13. package/pipeline/agents/dev-critic.md +2 -1
  14. package/pipeline/agents/explorer.md +1 -0
  15. package/pipeline/agents/ios-architect.md +1 -0
  16. package/pipeline/agents/security-auditor.md +1 -0
  17. package/pipeline/agents/task-clarifier.md +1 -0
  18. package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -1
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
  20. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  21. package/pipeline/commands/multi-agent/store-ready/SKILL.md +22 -0
  22. package/pipeline/commands/sim-test.md +10 -5
  23. package/pipeline/multi-agent-refs/channels/pr.md +22 -4
  24. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +15 -2
  25. package/pipeline/multi-agent-refs/features/review-delta.md +89 -0
  26. package/pipeline/multi-agent-refs/features/scope-check.md +41 -0
  27. package/pipeline/multi-agent-refs/features/verify-by-test.md +6 -5
  28. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  29. package/pipeline/multi-agent-refs/payload-contracts.md +1 -1
  30. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +17 -2
  31. package/pipeline/multi-agent-refs/phases/phase-4-review.md +46 -8
  32. package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
  33. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +2 -0
  34. package/pipeline/multi-agent-refs/phases/phase-7-report.md +4 -2
  35. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  36. package/pipeline/multi-agent-refs/rules.md +2 -2
  37. package/pipeline/schemas/agent-state.schema.json +129 -0
  38. package/pipeline/schemas/bulk-read-output.schema.json +52 -0
  39. package/pipeline/schemas/dev-critic-output.schema.json +5 -0
  40. package/pipeline/schemas/prefs.schema.json +121 -19
  41. package/pipeline/schemas/reviewer-output.schema.json +7 -2
  42. package/pipeline/schemas/scope-check.schema.json +55 -0
  43. package/pipeline/schemas/token-budget.json +3 -3
  44. package/pipeline/schemas/triage-output.schema.json +12 -2
  45. package/pipeline/scripts/README.md +3 -2
  46. package/pipeline/scripts/_fingerprint.mjs +173 -0
  47. package/pipeline/scripts/bulk-read.sh +277 -0
  48. package/pipeline/scripts/check-read-size.py +335 -0
  49. package/pipeline/scripts/check-read-size.sh +86 -0
  50. package/pipeline/scripts/evidence-gate.mjs +73 -5
  51. package/pipeline/scripts/finding-fingerprint.mjs +101 -0
  52. package/pipeline/scripts/pre-commit-check.sh +1 -0
  53. package/pipeline/scripts/review-delta.mjs +217 -0
  54. package/pipeline/scripts/run-metrics.mjs +20 -0
  55. package/pipeline/scripts/scope-check-gate.mjs +90 -0
  56. package/pipeline/scripts/smoke-cross-cli-behavior.sh +11 -4
  57. package/pipeline/scripts/uninstall.mjs +1 -0
  58. package/pipeline/scripts/validate-reviewer.mjs +6 -0
  59. package/pipeline/scripts/validate-triage.mjs +20 -0
  60. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +5 -0
  61. package/pipeline/skills/shared/external/backlog/SKILL.md +10 -6
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Finding fingerprint: the stable, cross-round identity of a review finding.
3
+ *
4
+ * A finding is the same finding from one Phase 4 round to the next when it
5
+ * names the same file and the same problem. The line moves once the fix lands,
6
+ * the severity can be lowered by triage, the fix text and the reviewer vary per
7
+ * round, so none of those take part. What remains is (file, ruleId) when the
8
+ * finding cites a rule, else (file, normalized issue text).
9
+ *
10
+ * Computed by scripts, never by the model: an older triage JSON gets its
11
+ * fingerprints on the fly and a reviewer that echoes one is respected but not
12
+ * relied on. Same argument shortId() in _retrieval.mjs makes for corpus rows.
13
+ *
14
+ * @module pipeline/scripts/_fingerprint
15
+ */
16
+
17
+ import { shortId } from "./_retrieval.mjs";
18
+
19
+ export const FINGERPRINT_RE = /^F:[0-9a-f]{8}$/;
20
+
21
+ const QUOTES = /[`'"‘’“”«»]/g;
22
+ const DIFF_PREFIX = /^(?:\.\/|[ab]\/)+/;
23
+
24
+ /**
25
+ * Strip diff and relative prefixes; keep a multi-repo `<repo>/` prefix because
26
+ * the same path in two repos is two findings.
27
+ * @param {unknown} file
28
+ * @returns {string}
29
+ */
30
+ export function normalizeFile(file) {
31
+ return String(file ?? "")
32
+ .trim()
33
+ .replace(DIFF_PREFIX, "");
34
+ }
35
+
36
+ /**
37
+ * Lowercase, quote-free, path-free, number-free text. Every digit run becomes
38
+ * `#` so a line reference or a count changes nothing while "retry # times" and
39
+ * "retry times" stay distinct. Tokens with a `/` collapse to their basename so a
40
+ * reviewer that writes the full path and one that writes the file name agree.
41
+ * @param {unknown} issue
42
+ * @returns {string}
43
+ */
44
+ export function normalizeIssue(issue) {
45
+ return String(issue ?? "")
46
+ .toLowerCase()
47
+ .replace(QUOTES, "")
48
+ .split(/\s+/)
49
+ .map((tok) => (tok.includes("/") ? tok.slice(tok.lastIndexOf("/") + 1) : tok))
50
+ .join(" ")
51
+ .replace(/\d+/g, "#")
52
+ .replace(/[^a-z0-9# ]+/g, " ")
53
+ .replace(/\s+/g, " ")
54
+ .trim();
55
+ }
56
+
57
+ /**
58
+ * @param {{file?: unknown, issue?: unknown, ruleId?: unknown}} finding
59
+ * @returns {string} e.g. "F:3fa9c21b"
60
+ */
61
+ export function findingFingerprint(finding) {
62
+ const file = normalizeFile(finding?.file);
63
+ const ruleId = typeof finding?.ruleId === "string" ? finding.ruleId.trim() : "";
64
+ if (ruleId) return shortId("F", file, "rule", ruleId.toLowerCase());
65
+ return shortId("F", file, normalizeIssue(finding?.issue));
66
+ }
67
+
68
+ /**
69
+ * Which arrays hold findings for each document kind. Triage buckets that wrap
70
+ * the finding (`deferred[].finding`) are addressed through `wrap`.
71
+ */
72
+ const SHAPES = {
73
+ reviewer: [{ bucket: "findings", wrap: null }],
74
+ "dev-critic": [{ bucket: "findings", wrap: null }],
75
+ triage: [
76
+ { bucket: "accepted", wrap: null },
77
+ { bucket: "deferred", wrap: "finding" },
78
+ { bucket: "rejected", wrap: "finding" },
79
+ ],
80
+ };
81
+
82
+ export const KINDS = Object.freeze(Object.keys(SHAPES));
83
+
84
+ /**
85
+ * @param {unknown} doc
86
+ * @returns {"triage"|"reviewer"|"dev-critic"|null}
87
+ */
88
+ export function sniffKind(doc) {
89
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
90
+ if (Array.isArray(doc.accepted)) return "triage";
91
+ if (Array.isArray(doc.findings)) return "iteration" in doc ? "dev-critic" : "reviewer";
92
+ return null;
93
+ }
94
+
95
+ function resolveKind(doc, kind) {
96
+ if (kind && kind !== "auto") {
97
+ if (!SHAPES[kind]) throw new Error(`unknown kind "${kind}" (expected ${KINDS.join("|")})`);
98
+ return kind;
99
+ }
100
+ const sniffed = sniffKind(doc);
101
+ if (!sniffed) throw new Error("cannot infer document kind: no findings[] or accepted[]");
102
+ return sniffed;
103
+ }
104
+
105
+ /**
106
+ * Return every finding object in the document, with its container so callers
107
+ * can mutate a copy in place.
108
+ * @param {object} doc
109
+ * @param {string} kind resolved kind
110
+ * @param {string[]} [buckets] subset of the kind's buckets
111
+ * @returns {Array<{bucket: string, index: number, finding: object, holder: object}>}
112
+ */
113
+ export function walkFindings(doc, kind, buckets) {
114
+ const out = [];
115
+ for (const shape of SHAPES[kind]) {
116
+ if (buckets && !buckets.includes(shape.bucket)) continue;
117
+ const list = doc?.[shape.bucket];
118
+ if (!Array.isArray(list)) continue;
119
+ list.forEach((entry, index) => {
120
+ const holder = shape.wrap ? entry?.[shape.wrap] : entry;
121
+ if (holder && typeof holder === "object") {
122
+ out.push({ bucket: shape.bucket, index, finding: holder, holder });
123
+ }
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Deep-copy the document and set `fingerprint` on every finding that lacks a
131
+ * valid one. Pure: the input is not mutated.
132
+ * @param {object} doc
133
+ * @param {string} [kind="auto"]
134
+ * @returns {{doc: object, kind: string, computed: number, kept: number}}
135
+ */
136
+ export function annotate(doc, kind = "auto") {
137
+ const resolved = resolveKind(doc, kind);
138
+ const copy = structuredClone(doc);
139
+ let computed = 0;
140
+ let kept = 0;
141
+ for (const { finding } of walkFindings(copy, resolved)) {
142
+ if (typeof finding.fingerprint === "string" && FINGERPRINT_RE.test(finding.fingerprint)) {
143
+ kept += 1;
144
+ continue;
145
+ }
146
+ finding.fingerprint = findingFingerprint(finding);
147
+ computed += 1;
148
+ }
149
+ return { doc: copy, kind: resolved, computed, kept };
150
+ }
151
+
152
+ /**
153
+ * Map of fingerprint to finding over the chosen buckets and severities of an
154
+ * already-annotated document. When two findings share a fingerprint the first
155
+ * wins; the delta report prints the issue text so a human can see the merge.
156
+ * @param {object} doc annotated document
157
+ * @param {{kind?: string, buckets?: string[], severities?: string[]|null}} [opts]
158
+ * @returns {Map<string, object>}
159
+ */
160
+ export function collectFingerprints(doc, opts = {}) {
161
+ const kind = resolveKind(doc, opts.kind ?? "auto");
162
+ const buckets = opts.buckets ?? SHAPES[kind].map((s) => s.bucket);
163
+ const severities = opts.severities ? new Set(opts.severities) : null;
164
+ const map = new Map();
165
+ for (const { finding } of walkFindings(doc, kind, buckets)) {
166
+ if (severities && !severities.has(finding.severity)) continue;
167
+ const fp = FINGERPRINT_RE.test(finding.fingerprint ?? "")
168
+ ? finding.fingerprint
169
+ : findingFingerprint(finding);
170
+ if (!map.has(fp)) map.set(fp, finding);
171
+ }
172
+ return map;
173
+ }
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env bash
2
+ # bulk-read.sh - read a large file on the cheap rung and return a summary.
3
+ #
4
+ # The worker behind `check-read-size.sh`. The gate says a file is too big to be
5
+ # worth reading whole at this phase's rung; this is where that read goes instead.
6
+ # The full text reaches a haiku-rung worker, the worker returns a structured
7
+ # summary WITH LINE NUMBERS, and only the summary enters the caller's context.
8
+ #
9
+ # Line numbers are the whole design, not a nicety. A summary without them is a
10
+ # dead end: the caller cannot edit from it, cannot verify it, and ends up reading
11
+ # the file anyway - having now paid twice. With them, the intended next step is a
12
+ # bounded `Read(file, offset:, limit:)` around the region that matters, which is
13
+ # both cheap and exact, and which the gate lets through.
14
+ #
15
+ # Usage:
16
+ # bulk-read.sh --file <path> [--question "<what you need>"] [--phase N]
17
+ # [--model <rung>] [--timeout <seconds>] [--json]
18
+ #
19
+ # Output: a human-readable summary on stdout, plus the ref id that buys the full
20
+ # text back. `--json` prints the worker's raw object instead, for a caller that
21
+ # wants to parse it.
22
+ #
23
+ # Degradation is explicit and always safe. No CLI, no auth, a timeout, or a
24
+ # non-JSON answer -> this prints WHY and tells the caller to fall back to a
25
+ # bounded read. It never invents a summary, and it never silently succeeds: a
26
+ # fabricated summary of a file nobody read is the one outcome worse than paying
27
+ # full price for the file.
28
+ #
29
+ # Exit codes: 0 summary produced · 3 degraded (caller should read it directly)
30
+ # 1 usage error.
31
+
32
+ set -uo pipefail
33
+
34
+ FILE=""
35
+ QUESTION="Summarize this file so a reader can decide which regions to open."
36
+ PHASE="${MULTI_AGENT_PHASE:-0}"
37
+ MODEL=""
38
+ TIMEOUT=""
39
+ AS_JSON=0
40
+
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --file) FILE="${2:?--file needs a value}"; shift 2 ;;
44
+ --question) QUESTION="${2:?--question needs a value}"; shift 2 ;;
45
+ --phase) PHASE="${2:?--phase needs a value}"; shift 2 ;;
46
+ --model) MODEL="${2:?--model needs a value}"; shift 2 ;;
47
+ --timeout) TIMEOUT="${2:?--timeout needs a value}"; shift 2 ;;
48
+ --json) AS_JSON=1; shift ;;
49
+ -h|--help) sed -n '2,30p' "$0"; exit 0 ;;
50
+ *) echo "bulk-read: unknown argument '$1'" >&2; exit 1 ;;
51
+ esac
52
+ done
53
+
54
+ [ -n "$FILE" ] || { echo "bulk-read: --file is required" >&2; exit 1; }
55
+ [ -f "$FILE" ] || { echo "bulk-read: not a readable file: $FILE" >&2; exit 1; }
56
+
57
+ HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
58
+
59
+ degrade() {
60
+ echo "bulk-read: DEGRADED - $1" >&2
61
+ echo "Read the region you need directly instead: Read('$FILE', offset: <n>, limit: <n>)." >&2
62
+ [ -x "$HERE/log-metric.sh" ] && \
63
+ "$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.degraded \
64
+ reason="$1" >/dev/null 2>&1
65
+ exit 3
66
+ }
67
+
68
+ # Resolve the three prefs this script honours - `bulkRead.model` (which rung the
69
+ # delegated read runs on), `bulkRead.timeoutSeconds` (how long it may take) and
70
+ # `bulkRead.maxBytes` (the ceiling past which delegation stops being a saving).
71
+ # Same search order and the same positive-integer rule as offload-ref.sh: a zero
72
+ # or a string takes the default rather than silently disabling the control.
73
+ PREF_MODEL=""
74
+ PREF_TIMEOUT=""
75
+ PREF_MAX_BYTES=""
76
+ if command -v jq >/dev/null 2>&1; then
77
+ for prefs in \
78
+ "$HOME/.claude/multi-agent-preferences.json" \
79
+ "$HOME/.config/multi-agent-pipeline/multi-agent-preferences.json" \
80
+ "$HOME/.claude/preferences.json" \
81
+ "$HOME/.config/multi-agent-pipeline/preferences.json"
82
+ do
83
+ [ -f "$prefs" ] || continue
84
+ values=$(jq -r '.global.bulkRead // {} | [(.model // ""), (.timeoutSeconds // ""), (.maxBytes // "")] | @tsv' \
85
+ "$prefs" 2>/dev/null) || true
86
+ PREF_MODEL=$(printf '%s' "$values" | cut -f1)
87
+ PREF_TIMEOUT=$(printf '%s' "$values" | cut -f2)
88
+ PREF_MAX_BYTES=$(printf '%s' "$values" | cut -f3)
89
+ break
90
+ done
91
+ fi
92
+ [ -n "$MODEL" ] || MODEL="${PREF_MODEL:-haiku}"
93
+ case "$TIMEOUT" in "" ) TIMEOUT="$PREF_TIMEOUT" ;; esac
94
+ case "$TIMEOUT" in ""|*[!0-9]*|0) TIMEOUT=60 ;; esac
95
+
96
+ command -v claude >/dev/null 2>&1 || degrade "the claude CLI is not on PATH"
97
+
98
+ LINES=$(wc -l < "$FILE" | tr -d ' ')
99
+ BYTES=$(wc -c < "$FILE" | tr -d ' ')
100
+
101
+ # A ceiling, because delegation is not free either. Past some size the worker's
102
+ # own input bill approaches the read it replaced, and a file large enough to
103
+ # strain the worker's window would come back truncated - a partial summary
104
+ # presented as a whole one is exactly what this must never produce. Degrading
105
+ # here hands the caller a cheaper move (grep for the symbol, then read around it)
106
+ # instead of an expensive round trip to a worse answer.
107
+ case "$PREF_MAX_BYTES" in ""|*[!0-9]*|0) MAX_BYTES=1048576 ;; *) MAX_BYTES="$PREF_MAX_BYTES" ;; esac
108
+ if [ "$BYTES" -gt "$MAX_BYTES" ]; then
109
+ echo "bulk-read: DEGRADED - ${FILE} is ${BYTES} bytes, past the ${MAX_BYTES}-byte ceiling" >&2
110
+ echo "At this size the delegated read costs about what reading it would, and risks a truncated" >&2
111
+ echo "summary presented as a whole one. Narrow it first: grep for the symbol you need, then" >&2
112
+ echo "Read('${FILE}', offset: <n>, limit: <n>) around the hit." >&2
113
+ [ -x "$HERE/log-metric.sh" ] && \
114
+ "$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.degraded \
115
+ reason=over-ceiling file_bytes="$BYTES" >/dev/null 2>&1
116
+ exit 3
117
+ fi
118
+
119
+ # Measured and accepted, so park the full text before asking anything: the
120
+ # pointer in the summary is then a promise that is already kept. The caller buys the rest back by reading this file - the same
121
+ # contract offload-ref.sh makes for build logs, and the same directory.
122
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
123
+ [ -n "$ROOT" ] || ROOT="$PWD"
124
+ REFS_DIR="$ROOT/.multi-agent/refs"
125
+ NODE_ID=""
126
+ if mkdir -p "$REFS_DIR" 2>/dev/null; then
127
+ GITIGNORE="$ROOT/.multi-agent/.gitignore"
128
+ if [ ! -f "$GITIGNORE" ]; then
129
+ printf '# Local run artefacts - never commit.\nmemory/\nrefs/\n' > "$GITIGNORE"
130
+ elif ! grep -q '^refs/$' "$GITIGNORE" 2>/dev/null; then
131
+ printf 'refs/\n' >> "$GITIGNORE"
132
+ fi
133
+ if command -v shasum >/dev/null 2>&1; then
134
+ DIGEST=$(shasum -a 256 "$FILE" | awk '{print substr($1,1,8)}')
135
+ elif command -v sha256sum >/dev/null 2>&1; then
136
+ DIGEST=$(sha256sum "$FILE" | awk '{print substr($1,1,8)}')
137
+ else
138
+ DIGEST=$(cksum < "$FILE" | awk '{print $1}')
139
+ fi
140
+ SLUG=$(basename "$FILE" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{1,\}/-/g; s/^-//; s/-$//')
141
+ NODE_ID="p${PHASE}-read-${SLUG:-file}-${DIGEST}"
142
+ cp "$FILE" "$REFS_DIR/${NODE_ID}.txt" 2>/dev/null || NODE_ID=""
143
+ fi
144
+
145
+ PROMPT_FILE="$(mktemp -t bulk-read.XXXXXX)"
146
+ OUT_FILE="$(mktemp -t bulk-read-out.XXXXXX)"
147
+ trap 'rm -f "$PROMPT_FILE" "$OUT_FILE"' EXIT
148
+
149
+ # The worker gets numbered lines because every claim it makes has to be
150
+ # addressable afterwards. `nl -ba` numbers blank lines too, so the numbers match
151
+ # the file's own and an offset computed from them is correct.
152
+ # The worker's contract is the bulk-reader PERSONA, loaded from disk rather than
153
+ # restated here. Two copies of the same prompt is the drift this repo keeps
154
+ # catching elsewhere: the copy that gets edited is never the copy that runs.
155
+ PERSONA=""
156
+ for candidate in \
157
+ "$HOME/.claude/agents/bulk-reader.md" \
158
+ "$HERE/../agents/bulk-reader.md"
159
+ do
160
+ [ -f "$candidate" ] && { PERSONA="$candidate"; break; }
161
+ done
162
+ [ -n "$PERSONA" ] || degrade "the bulk-reader persona is not installed"
163
+
164
+ # The frontmatter is dispatch metadata for the Agent tool, not instruction text;
165
+ # strip it and send the body.
166
+ {
167
+ awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$PERSONA"
168
+ printf '\n<question>\n%s\n</question>\n\n' "$QUESTION"
169
+ printf '<file path="%s" lines="%s">\n' "$FILE" "$LINES"
170
+ nl -ba "$FILE"
171
+ printf '\n</file>\n'
172
+ } > "$PROMPT_FILE"
173
+
174
+ START=$(date +%s)
175
+ # `claude -p` reads the prompt from stdin; the model flag names the rung. A
176
+ # non-zero exit, an empty answer and a non-JSON answer are all degradations, and
177
+ # each one names itself rather than falling through to a generic failure.
178
+ # macOS ships no `timeout`; coreutils installs it as `gtimeout`. Naming only the
179
+ # GNU spelling would leave the budget silently unenforced on the platform most of
180
+ # these runs happen on - a pref that does nothing, which is the class
181
+ # smoke-prefs-consumed exists to catch.
182
+ TIMEOUT_BIN=""
183
+ for candidate in timeout gtimeout; do
184
+ command -v "$candidate" >/dev/null 2>&1 && { TIMEOUT_BIN="$candidate"; break; }
185
+ done
186
+ if [ -n "$TIMEOUT_BIN" ]; then
187
+ "$TIMEOUT_BIN" "$TIMEOUT" claude -p --model "$MODEL" < "$PROMPT_FILE" > "$OUT_FILE" 2>/dev/null
188
+ STATUS=$?
189
+ else
190
+ # No timeout binary: the budget cannot be enforced, so say so rather than
191
+ # letting the caller believe timeoutSeconds is holding.
192
+ echo "bulk-read: no timeout binary found; bulkRead.timeoutSeconds is not enforced on this host" >&2
193
+ claude -p --model "$MODEL" < "$PROMPT_FILE" > "$OUT_FILE" 2>/dev/null
194
+ STATUS=$?
195
+ fi
196
+ ELAPSED=$(( $(date +%s) - START ))
197
+
198
+ [ "$STATUS" -eq 124 ] && degrade "the worker did not answer within ${TIMEOUT}s"
199
+ [ "$STATUS" -ne 0 ] && degrade "the worker exited $STATUS"
200
+ [ -s "$OUT_FILE" ] || degrade "the worker returned nothing"
201
+
202
+ RENDER=$(FILE="$FILE" LINES="$LINES" BYTES="$BYTES" NODE_ID="$NODE_ID" \
203
+ MODEL="$MODEL" ELAPSED="$ELAPSED" AS_JSON="$AS_JSON" \
204
+ python3 - "$OUT_FILE" <<'PYEOF'
205
+ import json, os, re, sys
206
+
207
+ raw = open(sys.argv[1], encoding="utf-8", errors="replace").read()
208
+ try:
209
+ data = json.loads(raw)
210
+ except Exception:
211
+ # A worker that wrapped its object in prose or a fence is still usable; a
212
+ # worker that answered in prose is not, and falls through to the degrade path.
213
+ m = re.search(r"\{[\s\S]*\}", raw)
214
+ if not m:
215
+ sys.exit(7)
216
+ try:
217
+ data = json.loads(m.group(0))
218
+ except Exception:
219
+ sys.exit(7)
220
+
221
+ if not isinstance(data, dict) or "summary" not in data:
222
+ sys.exit(7)
223
+
224
+ if os.environ.get("AS_JSON") == "1":
225
+ print(json.dumps(data, indent=2))
226
+ sys.exit(0)
227
+
228
+ out = []
229
+ out.append("bulk-read: %s (%s lines, %s bytes) via %s in %ss"
230
+ % (os.environ["FILE"], os.environ["LINES"], os.environ["BYTES"],
231
+ os.environ["MODEL"], os.environ["ELAPSED"]))
232
+ answer = (data.get("answer") or "").strip()
233
+ if answer:
234
+ out.append("")
235
+ out.append("ANSWER: " + answer)
236
+ out.append("")
237
+ out.append((data.get("summary") or "").strip())
238
+
239
+ symbols = [s for s in (data.get("symbols") or []) if isinstance(s, dict)]
240
+ if symbols:
241
+ out.append("")
242
+ out.append("Symbols:")
243
+ for s in symbols[:40]:
244
+ out.append(" %-6s %s (line %s)" % (s.get("kind", "?"), s.get("name", "?"), s.get("line", "?")))
245
+
246
+ regions = [r for r in (data.get("regions") or []) if isinstance(r, dict)]
247
+ if regions:
248
+ out.append("")
249
+ out.append("Read next (bounded, and this gate allows it):")
250
+ for r in regions[:8]:
251
+ start, end = r.get("start"), r.get("end")
252
+ span = ""
253
+ try:
254
+ span = " Read(offset: %d, limit: %d)" % (int(start), int(end) - int(start) + 1)
255
+ except Exception:
256
+ pass
257
+ out.append(" %s-%s %s%s" % (start, end, r.get("why", ""), span))
258
+
259
+ node = os.environ.get("NODE_ID") or ""
260
+ if node:
261
+ out.append("")
262
+ out.append("Full text: .multi-agent/refs/%s.txt [[ref:%s]]" % (node, node))
263
+ if data.get("truncated"):
264
+ out.append("")
265
+ out.append("NOTE: the worker reports it did not see the whole file.")
266
+ print("\n".join(out))
267
+ PYEOF
268
+ ) || degrade "the worker's answer was not the requested JSON object"
269
+
270
+ printf '%s\n' "$RENDER"
271
+
272
+ [ -x "$HERE/log-metric.sh" ] && \
273
+ "$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.delegated \
274
+ file_lines="$LINES" file_bytes="$BYTES" model="$MODEL" duration_ms="$(( ELAPSED * 1000 ))" \
275
+ >/dev/null 2>&1
276
+
277
+ exit 0