@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +231 -102
  2. package/README.md +6 -8
  3. package/README.tr.md +6 -8
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/features.md +1 -0
  7. package/install/templates/claude-hooks.json +12 -1
  8. package/install/templates/copilot-instructions.md +17 -2
  9. package/package.json +1 -1
  10. package/pipeline/agents/bulk-reader.md +57 -0
  11. package/pipeline/commands/multi-agent/SKILL.md +0 -5
  12. package/pipeline/commands/multi-agent/help/SKILL.md +0 -10
  13. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
  14. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -1
  15. package/pipeline/commands/multi-agent/resume-local/SKILL.md +2 -2
  16. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  17. package/pipeline/commands/multi-agent/sync/SKILL.md +13 -13
  18. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -12
  19. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-0-init.md +7 -2
  21. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
  22. package/pipeline/multi-agent-refs/phases/phase-7-report.md +8 -1
  23. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  24. package/pipeline/multi-agent-refs/tracker-contract.md +46 -0
  25. package/pipeline/schemas/agent-state.schema.json +1 -1
  26. package/pipeline/schemas/bulk-read-output.schema.json +52 -0
  27. package/pipeline/schemas/prefs.schema.json +74 -19
  28. package/pipeline/schemas/token-budget.json +3 -3
  29. package/pipeline/scripts/bulk-read.sh +277 -0
  30. package/pipeline/scripts/check-read-size.py +335 -0
  31. package/pipeline/scripts/check-read-size.sh +86 -0
  32. package/pipeline/scripts/phase-tracker.sh +245 -3
  33. package/pipeline/scripts/pre-commit-check.sh +1 -0
  34. package/pipeline/scripts/uninstall.mjs +1 -0
  35. package/pipeline/skills/.skill-manifest.json +8 -24
  36. package/pipeline/skills/.skills-index.json +2 -46
  37. package/pipeline/skills/shared/README.md +4 -8
  38. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +0 -8
  39. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +1 -1
  40. package/pipeline/skills/shared/core/multi-agent-resume-local/SKILL.md +1 -1
  41. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +12 -12
  42. package/pipeline/skills/shared/external/backlog/SKILL.md +10 -6
  43. package/pipeline/skills/skills-index.md +2 -6
  44. package/pipeline/commands/multi-agent/dev/SKILL.md +0 -17
  45. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +0 -23
  46. package/pipeline/commands/multi-agent/dev-local/SKILL.md +0 -17
  47. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +0 -21
  48. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +0 -19
  49. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +0 -25
  50. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +0 -19
  51. 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")