@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.
- package/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/docs/features.md +1 -0
- package/install/_codex-agents.mjs +2 -2
- package/install/_dev-only-files.mjs +1 -0
- package/install/templates/claude-hooks.json +12 -1
- package/package.json +4 -3
- package/pipeline/agents/android-architect.md +1 -0
- package/pipeline/agents/backend-architect.md +1 -0
- package/pipeline/agents/bulk-reader.md +57 -0
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/dev-critic.md +2 -1
- package/pipeline/agents/explorer.md +1 -0
- package/pipeline/agents/ios-architect.md +1 -0
- package/pipeline/agents/security-auditor.md +1 -0
- package/pipeline/agents/task-clarifier.md +1 -0
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +22 -0
- package/pipeline/commands/sim-test.md +10 -5
- package/pipeline/multi-agent-refs/channels/pr.md +22 -4
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +15 -2
- package/pipeline/multi-agent-refs/features/review-delta.md +89 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +41 -0
- package/pipeline/multi-agent-refs/features/verify-by-test.md +6 -5
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/payload-contracts.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +17 -2
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +46 -8
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +4 -2
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/rules.md +2 -2
- package/pipeline/schemas/agent-state.schema.json +129 -0
- package/pipeline/schemas/bulk-read-output.schema.json +52 -0
- package/pipeline/schemas/dev-critic-output.schema.json +5 -0
- package/pipeline/schemas/prefs.schema.json +121 -19
- package/pipeline/schemas/reviewer-output.schema.json +7 -2
- package/pipeline/schemas/scope-check.schema.json +55 -0
- package/pipeline/schemas/token-budget.json +3 -3
- package/pipeline/schemas/triage-output.schema.json +12 -2
- package/pipeline/scripts/README.md +3 -2
- package/pipeline/scripts/_fingerprint.mjs +173 -0
- package/pipeline/scripts/bulk-read.sh +277 -0
- package/pipeline/scripts/check-read-size.py +335 -0
- package/pipeline/scripts/check-read-size.sh +86 -0
- package/pipeline/scripts/evidence-gate.mjs +73 -5
- package/pipeline/scripts/finding-fingerprint.mjs +101 -0
- package/pipeline/scripts/pre-commit-check.sh +1 -0
- package/pipeline/scripts/review-delta.mjs +217 -0
- package/pipeline/scripts/run-metrics.mjs +20 -0
- package/pipeline/scripts/scope-check-gate.mjs +90 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +11 -4
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/scripts/validate-reviewer.mjs +6 -0
- package/pipeline/scripts/validate-triage.mjs +20 -0
- package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +5 -0
- 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
|