@mmerterden/multi-agent-pipeline 16.20.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 +24 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/docs/features.md +1 -0
- package/install/templates/claude-hooks.json +12 -1
- package/package.json +1 -1
- package/pipeline/agents/bulk-reader.md +57 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/schemas/bulk-read-output.schema.json +52 -0
- package/pipeline/schemas/prefs.schema.json +74 -19
- 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/pre-commit-check.sh +1 -0
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/skills/shared/external/backlog/SKILL.md +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,30 @@ Internal file-layout changes that don't affect the slash-command surface are sti
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [16.21.0] - 2026-09-08
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **A read-size gate, and a cheap worker to send an oversized read to.** `offload-ref.sh` took half the machinery bill in 15.10.0 - the build log, the diff, the test output become a pointer plus a tail. The other half was never looked at, because nothing could look at it: only a `PreToolUse` hook sees a tool call before it runs. A phase that reads six 900-line files pays for 5,400 lines at that phase's own rung, and the part it needed was a handful of symbols.
|
|
24
|
+
|
|
25
|
+
`check-read-size.sh` (+ `check-read-size.py`, the decision core) is the third hook in `install/templates/claude-hooks.json` and the first on the READ side. `bulk-read.sh` is where a blocked read goes instead: the file reaches a haiku-rung worker over the `bulk-reader` persona, and what comes back is a structured summary with LINE NUMBERS, so the caller's next step is a bounded `Read(offset:limit:)` around the region that matters - which is cheap, exact, and passes the gate. The full text is parked under `.multi-agent/refs/`, the same place and the same promise `offload-ref.sh` makes.
|
|
26
|
+
|
|
27
|
+
**`observe` is a real mode, not a debug flag.** It decides and logs, blocks nothing, and it is what `bulkRead.mode` should be set to first: a gate switched straight to `enforce` has no baseline to be compared against, and "we cut tokens" becomes a claim nothing can check. The saving is measured from `metrics.jsonl` (`read.observed` / `read.blocked` / `bulk_read.delegated`), not asserted here - this entry deliberately quotes no percentage.
|
|
28
|
+
|
|
29
|
+
**Phase 3 is exempt, and that is load-bearing.** Claude Code's `Edit` requires the same file to have been `Read` first, so a gate that blocks reads while code is being changed blocks the change. So does any file the run itself has touched (`phases[n].files[]`). The gate is for the phases that read to UNDERSTAND. A draft of the decision core read `run.phase` and `dev.editSet` - neither of which `agent-state.schema.json` declares - so the exemption could never have engaged and development would have been blocked by a hook whose message read like advice; `smoke-bulk-read.sh` now asserts the field names against the schema so it cannot come back quietly.
|
|
30
|
+
|
|
31
|
+
**A ceiling, because delegation is not free either** (`bulkRead.maxBytes`, default 1 MB). Past some size the worker's own input bill approaches the read it replaced, and a file large enough to strain its window comes back truncated - a partial summary presented as a whole one is the one thing this feature must never produce. Over the ceiling the caller is told to narrow first (grep, then a bounded read). The check runs BEFORE the file is hashed or parked, so the guard is not theatre.
|
|
32
|
+
|
|
33
|
+
**The command parser is per-tool, because the flags are not shared.** `cat -n` NUMBERS LINES rather than taking a count, and reading its next token as one swallowed the filename, left no operand, and let `cat -n <bigfile>` through the gate entirely. `sed -n '1,900p'` looked bounded because it carried a flag, when the range in its script IS the bound. An unparseable count reads as UNBOUNDED, never as 1. A `tail -f` is a stream and passes, because blocking it would be a false positive with no cheap alternative to offer.
|
|
34
|
+
|
|
35
|
+
Off by default (`bulkRead.mode: "off"`), so merging the hook block is not itself a behaviour change. The worker degrades rather than inventing: no CLI, no auth, a timeout, or a non-JSON answer prints why and tells the caller to do a bounded read. A fabricated summary of a file nobody read is the one outcome worse than paying full price for the file.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **`multi-agent:setup` Step 8 and the picker-contract stop miscounting the hooks.** Both said two gates ship, and the setup step called the secret scan "the only deterministic gate that is OS-enforceable as a hook" - untrue since `agent-guard.sh` landed. Three ship now, and both places say so, along with which side of the run each one inspects.
|
|
40
|
+
- **`smoke-gate-hooks.sh` covers all three gates** rather than two, and asserts the shipped defaults are safe (the read gate off, phase 3 exempt) - a read gate that shipped switched on would have been a breaking change disguised as a merge.
|
|
41
|
+
|
|
42
|
+
|
|
19
43
|
## [16.20.0] - 2026-09-07
|
|
20
44
|
|
|
21
45
|
A comparison against `msitarzewski/agency-agents`, a 150k-star persona catalog, found nothing to copy wholesale and three things to fix: the personas were never registered, the circuit-breaker was prose, and a review finding had no identity from one round to the next. This release gives findings that identity and builds the loop controls, the handoff records and the evidence rules on top of it.
|
package/README.md
CHANGED
|
@@ -213,7 +213,7 @@ The pipeline runs natively on **Claude Code**, **Copilot CLI** and **Codex CLI**
|
|
|
213
213
|
|
|
214
214
|
| Tool | Flag | What it installs |
|
|
215
215
|
|---|---|---|
|
|
216
|
-
| Claude Code | `--claude` (default) | slash commands + skills + agents + `PreToolUse` secret
|
|
216
|
+
| Claude Code | `--claude` (default) | slash commands + skills + agents + three `PreToolUse` hooks (secret scan, agent-guard, read-size gate) |
|
|
217
217
|
| Copilot CLI | `--copilot` | instructions + 55 sub-command skills + scripts |
|
|
218
218
|
| Codex CLI | `--codex` | one router skill + 55 specs as refs + 8 agent TOML + `AGENTS.md` block + `codex mcp add` |
|
|
219
219
|
|
package/README.tr.md
CHANGED
|
@@ -213,7 +213,7 @@ Pipeline **Claude Code**, **Copilot CLI** ve **Codex CLI** üzerinde native çal
|
|
|
213
213
|
|
|
214
214
|
| Araç | Bayrak | Ne kurar |
|
|
215
215
|
|---|---|---|
|
|
216
|
-
| Claude Code | `--claude` (varsayılan) | slash komutları + skill'ler + agent'lar + `PreToolUse`
|
|
216
|
+
| Claude Code | `--claude` (varsayılan) | slash komutları + skill'ler + agent'lar + üç `PreToolUse` hook'u (secret scan, agent-guard, okuma-boyutu geçidi) |
|
|
217
217
|
| Copilot CLI | `--copilot` | talimatlar + 55 alt-komut skill'i + script'ler |
|
|
218
218
|
| Codex CLI | `--codex` | bir router skill + ref olarak 55 spec + 8 agent TOML + `AGENTS.md` bloğu + `codex mcp add` |
|
|
219
219
|
|
package/docs/features.md
CHANGED
|
@@ -222,6 +222,7 @@ Phase 3 treats the issue-tracker status update as a required step with a post-mu
|
|
|
222
222
|
## Safety & Hygiene
|
|
223
223
|
|
|
224
224
|
- **Pre-Commit Secret Detection** (12 patterns): `PreToolUse` hook scans staged files for API keys/tokens, AWS access keys, private keys, `.env` files, service account JSON. Commit **blocked** if found.
|
|
225
|
+
- **Read-Size Gate** (opt-in, `prefs.global.bulkRead.mode`): a `PreToolUse` hook inspects `Read` and the shell commands that read a file whole. In `observe` it only logs what it would have caught - the baseline you measure before routing anything. In `enforce` a file over `minLines` (default 350) is blocked and delegated to a haiku-rung worker (`bulk-read.sh`), which returns a line-numbered summary so the follow-up is a bounded `Read(offset:limit:)` instead of the whole file; the full text is parked under `.multi-agent/refs/`. The development phase and any file the run has already touched are exempt, because Claude Code's `Edit` requires its own `Read` first.
|
|
225
226
|
- **Build Queue**: All `xcodebuild` calls acquire a lock. Each worktree uses own `-derivedDataPath`. Stale locks auto-clean after 15 min. Non-Xcode builds don't need the lock.
|
|
226
227
|
- **Context Management**: `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=65` - compaction at 65% usage (prevents degradation in 8-phase sessions).
|
|
227
228
|
- **3-Iteration Hard Kill**: Any retry loop stops after 3 attempts, then pauses for user. No infinite loops.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_readme": "Recommended Claude Code hooks for multi-agent-pipeline. Merge the `hooks` object into your ~/.claude/settings.json to make these deterministic, OS-enforced PreToolUse gates real (exit 2 blocks the tool call) rather than prompt-level hopes.
|
|
2
|
+
"_readme": "Recommended Claude Code hooks for multi-agent-pipeline. Merge the `hooks` object into your ~/.claude/settings.json to make these deterministic, OS-enforced PreToolUse gates real (exit 2 blocks the tool call) rather than prompt-level hopes. Three gates ship here: (1) a staged-diff secret scan on git commit (pre-commit-check.sh); (2) an agent-guard on git commit + git push (agent-guard.sh) that blocks AI/assistant attribution in commit messages and force-push to a protected branch (main/master/develop); (3) a read-size gate on Read and Bash (check-read-size.sh), which inspects Read plus the shell commands that read a file whole (cat/head/tail/sed) and returns immediately for everything else, which routes an oversized read to a cheap worker instead of the caller's own rung. The first two inspect what a run WRITES; the third inspects what it pays to READ, and it is inert until `prefs.global.bulkRead.mode` is set to observe or enforce - so merging this block changes nothing until you opt in. All three are self-contained, fail-open on internal error, never execute the inspected command, and need no run-specific arguments, which is why they are naturally PreToolUse hooks. The other deterministic gates (evidence, consensus, intent, learnings) take run-specific arguments and are phase-enforced by the pipeline instead. multi-agent:setup offers to merge this block.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
"statusMessage": "Checking push safety..."
|
|
30
30
|
}
|
|
31
31
|
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"matcher": "Read|Bash",
|
|
35
|
+
"hooks": [
|
|
36
|
+
{
|
|
37
|
+
"type": "command",
|
|
38
|
+
"command": "bash $HOME/.claude/scripts/check-read-size.sh",
|
|
39
|
+
"timeout": 10,
|
|
40
|
+
"statusMessage": "Checking read size..."
|
|
41
|
+
}
|
|
42
|
+
]
|
|
32
43
|
}
|
|
33
44
|
]
|
|
34
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-pipeline",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.21.0",
|
|
4
4
|
"description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bulk-reader
|
|
3
|
+
description: "Reads ONE large file and returns a structured, line-numbered summary so the full text never enters the caller's context. Dispatched by bulk-read.sh when check-read-size.sh blocks a whole-file read. Haiku by default; a delegated read costs a fraction of a cent."
|
|
4
|
+
model: haiku
|
|
5
|
+
preferredModel: haiku
|
|
6
|
+
modelRationale: "Reading a file and reporting what is in it is extraction, not judgement - the task has a single source, a fixed output shape, and no reasoning chain. Haiku is the right rung and the whole point: the saving is the difference between this rung and the caller's. A worker that reasons is the wrong tool here, and the contract below forbids it explicitly, because a cheap rung's opinion about code is worth less than nothing."
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Bulk Reader
|
|
10
|
+
|
|
11
|
+
You are given ONE file and ONE question. You return ONE JSON object and nothing
|
|
12
|
+
else: no prose before it, no markdown fence around it, no commentary after it.
|
|
13
|
+
|
|
14
|
+
The file arrives with its lines numbered. Those numbers are the file's own, so a
|
|
15
|
+
number you report is a number the caller can open directly.
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
- **Every claim carries the line numbers it comes from.** A claim without them is
|
|
20
|
+
not usable - the caller cannot open it, cannot check it, and ends up reading
|
|
21
|
+
the file itself, having now paid for it twice. If you cannot cite it, do not
|
|
22
|
+
claim it.
|
|
23
|
+
- **You describe what IS in the file.** You do not review it, do not judge its
|
|
24
|
+
quality, do not propose changes, and do not name defects. Judgement about code
|
|
25
|
+
is the caller's; you are here so the caller has something to judge.
|
|
26
|
+
- **You never guess.** If the question cannot be answered from this file, say
|
|
27
|
+
exactly that in `answer` and return an empty `regions`. A confident wrong
|
|
28
|
+
summary is the one outcome worse than the caller paying full price for the
|
|
29
|
+
file, because nothing downstream can tell it is wrong.
|
|
30
|
+
- **`regions` are where a reader should look next**, most important first, at
|
|
31
|
+
most 8. Each one is a span worth opening on its own - not the whole file
|
|
32
|
+
restated as one region.
|
|
33
|
+
- If you did not see the whole file, set `truncated: true`. Do not summarize a
|
|
34
|
+
part as though it were the whole.
|
|
35
|
+
|
|
36
|
+
## Output Format
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"answer": "<direct answer to the question, or why this file cannot answer it>",
|
|
41
|
+
"summary": "<what this file is and does, 3-6 sentences>",
|
|
42
|
+
"symbols": [{"name": "<declaration>", "kind": "type|func|var|extension|other", "line": 42}],
|
|
43
|
+
"regions": [{"why": "<what a reader finds here>", "start": 120, "end": 180}],
|
|
44
|
+
"truncated": false
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Contract: `pipeline/schemas/bulk-read-output.schema.json`.
|
|
49
|
+
|
|
50
|
+
## What this agent does NOT do
|
|
51
|
+
|
|
52
|
+
- Does NOT review, rate, or critique the code it reads.
|
|
53
|
+
- Does NOT read a second file, follow an import, or look anything up.
|
|
54
|
+
- Does NOT answer from prior knowledge of a framework - only from this file.
|
|
55
|
+
- Does NOT edit anything. It has no write path by design: a summary has no
|
|
56
|
+
reliable basis for an edit, which is why the caller comes back with a bounded
|
|
57
|
+
read before changing a line.
|
|
@@ -813,13 +813,15 @@ To set up multi-agent on a new machine:
|
|
|
813
813
|
|
|
814
814
|
All tokens are optional in the sense that every service can be answered with Skip - but the ASKING is not optional: the Step 3 sequential loop still walks every missing service one by one (token → author → host). Phase 0 re-asks at runtime only for tokens the user skipped here.
|
|
815
815
|
|
|
816
|
-
### Step 8 - Enforcement
|
|
816
|
+
### Step 8 - Enforcement hooks (optional, Claude Code)
|
|
817
817
|
|
|
818
|
-
Offer to make the
|
|
818
|
+
Offer to make the three hookable gates HARD (a non-zero exit blocks the tool call). The block ships at `install/templates/claude-hooks.json`: secret scan, agent-guard, read-size gate.
|
|
819
|
+
|
|
820
|
+
- Ask (picker): "Install the pipeline's PreToolUse gates into `~/.claude/settings.json`?" Default Yes.
|
|
821
|
+
- On Yes, deep-merge the template's `hooks.PreToolUse` (preserve existing hooks; never duplicate a matcher already calling the same script).
|
|
822
|
+
- Say what the merge does NOT cover: only these three need no run-specific arguments, so only these three are hookable; the rest are phase-enforced.
|
|
823
|
+
- Say what it does not turn on: the read-size gate is inert until `prefs.global.bulkRead.mode` is set. Recommend `observe` first. Why, and the Phase 3 exemption: `$HOME/.claude/multi-agent-refs/picker-contract.md`.
|
|
819
824
|
|
|
820
|
-
- Ask (picker): "Install the pre-commit secret-scan hook into `~/.claude/settings.json`?" Default Yes.
|
|
821
|
-
- On Yes, deep-merge the template's `hooks.PreToolUse` into the user's `settings.json` (preserve any existing hooks; do not duplicate a matcher that already calls `pre-commit-check.sh`).
|
|
822
|
-
- Honest note to show: this is the only deterministic gate that is OS-enforceable as a hook (it needs no run-specific arguments). The evidence / consensus / intent / learnings gates are invoked by the pipeline phases with per-run arguments, so they are enforced by the phase contract + the installed gate scripts, not by a hook.
|
|
823
825
|
### Step 9 - Default stack plugin enablement
|
|
824
826
|
|
|
825
827
|
Stack skills ship as versioned plugins in the `{owner}/multi-agent-plugins` marketplace. On first setup, wire the stack so the pipeline works out of the box.
|
|
@@ -81,6 +81,6 @@ In autopilot, `ask_choice` resolves to `default` (or the safe first option) with
|
|
|
81
81
|
|
|
82
82
|
## Deterministic gates note
|
|
83
83
|
|
|
84
|
-
Claude Code's `PreToolUse` exit-2 hooks are the HARD blocking gates.
|
|
84
|
+
Claude Code's `PreToolUse` exit-2 hooks are the HARD blocking gates. Three ship, none needing run-specific arguments so they are naturally hookable: (1) `pre-commit-check.sh` scans the staged diff on every `git commit` and blocks on a detected secret; (2) `agent-guard.sh` runs on `git commit` + `git push` and blocks AI/assistant attribution in a commit message and force-push to a protected branch (main/master/develop); (3) `check-read-size.sh` runs on `Read` and on the shell commands that read a file whole, and routes an oversized read to a cheap worker (`bulk-read.sh`) instead of the caller's own rung. The first two inspect what a run WRITES; the third inspects what it pays to READ, and it is inert until `bulkRead.mode` is set to `observe` or `enforce`, so merging the block changes nothing until the user opts in. Its `observe` mode blocks nothing and only logs, which is how the baseline is measured before anything is routed. All three are self-contained, fail-open on internal error, and never execute the inspected command. The recommended hook block ships at `install/templates/claude-hooks.json`; `multi-agent:setup` offers to merge it into `~/.claude/settings.json`. The other deterministic gates (evidence, consensus, intent, learnings) are invoked by the pipeline phases with per-run arguments (a build-log path, the triage JSON, the free-text input), so they are phase-enforced by contract, not OS-hookable.
|
|
85
85
|
|
|
86
86
|
Copilot CLI has no `PreToolUse` equivalent, so the secret scan there is workflow-enforced (run as a phase step, not OS-blocked) plus a CI smoke-gate step.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://example.com/pipeline/bulk-read-output.schema.json",
|
|
4
|
+
"title": "Bulk Read Output",
|
|
5
|
+
"description": "Schema for the bulk-reader worker's answer. Source: pipeline/agents/bulk-reader.md, invoked by pipeline/scripts/bulk-read.sh when check-read-size.sh blocks a whole-file read. Every positional field is a LINE NUMBER in the file as given to the worker, because a summary the caller cannot open is a summary the caller will pay to replace.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["summary"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"answer": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Direct answer to the question the caller asked, or a plain statement that this file cannot answer it. Never a guess - an unanswerable question is reported, not filled in."
|
|
13
|
+
},
|
|
14
|
+
"summary": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "What the file is and does, 3-6 sentences. Descriptive only: no review, no judgement, no proposed change."
|
|
17
|
+
},
|
|
18
|
+
"symbols": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"description": "Top-level declarations the file contains, in file order.",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"required": ["name", "line"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"name": { "type": "string" },
|
|
27
|
+
"kind": { "type": "string", "enum": ["type", "func", "var", "extension", "other"] },
|
|
28
|
+
"line": { "type": "integer", "minimum": 1 }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"regions": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"maxItems": 8,
|
|
35
|
+
"description": "Spans worth opening next, most important first. The intended use is a bounded Read(offset:limit:) over one of these, which is both cheaper than the whole file and exact - and which the read gate allows through.",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"additionalProperties": false,
|
|
39
|
+
"required": ["start", "end"],
|
|
40
|
+
"properties": {
|
|
41
|
+
"why": { "type": "string", "description": "What a reader finds in this span." },
|
|
42
|
+
"start": { "type": "integer", "minimum": 1 },
|
|
43
|
+
"end": { "type": "integer", "minimum": 1 }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"truncated": {
|
|
48
|
+
"type": "boolean",
|
|
49
|
+
"description": "True when the worker did not see the whole file. A partial read summarized as a whole one is the failure this flag exists to make visible."
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -365,7 +365,7 @@
|
|
|
365
365
|
},
|
|
366
366
|
"multiRepoIntegrationHosts": {
|
|
367
367
|
"type": "array",
|
|
368
|
-
"description": "v5.6.0+. Learn-once registry of host projects that build together with a multi-repo combo (codegen producer
|
|
368
|
+
"description": "v5.6.0+. Learn-once registry of host projects that build together with a multi-repo combo (codegen producer \u2192 consumer + host integration project). Pipeline checks this registry in Phase 6 before commit; on match, auto-runs the host build. On miss for a \u22652-repo task, prompts the user once and persists the answer. See refs/multi-repo-integration-build.md for the full contract.",
|
|
369
369
|
"items": {
|
|
370
370
|
"type": "object",
|
|
371
371
|
"additionalProperties": false,
|
|
@@ -653,12 +653,12 @@
|
|
|
653
653
|
"reportChannels": {
|
|
654
654
|
"type": "object",
|
|
655
655
|
"additionalProperties": false,
|
|
656
|
-
"description": "v5.7+ - Phase 7 / /multi-agent:channels kanal
|
|
656
|
+
"description": "v5.7+ - Phase 7 / /multi-agent:channels kanal se\u00e7imi default'lar\u0131. Multi-select men\u00fcde tick'li gelecek kanallar. Her kanal ba\u011f\u0131ms\u0131z boolean. Autopilot Phase 7'de ALWAYS pauses (30-min timeout) - bu de\u011ferler sadece men\u00fcn\u00fcn \u00f6nceden se\u00e7ili halini belirler.",
|
|
657
657
|
"properties": {
|
|
658
658
|
"pr": {
|
|
659
659
|
"type": "boolean",
|
|
660
660
|
"default": true,
|
|
661
|
-
"description": "PR description update (replace/append). Default ON - en
|
|
661
|
+
"description": "PR description update (replace/append). Default ON - en yayg\u0131n kanal."
|
|
662
662
|
},
|
|
663
663
|
"jira": {
|
|
664
664
|
"type": "boolean",
|
|
@@ -668,12 +668,12 @@
|
|
|
668
668
|
"confluence": {
|
|
669
669
|
"type": "boolean",
|
|
670
670
|
"default": false,
|
|
671
|
-
"description": "Confluence page creation. Default OFF - bir kez parent page
|
|
671
|
+
"description": "Confluence page creation. Default OFF - bir kez parent page se\u00e7ince LRU'dan \u00f6ner."
|
|
672
672
|
},
|
|
673
673
|
"wiki": {
|
|
674
674
|
"type": "boolean",
|
|
675
675
|
"default": false,
|
|
676
|
-
"description": "Component wiki pages (Case A scope multi-select). Default OFF - taskType=component + figmaConfig.wiki.enabled gerekli, yoksa
|
|
676
|
+
"description": "Component wiki pages (Case A scope multi-select). Default OFF - taskType=component + figmaConfig.wiki.enabled gerekli, yoksa men\u00fcde greyed out."
|
|
677
677
|
}
|
|
678
678
|
},
|
|
679
679
|
"default": {
|
|
@@ -686,32 +686,32 @@
|
|
|
686
686
|
"reportContent": {
|
|
687
687
|
"type": "object",
|
|
688
688
|
"additionalProperties": false,
|
|
689
|
-
"description": "v5.7+ - Phase 7 / /multi-agent:channels
|
|
689
|
+
"description": "v5.7+ - Phase 7 / /multi-agent:channels i\u00e7erik se\u00e7imi default'lar\u0131. Multi-select men\u00fcde tick'li gelecek content source'lar\u0131.",
|
|
690
690
|
"properties": {
|
|
691
691
|
"normalAnalysis": {
|
|
692
692
|
"type": "boolean",
|
|
693
693
|
"default": true,
|
|
694
|
-
"description": "Phase 1+2+4 pipeline log'undan impact summary + risks + architectural decisions (
|
|
694
|
+
"description": "Phase 1+2+4 pipeline log'undan impact summary + risks + architectural decisions (y\u00fcksek seviye). Greyed out post-hoc \u00e7a\u011fr\u0131da pipeline log yoksa."
|
|
695
695
|
},
|
|
696
696
|
"technicalAnalysis": {
|
|
697
697
|
"type": "boolean",
|
|
698
698
|
"default": false,
|
|
699
|
-
"description": "Changes (
|
|
699
|
+
"description": "Changes (de\u011fi\u015fen dosyalar gruplan\u0131p ne/neden), Architecture (structural decisions), Dependencies (yeni import/framework/paket). PR body'deki 'Technical Details' b\u00f6l\u00fcm\u00fcn\u00fcn \u00f6zeti; user'\u0131n kanal se\u00e7imi PR i\u00e7ermedi\u011fi durumlarda (\u00f6r. sadece Jira/Confluence) teknik i\u00e7erik aktarmak istiyorsa devreye girer. Source: Phase 2 planning + Phase 3 dev log + PR diff stat."
|
|
700
700
|
},
|
|
701
701
|
"testScenarios": {
|
|
702
702
|
"type": "boolean",
|
|
703
703
|
"default": true,
|
|
704
|
-
"description": "Precondition / steps / expected tablosu (4-8
|
|
704
|
+
"description": "Precondition / steps / expected tablosu (4-8 sat\u0131r, user perspective). Pipeline log source."
|
|
705
705
|
},
|
|
706
706
|
"autoDiff": {
|
|
707
707
|
"type": "boolean",
|
|
708
708
|
"default": false,
|
|
709
|
-
"description": "PR diff'ten auto-generate
|
|
709
|
+
"description": "PR diff'ten auto-generate \u00f6zet (eski enrich behavior - root cause / solution / changed files / test scenarios). PR linked de\u011filse greyed out."
|
|
710
710
|
},
|
|
711
711
|
"manualNote": {
|
|
712
712
|
"type": "boolean",
|
|
713
713
|
"default": false,
|
|
714
|
-
"description": "Serbest metin paragraf (--message / --message-file). Her durumda
|
|
714
|
+
"description": "Serbest metin paragraf (--message / --message-file). Her durumda se\u00e7ilebilir."
|
|
715
715
|
},
|
|
716
716
|
"costSummary": {
|
|
717
717
|
"type": "boolean",
|
|
@@ -721,7 +721,7 @@
|
|
|
721
721
|
"workSummary": {
|
|
722
722
|
"type": "boolean",
|
|
723
723
|
"default": false,
|
|
724
|
-
"description": "v7.1.0+ - Executive 'Work Done' summary block. Distills the whole pipeline run into a single-screen section: task + branch + base + PR number, scope delivered (
|
|
724
|
+
"description": "v7.1.0+ - Executive 'Work Done' summary block. Distills the whole pipeline run into a single-screen section: task + branch + base + PR number, scope delivered (\u2705/\u23f3 per Phase 2 task), changed files with +/- counts (capped at 20 rows), review outcome (accepted/deferred/rejected counts + approved flag), and a one-line phase tick strip (0 Init \u2705 \u00b7 1 Analysis \u2705 \u00b7 ...). Source: `agent-state.json` + `phase-tracker.json` + `git diff --numstat` between `baseBranch`...HEAD. Consumed by `render-work-summary.sh`. Greyed out if no state file exists for the task. Opt-in - off by default so baseline PR body stays unchanged."
|
|
725
725
|
}
|
|
726
726
|
},
|
|
727
727
|
"default": {
|
|
@@ -739,7 +739,7 @@
|
|
|
739
739
|
"default": 1800,
|
|
740
740
|
"minimum": 60,
|
|
741
741
|
"maximum": 7200,
|
|
742
|
-
"description": "v5.7+ - Phase 7'de autopilot always-pause
|
|
742
|
+
"description": "v5.7+ - Phase 7'de autopilot always-pause men\u00fcs\u00fcnde kullan\u0131c\u0131 cevap vermezse session'\u0131 sonland\u0131rma s\u00fcresi (saniye). Default 1800 (30 dk). Timeout'ta external delivery aborted, internal capture (agent-log, telemetry, knowledge) yine \u00e7al\u0131\u015f\u0131r, session /multi-agent:resume ile devam ettirilebilir."
|
|
743
743
|
},
|
|
744
744
|
"wikiScope": {
|
|
745
745
|
"type": "array",
|
|
@@ -748,7 +748,7 @@
|
|
|
748
748
|
"enum": ["main", "ios", "screenshots", "index"]
|
|
749
749
|
},
|
|
750
750
|
"default": ["main", "ios", "screenshots", "index"],
|
|
751
|
-
"description": "v5.7+ - Wiki Case A scope multi-select default'u. Component wiki dispatch'inde hangi artifact'lar
|
|
751
|
+
"description": "v5.7+ - Wiki Case A scope multi-select default'u. Component wiki dispatch'inde hangi artifact'lar yaz\u0131lacak: main (ana component sayfas\u0131), ios (iOS sub-page), screenshots (assets/ klas\u00f6r\u00fc), index (_Sidebar.md + ComponentImplementationStatus.md). Legacy wikiDefault=true \u2192 [main,ios,screenshots,index] migration; wikiDefault=false \u2192 [] (empty array = Wiki adapter Case B men\u00fcs\u00fcne d\u00fc\u015fer)."
|
|
752
752
|
},
|
|
753
753
|
"autoJiraFromGithubIssue": {
|
|
754
754
|
"type": "string",
|
|
@@ -831,7 +831,7 @@
|
|
|
831
831
|
"reviewDisagreementRound": {
|
|
832
832
|
"type": "boolean",
|
|
833
833
|
"default": false,
|
|
834
|
-
"description": "v6.1.0+ - Phase 4 Step 2.5 rebuttal round. When reviewers disagree (mixed blocker/approved verdict), each reviewer is re-prompted with the others' opposing arguments for one additional round before triage. Lifts signal quality on ambiguous findings at ~1
|
|
834
|
+
"description": "v6.1.0+ - Phase 4 Step 2.5 rebuttal round. When reviewers disagree (mixed blocker/approved verdict), each reviewer is re-prompted with the others' opposing arguments for one additional round before triage. Lifts signal quality on ambiguous findings at ~1\u00d7 Step 2 token cost. Off by default - flip for security-critical or release-branch reviews."
|
|
835
835
|
},
|
|
836
836
|
"analysisProfiles": {
|
|
837
837
|
"type": "array",
|
|
@@ -1047,7 +1047,7 @@
|
|
|
1047
1047
|
"minimum": 0,
|
|
1048
1048
|
"maximum": 10,
|
|
1049
1049
|
"default": 6,
|
|
1050
|
-
"description": "Clarity threshold. Score
|
|
1050
|
+
"description": "Clarity threshold. Score \u2265 threshold \u2192 proceed silently. Below \u2192 questions fire. 6 is the borderline 'the what is clear but the how is fuzzy' line."
|
|
1051
1051
|
},
|
|
1052
1052
|
"maxQuestions": {
|
|
1053
1053
|
"type": "integer",
|
|
@@ -1215,7 +1215,7 @@
|
|
|
1215
1215
|
"minimum": 30,
|
|
1216
1216
|
"maximum": 86400,
|
|
1217
1217
|
"default": 300,
|
|
1218
|
-
"description": "Polling interval for --watch loop. Clamped to
|
|
1218
|
+
"description": "Polling interval for --watch loop. Clamped to \u226530s to stay polite with GitHub rate limits."
|
|
1219
1219
|
},
|
|
1220
1220
|
"labelFilter": {
|
|
1221
1221
|
"type": "string",
|
|
@@ -1291,7 +1291,7 @@
|
|
|
1291
1291
|
"devCritic": {
|
|
1292
1292
|
"type": "object",
|
|
1293
1293
|
"additionalProperties": false,
|
|
1294
|
-
"description": "v8.6+ - Phase 3.5 evaluator-optimizer. After the Dev generator's last edit and BEFORE Phase 4 reviewers, dispatch agents/dev-critic.md (Sonnet by default) to run deterministic gates (build/lint/test/secrets) + the platform checklist (rules/*.md). Max 2 critic iterations, then escalate. Catches gate failures and checklist violations that would otherwise burn 2-3 Phase 4 reviewer calls + Opus triage. Off by default - introduces 1
|
|
1294
|
+
"description": "v8.6+ - Phase 3.5 evaluator-optimizer. After the Dev generator's last edit and BEFORE Phase 4 reviewers, dispatch agents/dev-critic.md (Sonnet by default) to run deterministic gates (build/lint/test/secrets) + the platform checklist (rules/*.md). Max 2 critic iterations, then escalate. Catches gate failures and checklist violations that would otherwise burn 2-3 Phase 4 reviewer calls + Opus triage. Off by default - introduces 1\u00d7 Sonnet call per Dev iteration; flip on for feature work, security-touching paths, or multi-file refactors. Source: Anthropic 'Building Effective Agents' (Dec 2024) evaluator-optimizer pattern.",
|
|
1295
1295
|
"properties": {
|
|
1296
1296
|
"enabled": {
|
|
1297
1297
|
"type": "boolean",
|
|
@@ -1464,6 +1464,61 @@
|
|
|
1464
1464
|
"tailLines": 20
|
|
1465
1465
|
}
|
|
1466
1466
|
},
|
|
1467
|
+
"bulkRead": {
|
|
1468
|
+
"type": "object",
|
|
1469
|
+
"additionalProperties": false,
|
|
1470
|
+
"description": "v16.21+ - Route a whole-file read that is too big to be worth the caller's rung to a cheap worker instead, and put its line-numbered summary in context rather than the file. The gate is pipeline/scripts/check-read-size.sh (PreToolUse, installed from install/templates/claude-hooks.json); the worker is pipeline/scripts/bulk-read.sh over the bulk-reader persona. Companion to contextOffload, which does the same for tool OUTPUT rather than for reads.",
|
|
1471
|
+
"properties": {
|
|
1472
|
+
"mode": {
|
|
1473
|
+
"type": "string",
|
|
1474
|
+
"enum": ["off", "observe", "enforce"],
|
|
1475
|
+
"default": "off",
|
|
1476
|
+
"description": "off = the hook is a no-op. observe = decide and log every inspected read, block nothing; this is the BASELINE MEASUREMENT, and running it before enforce is what makes a later saving claim checkable. enforce = block a whole-file read over minLines and tell the model to delegate it."
|
|
1477
|
+
},
|
|
1478
|
+
"minLines": {
|
|
1479
|
+
"type": "integer",
|
|
1480
|
+
"minimum": 1,
|
|
1481
|
+
"maximum": 100000,
|
|
1482
|
+
"default": 350,
|
|
1483
|
+
"description": "Files shorter than this are never inspected. A delegated read costs a round trip measured in tens of seconds, so a small file is cheaper read directly - the threshold is where that trade turns over."
|
|
1484
|
+
},
|
|
1485
|
+
"exemptPhases": {
|
|
1486
|
+
"type": "array",
|
|
1487
|
+
"items": {
|
|
1488
|
+
"type": "string"
|
|
1489
|
+
},
|
|
1490
|
+
"default": ["3"],
|
|
1491
|
+
"description": "Phases the gate never blocks in. Phase 3 is the shipped default and removing it breaks development: Claude Code's Edit requires the same file to have been Read first, so a gate that blocks reads while code is being changed blocks the change. The gate is for phases that read to UNDERSTAND."
|
|
1492
|
+
},
|
|
1493
|
+
"model": {
|
|
1494
|
+
"type": "string",
|
|
1495
|
+
"default": "haiku",
|
|
1496
|
+
"description": "Rung the delegated read runs on. The saving IS the gap between this rung and the caller's, so a worker raised to the caller's own rung saves nothing."
|
|
1497
|
+
},
|
|
1498
|
+
"timeoutSeconds": {
|
|
1499
|
+
"type": "integer",
|
|
1500
|
+
"minimum": 5,
|
|
1501
|
+
"maximum": 600,
|
|
1502
|
+
"default": 60,
|
|
1503
|
+
"description": "Wall-clock budget for one delegated read. On expiry the worker degrades and the caller is told to do a bounded read instead - never left waiting, and never handed an invented summary."
|
|
1504
|
+
},
|
|
1505
|
+
"maxBytes": {
|
|
1506
|
+
"type": "integer",
|
|
1507
|
+
"minimum": 1024,
|
|
1508
|
+
"maximum": 20971520,
|
|
1509
|
+
"default": 1048576,
|
|
1510
|
+
"description": "Ceiling past which a delegated read degrades instead of running. Delegation is not free: at some size the worker's own input bill approaches the read it replaced, and a file large enough to strain its window comes back truncated - a partial summary presented as a whole one is the failure mode this feature must never produce. Over the ceiling the caller is told to narrow first (grep, then a bounded read) rather than handed an expensive round trip to a worse answer."
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
"default": {
|
|
1514
|
+
"mode": "off",
|
|
1515
|
+
"minLines": 350,
|
|
1516
|
+
"exemptPhases": ["3"],
|
|
1517
|
+
"model": "haiku",
|
|
1518
|
+
"timeoutSeconds": 60,
|
|
1519
|
+
"maxBytes": 1048576
|
|
1520
|
+
}
|
|
1521
|
+
},
|
|
1467
1522
|
"testGap": {
|
|
1468
1523
|
"type": "object",
|
|
1469
1524
|
"additionalProperties": false,
|
|
@@ -1506,7 +1561,7 @@
|
|
|
1506
1561
|
"autopilotSafetyGate": {
|
|
1507
1562
|
"type": "boolean",
|
|
1508
1563
|
"default": true,
|
|
1509
|
-
"description": "v7.0.0+ - Phase 2 autopilot safety classifier. Before autopilot mode consumes the user's approval skip, run `classify-plan-safety.mjs` over the approved plan. If the heuristic score
|
|
1564
|
+
"description": "v7.0.0+ - Phase 2 autopilot safety classifier. Before autopilot mode consumes the user's approval skip, run `classify-plan-safety.mjs` over the approved plan. If the heuristic score \u2265 50 (e.g. >15 files touched, or security-path touch, or delete-without-test, or schema migration) inject a ONE-TIME pause asking for explicit manual approval - even in autopilot. Default ON because the risk of skipping this gate is asymmetric: a pause on a high-blast-radius plan costs seconds; a silent auto-merge of a bad one costs hours of rollback. Flip to `false` only for tightly-scoped autopilot workflows (e.g. batch figma component iteration) where the task class is known-safe."
|
|
1510
1565
|
},
|
|
1511
1566
|
"dynamicSkillLoading": {
|
|
1512
1567
|
"type": "boolean",
|
|
@@ -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")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-read-size.sh - PreToolUse read gate for the multi-agent pipeline.
|
|
3
|
+
#
|
|
4
|
+
# The third deterministic hook, and the first on the READ side. The other two
|
|
5
|
+
# (pre-commit-check, agent-guard) inspect what the run writes; this one inspects
|
|
6
|
+
# what it pays to read.
|
|
7
|
+
#
|
|
8
|
+
# The bill it exists for: a phase that reads six 900-line files pays for 5,400
|
|
9
|
+
# lines at that phase's own rung, and the part it needed was a handful of
|
|
10
|
+
# symbols. `offload-ref.sh` already took the other half of that bill - the build
|
|
11
|
+
# log, the diff, the test output - by parking the payload and printing a pointer.
|
|
12
|
+
# Nothing looked at file reads, because nothing could: only a hook sees a tool
|
|
13
|
+
# call before it runs.
|
|
14
|
+
#
|
|
15
|
+
# Contract (Claude Code PreToolUse hook):
|
|
16
|
+
# - Reads the tool-call JSON on stdin.
|
|
17
|
+
# - Exit 2 -> BLOCK (reason on stderr, shown to the model).
|
|
18
|
+
# - Exit 0 -> allow.
|
|
19
|
+
#
|
|
20
|
+
# Modes - `prefs.global.bulkRead.mode`:
|
|
21
|
+
# off (default) nothing is inspected; the hook is a no-op.
|
|
22
|
+
# observe decide and LOG, never block. This is the measurement: run the
|
|
23
|
+
# pipeline normally and metrics.jsonl carries the read-size
|
|
24
|
+
# distribution, so the saving can be estimated before anything is
|
|
25
|
+
# built on top of it.
|
|
26
|
+
# enforce block a whole-file read over `minLines` and tell the model to
|
|
27
|
+
# delegate it to bulk-read.sh instead.
|
|
28
|
+
#
|
|
29
|
+
# Why observe is a real mode and not a debug flag: the honest order is measure,
|
|
30
|
+
# then route. A gate switched straight to enforce has no baseline to be
|
|
31
|
+
# compared against, and "we cut tokens" becomes a claim nothing can check.
|
|
32
|
+
#
|
|
33
|
+
# Why the development phase is exempt by default: in Claude Code, `Edit` requires
|
|
34
|
+
# the same file to have been read first. A gate that blocks reads in Phase 3
|
|
35
|
+
# blocks editing. The gate is for the phases that read to UNDERSTAND.
|
|
36
|
+
#
|
|
37
|
+
# Safety design (fail-OPEN): any internal error, missing python3, missing helper,
|
|
38
|
+
# empty payload -> exit 0. It never executes the inspected command, never writes
|
|
39
|
+
# to the inspected file, makes no network call, and prints no file content.
|
|
40
|
+
|
|
41
|
+
set -u
|
|
42
|
+
|
|
43
|
+
HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
44
|
+
HELPER="$HERE/check-read-size.py"
|
|
45
|
+
[ -f "$HELPER" ] || exit 0
|
|
46
|
+
command -v python3 >/dev/null 2>&1 || exit 0
|
|
47
|
+
|
|
48
|
+
PAYLOAD="$(cat 2>/dev/null || true)"
|
|
49
|
+
[ -z "$PAYLOAD" ] && exit 0
|
|
50
|
+
|
|
51
|
+
DECISION="$(printf '%s' "$PAYLOAD" | python3 "$HELPER" 2>/dev/null || true)"
|
|
52
|
+
[ -z "$DECISION" ] && exit 0
|
|
53
|
+
|
|
54
|
+
VERDICT="$(printf '%s' "$DECISION" | cut -f1)"
|
|
55
|
+
FILE="$(printf '%s' "$DECISION" | cut -f2)"
|
|
56
|
+
LINES="$(printf '%s' "$DECISION" | cut -f3)"
|
|
57
|
+
WHY="$(printf '%s' "$DECISION" | cut -f4)"
|
|
58
|
+
|
|
59
|
+
# Telemetry is best-effort and never gates the decision. `log-metric.sh` already
|
|
60
|
+
# tolerates an unwritable directory, so the only guard needed here is the
|
|
61
|
+
# script's own presence.
|
|
62
|
+
note() {
|
|
63
|
+
[ -x "$HERE/log-metric.sh" ] || return 0
|
|
64
|
+
"$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "${MULTI_AGENT_PHASE:-0}" \
|
|
65
|
+
"read.$1" lines="${LINES:-0}" reason="${WHY:-}" >/dev/null 2>&1 || true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case "$VERDICT" in
|
|
69
|
+
OBSERVE)
|
|
70
|
+
note observed
|
|
71
|
+
exit 0 ;;
|
|
72
|
+
BLOCK)
|
|
73
|
+
note blocked
|
|
74
|
+
echo "BLOCKED by check-read-size: ${FILE} is ${LINES} lines." >&2
|
|
75
|
+
echo "Reading it whole costs this phase's rung for a file whose useful part is a few symbols." >&2
|
|
76
|
+
echo "" >&2
|
|
77
|
+
echo "Delegate it, then work from what comes back:" >&2
|
|
78
|
+
echo " bash \$HOME/.claude/scripts/bulk-read.sh --file '${FILE}' --question '<what you need from it>'" >&2
|
|
79
|
+
echo "" >&2
|
|
80
|
+
echo "The summary carries LINE NUMBERS, so a follow-up Read with an offset/limit around the" >&2
|
|
81
|
+
echo "region you actually need is the intended next step and passes this gate." >&2
|
|
82
|
+
echo "Full text stays on disk under .multi-agent/refs/ if the summary is not enough." >&2
|
|
83
|
+
exit 2 ;;
|
|
84
|
+
*)
|
|
85
|
+
exit 0 ;;
|
|
86
|
+
esac
|
|
@@ -194,6 +194,7 @@ scan_file() {
|
|
|
194
194
|
if (s ~ /^[0-9]+$/) next; # pure digits
|
|
195
195
|
if (s ~ /^[0-9a-f]+$/) next; # lowercase hex (git sha / md5 / sha-*)
|
|
196
196
|
if (s ~ /^[0-9A-F]+$/) next; # uppercase hex
|
|
197
|
+
if (s ~ /^([A-Z][a-z]+)+[A-Z]*$/) next; # CamelCase words only (label key / type name), never a credential
|
|
197
198
|
delete freq;
|
|
198
199
|
for (i = 1; i <= n; i++) { c = substr(s, i, 1); freq[c]++ }
|
|
199
200
|
H = 0;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: backlog
|
|
3
3
|
version: 1.0.0
|
|
4
|
-
description: "The engineering backlog registry for deferred-but-approved work in this codebase. Every job the user defers ('defer it', 'add it to the list', 'later', 'not now') lands HERE with its full spec, origin, and unblock condition, so deferral never means loss. Load when the user defers work, asks 'what's on the backlog / the list', says 'work the backlog' / 'pick up <item>', or when completing an item. The bundled BACKLOG.md is the registry; entries move deferred -> ready -> in-progress -> done and
|
|
4
|
+
description: "The engineering backlog registry for deferred-but-approved work in this codebase. Every job the user defers ('defer it', 'add it to the list', 'later', 'not now') lands HERE with its full spec, origin, and unblock condition, so deferral never means loss. Load when the user defers work, asks 'what's on the backlog / the list', says 'work the backlog' / 'pick up <item>', or when completing an item. The bundled BACKLOG.md is the registry; entries move deferred -> ready -> in-progress -> done, and the registry is re-audited against live code - a rotted entry deletes, a done record retires once what it taught is re-homed in the skill that owns it. This records WHAT IS OWED; git history records what happened."
|
|
5
5
|
user-invocable: true
|
|
6
|
-
argument-hint: [list | add | pick <id> | done <id>]
|
|
6
|
+
argument-hint: [list | add | pick <id> | done <id> | re-audit]
|
|
7
7
|
allowed-tools: Read, Grep, Glob, Edit, Write
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -13,14 +13,17 @@ allowed-tools: Read, Grep, Glob, Edit, Write
|
|
|
13
13
|
> **Bundled:** [BACKLOG.md](BACKLOG.md) - the registry. The heart of this skill.
|
|
14
14
|
> **Iron rule:** moving a job onto this list is a MOVE, never a rewrite - the FULL spec
|
|
15
15
|
> travels verbatim. A compressed one-liner is a loss; the point of the backlog is that
|
|
16
|
-
> deferring costs nothing later.
|
|
16
|
+
> deferring costs nothing later. The registry stays honest in the other direction too:
|
|
17
|
+
> entries are re-audited against live code, and an entry whose named types or premises
|
|
18
|
+
> no longer exist is DELETED, never left readable as live.
|
|
17
19
|
|
|
18
20
|
## When to load
|
|
19
21
|
|
|
20
22
|
- The user defers work: "defer it", "add it to the list", "later", "not now", "backlog this".
|
|
21
23
|
- The user asks about it: "what's on the backlog", "what's on the list", "what's owed".
|
|
22
24
|
- The user works it: "work the backlog", "pick up <id>", "do the next one".
|
|
23
|
-
- An item completes: mark it done
|
|
25
|
+
- An item completes: mark it done, and retire the record once what it taught has a home.
|
|
26
|
+
- A staleness signal arrives (a rename, a deleted subject, a doubted claim): re-audit.
|
|
24
27
|
|
|
25
28
|
## The registry contract
|
|
26
29
|
|
|
@@ -28,7 +31,7 @@ Every entry in `BACKLOG.md` carries, verbatim:
|
|
|
28
31
|
|
|
29
32
|
- **id** - short stable slug (`kebab-case`), assigned on add.
|
|
30
33
|
- **title** - one line.
|
|
31
|
-
- **state** - `deferred` -> `ready` -> `in-progress` -> `done`. Entries
|
|
34
|
+
- **state** - `deferred` -> `ready` -> `in-progress` -> `done`. Entries move forward; they leave the registry only by the two rules below (rot, or a re-homed done record), never by being quietly rewritten.
|
|
32
35
|
- **origin** - where it came from (the task/PR/finding/conversation that spawned it).
|
|
33
36
|
- **spec** - the FULL description, verbatim from when it was deferred. Not a summary.
|
|
34
37
|
- **unblock** - the condition that makes it actionable (a dependency, a decision, a date). `deferred` items without an unblock condition are just `ready`.
|
|
@@ -42,7 +45,8 @@ Prefer an append-only markdown table or section list in `BACKLOG.md`; never over
|
|
|
42
45
|
| `list` | Show entries grouped by state (ready first, then deferred, then in-progress; done collapsed). |
|
|
43
46
|
| `add` | Capture the deferred job with its full spec + origin + unblock condition. Assign an id. |
|
|
44
47
|
| `pick <id>` | Move `ready`/`deferred` -> `in-progress`; surface the full spec so work resumes with zero context loss. |
|
|
45
|
-
| `done <id>` | Move -> `done
|
|
48
|
+
| `done <id>` | Move -> `done` with the evidence (PR/commit + date). The record RETIRES from the registry once what it taught is written into the skill or doc that owns it - git history is the archive of what was owed and delivered. A done entry kept past that point is a second place for the same truth to rot. |
|
|
49
|
+
| `re-audit` | Verify every entry's named types, paths and claims against LIVE code (binary-safe: `LC_ALL=C grep -a`, so a repo with binary blobs does not silently return nothing). A subject that no longer exists deletes its entry; a changed fact restates it. **Numbers move by measurement, never by edit** - a count adjusted to match an impression reads as evidence and is worse than no count. |
|
|
46
50
|
|
|
47
51
|
## Why keep it whole
|
|
48
52
|
|