@mmerterden/multi-agent-pipeline 16.20.0 → 16.23.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 +231 -102
- package/README.md +6 -8
- package/README.tr.md +6 -8
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/features.md +1 -0
- package/install/templates/claude-hooks.json +12 -1
- package/install/templates/copilot-instructions.md +17 -2
- package/package.json +1 -1
- package/pipeline/agents/bulk-reader.md +57 -0
- package/pipeline/commands/multi-agent/SKILL.md +0 -5
- package/pipeline/commands/multi-agent/help/SKILL.md +0 -10
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/resume-local/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/sync/SKILL.md +13 -13
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -12
- package/pipeline/multi-agent-refs/phases/modes.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +7 -2
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +8 -1
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +46 -0
- package/pipeline/schemas/agent-state.schema.json +1 -1
- package/pipeline/schemas/bulk-read-output.schema.json +52 -0
- package/pipeline/schemas/prefs.schema.json +74 -19
- package/pipeline/schemas/token-budget.json +3 -3
- 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/phase-tracker.sh +245 -3
- package/pipeline/scripts/pre-commit-check.sh +1 -0
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/skills/.skill-manifest.json +8 -24
- package/pipeline/skills/.skills-index.json +2 -46
- package/pipeline/skills/shared/README.md +4 -8
- package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +0 -8
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-resume-local/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +12 -12
- package/pipeline/skills/shared/external/backlog/SKILL.md +10 -6
- package/pipeline/skills/skills-index.md +2 -6
- package/pipeline/commands/multi-agent/dev/SKILL.md +0 -17
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +0 -23
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +0 -17
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +0 -21
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +0 -19
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +0 -25
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +0 -19
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +0 -23
|
@@ -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
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""check-read-size.py - decision core for the read-size PreToolUse gate.
|
|
3
|
+
|
|
4
|
+
Reads a Claude Code hook payload on stdin and prints ONE line:
|
|
5
|
+
|
|
6
|
+
PASS
|
|
7
|
+
OBSERVE\t<path>\t<lines>\t<why>
|
|
8
|
+
BLOCK\t<path>\t<lines>\t<why>
|
|
9
|
+
|
|
10
|
+
It decides only. The wrapper (check-read-size.sh) owns telemetry, the message
|
|
11
|
+
the model sees, and the exit code. Splitting them is what lets the smoke gate
|
|
12
|
+
drive the decision without a hook harness, and it keeps the blocking path in
|
|
13
|
+
shell where the other two gates already live.
|
|
14
|
+
|
|
15
|
+
Why a gate at all: a phase that reads six 900-line files pays for 5,400 lines of
|
|
16
|
+
Swift at the phase's own rung, and the useful content is a handful of symbols.
|
|
17
|
+
`offload-ref.sh` already caught the other half of that bill - the build log - but
|
|
18
|
+
nothing looked at reads.
|
|
19
|
+
|
|
20
|
+
Why it must not simply block: in Claude Code, `Edit` requires that the SAME file
|
|
21
|
+
was read first. A gate that blocks reads during development stops the pipeline
|
|
22
|
+
from editing anything. So the development phase is exempt by default, and so is
|
|
23
|
+
any file already in the run's edit set. The gate exists for the phases that read
|
|
24
|
+
to UNDERSTAND (analysis, review), not for the one that reads to change.
|
|
25
|
+
|
|
26
|
+
Modes (prefs `global.bulkRead.mode`):
|
|
27
|
+
off nothing is inspected (default)
|
|
28
|
+
observe decide, log, never block - the baseline measurement
|
|
29
|
+
enforce block a read over the threshold
|
|
30
|
+
|
|
31
|
+
Fail-open everywhere: any error prints PASS. A gate that guesses wrong must cost
|
|
32
|
+
a log line, never a tool call.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
# Shipped defaults for the two prefs read below: `bulkRead.minLines` and
|
|
42
|
+
# `bulkRead.exemptPhases`. A pref that resolves to anything else - absent, zero,
|
|
43
|
+
# a string - takes these rather than silently turning the gate off.
|
|
44
|
+
DEFAULT_MIN_LINES = 350
|
|
45
|
+
DEFAULT_EXEMPT_PHASES = ["3"]
|
|
46
|
+
|
|
47
|
+
PREF_PATHS = [
|
|
48
|
+
"~/.claude/multi-agent-preferences.json",
|
|
49
|
+
"~/.config/multi-agent-pipeline/multi-agent-preferences.json",
|
|
50
|
+
"~/.claude/preferences.json",
|
|
51
|
+
"~/.config/multi-agent-pipeline/preferences.json",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Commands that read a whole file to stdout. `sed` is here for `sed -n '1,900p'`,
|
|
55
|
+
# which is the spelling an agent reaches for when Read is unavailable.
|
|
56
|
+
READ_COMMANDS = {"cat", "head", "tail", "sed", "bat", "less", "more"}
|
|
57
|
+
|
|
58
|
+
# `1,900p` / `1,$p` / `900p` - a sed print script with a line range.
|
|
59
|
+
SED_RANGE = re.compile(r"^'?(\d+)(?:,(\d+|\$))?p'?$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def emit(verdict, path="", lines=0, why=""):
|
|
63
|
+
sys.stdout.write("%s\t%s\t%s\t%s\n" % (verdict, path, lines, why))
|
|
64
|
+
sys.exit(0)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_prefs():
|
|
68
|
+
for raw in PREF_PATHS:
|
|
69
|
+
p = os.path.expanduser(raw)
|
|
70
|
+
if not os.path.isfile(p):
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
with open(p, "r", encoding="utf-8") as fh:
|
|
74
|
+
return json.load(fh).get("global", {}).get("bulkRead", {}) or {}
|
|
75
|
+
except Exception:
|
|
76
|
+
return {}
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def positive_int(value, default):
|
|
81
|
+
"""A pref counts only as a positive integer; anything else takes the default.
|
|
82
|
+
|
|
83
|
+
Same rule as offload-ref.sh: a zero or a string must not silently disable
|
|
84
|
+
the threshold, because that reads as "the gate is off" when it is broken.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
n = int(value)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return default
|
|
90
|
+
return n if n > 0 else default
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_state():
|
|
94
|
+
"""Walk up from cwd for the run's agent-state.json.
|
|
95
|
+
|
|
96
|
+
A hook runs in the worktree where the tool call happens, so the state file
|
|
97
|
+
is at cwd or above it. The env override exists for the smoke gate.
|
|
98
|
+
"""
|
|
99
|
+
override = os.environ.get("MULTI_AGENT_STATE")
|
|
100
|
+
if override:
|
|
101
|
+
return override if os.path.isfile(override) else None
|
|
102
|
+
here = os.path.abspath(os.getcwd())
|
|
103
|
+
while True:
|
|
104
|
+
candidate = os.path.join(here, "agent-state.json")
|
|
105
|
+
if os.path.isfile(candidate):
|
|
106
|
+
return candidate
|
|
107
|
+
parent = os.path.dirname(here)
|
|
108
|
+
if parent == here:
|
|
109
|
+
return None
|
|
110
|
+
here = parent
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_context():
|
|
114
|
+
"""(phase, edit_set) for the current run, both best-effort.
|
|
115
|
+
|
|
116
|
+
Both come from agent-state.json's REAL shape, checked against
|
|
117
|
+
pipeline/schemas/agent-state.schema.json: the phase is the top-level
|
|
118
|
+
`currentPhase` integer, and the files a phase has touched are
|
|
119
|
+
`phases["<n>"].files[]` - the same list semantic revert uses. An earlier
|
|
120
|
+
draft of this read `run.phase` and `dev.editSet`, neither of which the
|
|
121
|
+
schema declares, so the exemption could never have engaged from state and
|
|
122
|
+
the gate would have blocked development. smoke-bulk-read.sh asserts the
|
|
123
|
+
field names against the schema now, so that cannot come back quietly.
|
|
124
|
+
"""
|
|
125
|
+
phase = os.environ.get("MULTI_AGENT_PHASE", "")
|
|
126
|
+
edit_set = set()
|
|
127
|
+
path = find_state()
|
|
128
|
+
if not path:
|
|
129
|
+
return phase, edit_set
|
|
130
|
+
try:
|
|
131
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
132
|
+
state = json.load(fh)
|
|
133
|
+
except Exception:
|
|
134
|
+
return phase, edit_set
|
|
135
|
+
if not phase:
|
|
136
|
+
current = state.get("currentPhase")
|
|
137
|
+
phase = str(current) if isinstance(current, int) else ""
|
|
138
|
+
# Every phase's file list counts, not only the current one: a review that
|
|
139
|
+
# re-reads what development just wrote is reading the run's own work.
|
|
140
|
+
phases = state.get("phases")
|
|
141
|
+
if isinstance(phases, dict):
|
|
142
|
+
for entry in phases.values():
|
|
143
|
+
if not isinstance(entry, dict):
|
|
144
|
+
continue
|
|
145
|
+
for f in entry.get("files") or []:
|
|
146
|
+
if isinstance(f, str):
|
|
147
|
+
edit_set.add(os.path.basename(f))
|
|
148
|
+
return phase, edit_set
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def count_lines(path):
|
|
152
|
+
"""Line count, or -1 when the file cannot be measured.
|
|
153
|
+
|
|
154
|
+
Binary-safe and streamed: the gate must not itself load the payload it
|
|
155
|
+
exists to keep out of memory.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
total = 0
|
|
159
|
+
with open(path, "rb") as fh:
|
|
160
|
+
while True:
|
|
161
|
+
chunk = fh.read(1 << 20)
|
|
162
|
+
if not chunk:
|
|
163
|
+
break
|
|
164
|
+
total += chunk.count(b"\n")
|
|
165
|
+
return total
|
|
166
|
+
except Exception:
|
|
167
|
+
return -1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def as_count(token):
|
|
171
|
+
"""A flag's numeric argument, or a large sentinel when it is not a number.
|
|
172
|
+
|
|
173
|
+
Not-a-number must read as UNBOUNDED, not as 1: treating an unparseable
|
|
174
|
+
count as a tiny one is how a gate lets through exactly the reads it exists
|
|
175
|
+
to catch.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
n = int(token)
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
return 1 << 30
|
|
181
|
+
return abs(n)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def looks_like_sed_script(token):
|
|
185
|
+
return bool(SED_RANGE.match(token or ""))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def sed_span(script):
|
|
189
|
+
"""Lines a `sed` print script covers, or a large sentinel when unclear."""
|
|
190
|
+
m = SED_RANGE.match(script or "")
|
|
191
|
+
if not m:
|
|
192
|
+
return 1 << 30
|
|
193
|
+
start, end = m.group(1), m.group(2)
|
|
194
|
+
try:
|
|
195
|
+
if end in (None, "", "$"):
|
|
196
|
+
return 1 << 30
|
|
197
|
+
return abs(int(end) - int(start)) + 1
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
return 1 << 30
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def read_target(payload):
|
|
203
|
+
"""The file a tool call would read whole, or None.
|
|
204
|
+
|
|
205
|
+
Returns None for every call that is already bounded - a Read with a small
|
|
206
|
+
`limit`, a `head -n 50` - because those are not what this gate is for.
|
|
207
|
+
"""
|
|
208
|
+
tool = payload.get("tool_name", "")
|
|
209
|
+
args = payload.get("tool_input", {}) or {}
|
|
210
|
+
|
|
211
|
+
if tool == "Read":
|
|
212
|
+
path = args.get("file_path")
|
|
213
|
+
if not path:
|
|
214
|
+
return None
|
|
215
|
+
limit = args.get("limit")
|
|
216
|
+
if isinstance(limit, int) and limit > 0:
|
|
217
|
+
return (path, limit)
|
|
218
|
+
return (path, None)
|
|
219
|
+
|
|
220
|
+
if tool != "Bash":
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
command = args.get("command", "")
|
|
224
|
+
if not command:
|
|
225
|
+
return None
|
|
226
|
+
# A compound command is not decided here: splitting on shell operators
|
|
227
|
+
# correctly is the kind of parsing that goes wrong quietly, and a wrong
|
|
228
|
+
# block is worse than a missed one.
|
|
229
|
+
for operator in ("&&", "||", "|", ";", "$(", "`", ">", "<"):
|
|
230
|
+
if operator in command:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
tokens = shlex.split(command)
|
|
234
|
+
except ValueError:
|
|
235
|
+
return None
|
|
236
|
+
if not tokens or os.path.basename(tokens[0]) not in READ_COMMANDS:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
tool = os.path.basename(tokens[0])
|
|
240
|
+
bound = None
|
|
241
|
+
operands = []
|
|
242
|
+
i = 1
|
|
243
|
+
while i < len(tokens):
|
|
244
|
+
token = tokens[i]
|
|
245
|
+
|
|
246
|
+
if not token.startswith("-") or token == "-":
|
|
247
|
+
if tool == "sed" and not operands and looks_like_sed_script(token):
|
|
248
|
+
# `sed '1,900p' file` - the script, not the file.
|
|
249
|
+
bound = max(bound or 0, sed_span(token))
|
|
250
|
+
else:
|
|
251
|
+
operands.append(token)
|
|
252
|
+
i += 1
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# A following-tail stream is not a bulk read at all; blocking it would
|
|
256
|
+
# be a false positive with no cheap alternative to offer.
|
|
257
|
+
if tool == "tail" and token in ("-f", "-F", "--follow"):
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
# Only head/tail take a separate count operand. `cat -n` NUMBERS LINES -
|
|
261
|
+
# reading its next token as a count swallowed the filename, left no
|
|
262
|
+
# operand, and let `cat -n <bigfile>` through the gate entirely.
|
|
263
|
+
if tool in ("head", "tail") and token in ("-n", "-c"):
|
|
264
|
+
nxt = tokens[i + 1] if i + 1 < len(tokens) else ""
|
|
265
|
+
bound = max(bound or 0, as_count(nxt))
|
|
266
|
+
i += 2
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# `sed -n` suppresses default output; the range lives in the script,
|
|
270
|
+
# which arrives as the next operand and is handled above.
|
|
271
|
+
if tool == "head" or tool == "tail":
|
|
272
|
+
attached = token.lstrip("-")
|
|
273
|
+
if attached.isdigit():
|
|
274
|
+
bound = max(bound or 0, as_count(attached))
|
|
275
|
+
elif attached[:1] in ("n", "c") and attached[1:].isdigit():
|
|
276
|
+
bound = max(bound or 0, as_count(attached[1:]))
|
|
277
|
+
i += 1
|
|
278
|
+
|
|
279
|
+
if len(operands) != 1:
|
|
280
|
+
return None
|
|
281
|
+
return (operands[0], bound)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main():
|
|
285
|
+
try:
|
|
286
|
+
payload = json.load(sys.stdin)
|
|
287
|
+
except Exception:
|
|
288
|
+
emit("PASS", why="unparseable payload")
|
|
289
|
+
|
|
290
|
+
# `bulkRead.mode` decides whether this gate does anything at all.
|
|
291
|
+
prefs = load_prefs()
|
|
292
|
+
mode = str(prefs.get("mode", "off") or "off").lower()
|
|
293
|
+
if mode not in ("observe", "enforce"):
|
|
294
|
+
emit("PASS", why="mode=%s" % mode)
|
|
295
|
+
|
|
296
|
+
target = read_target(payload)
|
|
297
|
+
if target is None:
|
|
298
|
+
emit("PASS", why="not a whole-file read")
|
|
299
|
+
path, bound = target
|
|
300
|
+
|
|
301
|
+
min_lines = positive_int(prefs.get("minLines"), DEFAULT_MIN_LINES)
|
|
302
|
+
if bound is not None and bound < min_lines:
|
|
303
|
+
emit("PASS", path, 0, "bounded read")
|
|
304
|
+
|
|
305
|
+
if not os.path.isfile(path):
|
|
306
|
+
emit("PASS", path, 0, "not a readable file")
|
|
307
|
+
|
|
308
|
+
lines = count_lines(path)
|
|
309
|
+
if lines < 0:
|
|
310
|
+
emit("PASS", path, 0, "unmeasurable")
|
|
311
|
+
if lines < min_lines:
|
|
312
|
+
emit("PASS", path, lines, "under threshold")
|
|
313
|
+
|
|
314
|
+
phase, edit_set = run_context()
|
|
315
|
+
exempt = prefs.get("exemptPhases")
|
|
316
|
+
if not isinstance(exempt, list) or not exempt:
|
|
317
|
+
exempt = DEFAULT_EXEMPT_PHASES
|
|
318
|
+
exempt = [str(x) for x in exempt]
|
|
319
|
+
|
|
320
|
+
# The two exemptions that keep Edit working. Both are stated as PASS with a
|
|
321
|
+
# reason rather than skipped silently, so the telemetry shows how often the
|
|
322
|
+
# gate declined to act and why.
|
|
323
|
+
if phase and phase in exempt:
|
|
324
|
+
emit("PASS", path, lines, "phase %s exempt" % phase)
|
|
325
|
+
if os.path.basename(path) in edit_set:
|
|
326
|
+
emit("PASS", path, lines, "in edit set")
|
|
327
|
+
|
|
328
|
+
emit("OBSERVE" if mode == "observe" else "BLOCK", path, lines, "over threshold")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == "__main__":
|
|
332
|
+
try:
|
|
333
|
+
main()
|
|
334
|
+
except Exception:
|
|
335
|
+
emit("PASS", why="internal error")
|