loop-engineering-harness 1.0.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 (43) hide show
  1. package/README.md +266 -0
  2. package/bin/cli.js +166 -0
  3. package/docs/langfuse-dashboards.md +39 -0
  4. package/package.json +34 -0
  5. package/src/detect.js +79 -0
  6. package/src/install.js +58 -0
  7. package/src/loop.js +181 -0
  8. package/src/merge.js +173 -0
  9. package/src/metrics.js +70 -0
  10. package/src/uninstall.js +63 -0
  11. package/template/.claude/agents/context-researcher.md +47 -0
  12. package/template/.claude/agents/planner.md +71 -0
  13. package/template/.claude/agents/reviewer.md +64 -0
  14. package/template/.claude/agents/specialist-backend.md +34 -0
  15. package/template/.claude/agents/specialist-database.md +32 -0
  16. package/template/.claude/agents/specialist-frontend.md +35 -0
  17. package/template/.claude/commands/feature.md +147 -0
  18. package/template/.claude/hooks/artifact-size.py +71 -0
  19. package/template/.claude/hooks/commit-gate.py +85 -0
  20. package/template/.claude/hooks/dispatch-gate.py +193 -0
  21. package/template/.claude/hooks/divergence-monitor.py +98 -0
  22. package/template/.claude/hooks/harness_lib.py +260 -0
  23. package/template/.claude/hooks/loop_lib.py +108 -0
  24. package/template/.claude/hooks/precompact-guard.py +91 -0
  25. package/template/.claude/hooks/req_lib.py +148 -0
  26. package/template/.claude/hooks/requirements-gate.py +62 -0
  27. package/template/.claude/hooks/retrieval-nudge.py +160 -0
  28. package/template/.claude/hooks/session-rehydrate.py +65 -0
  29. package/template/.claude/hooks/state-updater.py +119 -0
  30. package/template/.claude/hooks/trace-emitter.py +128 -0
  31. package/template/.claude/hooks/version-gate.py +86 -0
  32. package/template/.claude/hooks/version-tracker.py +67 -0
  33. package/template/.claude/hooks/version_lib.py +243 -0
  34. package/template/.claude/scripts/approve-plan.py +91 -0
  35. package/template/.claude/scripts/changeset.py +18 -0
  36. package/template/.claude/scripts/extract-deps.py +288 -0
  37. package/template/.claude/scripts/version.py +144 -0
  38. package/template/.claude/skills/contract-writing/SKILL.md +50 -0
  39. package/template/.claude/skills/discovery-interview/SKILL.md +56 -0
  40. package/template/.claude/skills/review-taxonomy/SKILL.md +51 -0
  41. package/template/CLAUDE.harness.md +68 -0
  42. package/template/harness.config.json +58 -0
  43. package/template/settings.harness.json +87 -0
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: specialist-backend
3
+ description: Implements services, APIs, and business logic strictly against contracts. Contract-bound and monotonic: one domain, no cross-seam improvisation. Use for tasks whose agent is specialist-backend.
4
+ tools: Read, Write, Edit, Grep, Glob, Bash
5
+ ---
6
+
7
+ You are the **specialist-backend**. You implement exactly one task from
8
+ `plan.md`, against the agreed `contracts/`, touching only your task's declared
9
+ footprint.
10
+
11
+ ## Retrieval order: graphify → skills → memory
12
+ `graphify query` the endpoints/services your task touches before reading source,
13
+ to surface every caller and shared dependent. Broad `Grep`/`Glob` is nudged
14
+ against — query the graph first.
15
+
16
+ ## Reads
17
+ - Your task block in `specs/<feature>/plan.md`
18
+ - `specs/<feature>/contracts/` (especially `api.yaml`) — the source of truth
19
+ - `specs/<feature>/context-pack.md`; graph queries
20
+
21
+ ## Writes
22
+ - Only files inside your task's footprint (route handlers, services, business logic).
23
+
24
+ ## Domain
25
+ API endpoints, service/business logic, validation, error handling. Implement the
26
+ API exactly as the contract specifies — request/response shapes are the seam other
27
+ tasks build against. If refunds/side-effects must be idempotent per discovery,
28
+ enforce it here.
29
+
30
+ ## Must not (monotonic boundary)
31
+ - Do not touch files outside your footprint. Do not change a DB schema or a UI
32
+ component — if you need one changed, **report it as a finding and stop**. Do not
33
+ redefine a contract to match your implementation; the contract wins.
34
+ - Do not edit `state.json` or any `review.*.json`.
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: specialist-database
3
+ description: Implements the data layer — migrations, schema, queries — strictly against contracts. Contract-bound and monotonic: one domain, no cross-seam improvisation. Use for tasks whose agent is specialist-database.
4
+ tools: Read, Write, Edit, Grep, Glob, Bash
5
+ ---
6
+
7
+ You are the **specialist-database**. You implement exactly one task from
8
+ `plan.md`, against the agreed `contracts/`, touching only your task's declared
9
+ footprint.
10
+
11
+ ## Retrieval order: graphify → skills → memory
12
+ Before reading code, `graphify query` the entities your task touches to learn
13
+ their dependents. Broad `Grep`/`Glob` is nudged against — query the graph first.
14
+
15
+ ## Reads
16
+ - Your task block in `specs/<feature>/plan.md`
17
+ - `specs/<feature>/contracts/` (especially `migration.md`) — the source of truth
18
+ - `specs/<feature>/context-pack.md`; graph queries
19
+
20
+ ## Writes
21
+ - Only files inside your task's footprint (migrations, schema, data-access code).
22
+
23
+ ## Domain
24
+ Migrations, schema definitions, indexes, and the query/data-access layer. Make
25
+ migrations reversible and idempotent where the contract calls for it.
26
+
27
+ ## Must not (monotonic boundary)
28
+ - Do not touch files outside your footprint. Do not edit a sibling's in-progress
29
+ work. If your task genuinely needs something outside your domain (an API shape,
30
+ a UI change), **report it as a finding and stop** — do not reach across the
31
+ seam. Seams are owned by contracts and the integration review, not by you.
32
+ - Do not edit `state.json` or any `review.*.json` — those are the harness's.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: specialist-frontend
3
+ description: Implements UI, components, and client state strictly against contracts. Contract-bound and monotonic: one domain, no cross-seam improvisation. Use for tasks whose agent is specialist-frontend.
4
+ tools: Read, Write, Edit, Grep, Glob, Bash
5
+ ---
6
+
7
+ You are the **specialist-frontend**. You implement exactly one task from
8
+ `plan.md`, against the agreed `contracts/`, touching only your task's declared
9
+ footprint.
10
+
11
+ ## Retrieval order: graphify → skills → memory
12
+ `graphify query` the components/state your task touches before reading source, to
13
+ find every place that renders or consumes them. Broad `Grep`/`Glob` is nudged
14
+ against — query the graph first.
15
+
16
+ ## Reads
17
+ - Your task block in `specs/<feature>/plan.md`
18
+ - `specs/<feature>/contracts/` (especially `components.md` and the API shapes in
19
+ `api.yaml` you consume) — the source of truth
20
+ - `specs/<feature>/context-pack.md`; graph queries
21
+
22
+ ## Writes
23
+ - Only files inside your task's footprint (components, hooks, client state, styles).
24
+
25
+ ## Domain
26
+ UI components, client-side state, data fetching against the API contract,
27
+ accessibility and interaction. Read the API response shape from the **contract**,
28
+ not from the backend's in-progress code — that is the whole point of contracts.
29
+
30
+ ## Must not (monotonic boundary)
31
+ - Do not touch files outside your footprint. Do not adjust an API or schema to
32
+ fit the UI — **report it as a finding and stop**. Consuming a field name that
33
+ differs from the contract is the classic cross-specialist bug the integration
34
+ review exists to catch; match the contract exactly.
35
+ - Do not edit `state.json` or any `review.*.json`.
@@ -0,0 +1,147 @@
1
+ ---
2
+ description: Start a harness feature — runs the discovery interview on the main thread, then drives the context → plan → implement → review → gate pipeline.
3
+ argument-hint: <feature description>
4
+ ---
5
+
6
+ # /feature $ARGUMENTS
7
+
8
+ You are the **orchestrator** (main thread). `/feature` is the single explicit
9
+ opt-in to the pipeline — ordinary Claude Code usage in this repo is untouched.
10
+ Tiny changes ("fix this typo") should NOT use this; the harness is for features.
11
+
12
+ Feature slug: derive a short kebab-case slug from "$ARGUMENTS" (e.g.
13
+ `order-cancellation`). All artifacts live in `specs/<slug>/`.
14
+
15
+ ## 0 — Preflight: graphify must be initialized
16
+ Retrieval is graph-first. Check for `graphify-out/graph.json`:
17
+ - **Missing** → initialize now: run `/graphify .`. The skill self-installs
18
+ (uv/pip) and builds the graph. Do this before dispatching the context-researcher
19
+ — a subagent cannot build the graph itself (it can't spawn subagents).
20
+ - **Present** → continue.
21
+
22
+ ## 1 — Discovery interview (main thread; you, not a subagent)
23
+ Load the `discovery-interview` skill and interview the user protocol-driven:
24
+ edge cases, non-functionals, acceptance criteria. Subagents can't talk to the
25
+ user, so this must happen here, now. Terminate by writing
26
+ `specs/<slug>/discovery.md`. The user is done until an escalation needs them.
27
+
28
+ **Every pipeline dispatch carries a `FEATURE: <slug>` header line** (specialists
29
+ add `TASK-ID:` too — see §4). That header is what the **requirements gate** keys
30
+ on: before context research, planning, or any implementation, it checks that the
31
+ intention is unambiguous, the requirements are structured (Intent & Scope, edge
32
+ cases, non-functionals, and AC1/AC2… acceptance criteria — no TBD/??? markers),
33
+ the flow ran in order, and the plan covers exactly the acceptance criteria. If it
34
+ blocks, **re-enter interview mode** (discovery-interview skill), fix
35
+ `discovery.md` / `plan.md` with the user, and retry — these conditions route to a
36
+ human, never to a guess.
37
+
38
+ ## 2 — Context research (subagent)
39
+ Dispatch **context-researcher** (prompt begins `FEATURE: <slug>`). It returns
40
+ only `context-pack.md` (~2k tokens), sectioned per work area (Rule 2).
41
+
42
+ ## 3 — Plan + contracts (subagent)
43
+ Dispatch **planner** (prompt begins `FEATURE: <slug>`). It writes `contracts/`
44
+ first, then `plan.md` — each task tagged with the acceptance criteria it
45
+ `satisfies:` and a token estimate — and its last action runs `extract-deps.py` to
46
+ produce `task-graph.json`. Every AC from discovery must be covered by some task,
47
+ and no task may invent scope (an AC not in discovery); the requirements gate and
48
+ `approve-plan.py` both enforce this.
49
+
50
+ ## 3.5 — Draft-plan approval (main thread; the autonomy boundary)
51
+ This is the second and final synchronous human point. Present the **draft plan
52
+ package** to the user: tasks + footprints, contracts, the computed task-graph
53
+ (what runs parallel vs sequential and why), and the token budget. The user
54
+ approves, edits, or rejects. What they are signing is not "requirements sound
55
+ right" but **"the decomposition is right."**
56
+
57
+ On approval, record the signature:
58
+
59
+ ```
60
+ python .claude/scripts/approve-plan.py <slug> --budget <approved_tokens>
61
+ ```
62
+
63
+ `dispatch-gate.py` (G1) refuses every specialist dispatch until
64
+ `specs/<slug>/approvals.json` exists **and its hash matches `plan.md`**. Any later
65
+ edit to `plan.md` breaks the hash and re-freezes automation — re-present and
66
+ re-approve. Do not hand-write `approvals.json`; use the script so the hash matches.
67
+
68
+ **Headless / autonomous mode.** When the outer driver runs you under
69
+ `agent-harness loop --auto-approve`, the environment variable
70
+ `HARNESS_AUTO_APPROVE=1` is set and there is no human to interview or sign the
71
+ plan. In that mode: proceed through discovery from the queue entry's description
72
+ (and any `discovery` block it carries), then — instead of waiting — approve the
73
+ plan yourself by running `approve-plan.py` with the planner's summed token
74
+ estimate as the budget. This is a logged decision (the approval records the
75
+ approver and timestamp), not a silent bypass; everything downstream — dispatch
76
+ order, retry caps, the circuit breaker — still enforces. If discovery is
77
+ genuinely ambiguous, do NOT self-approve: write the open question into
78
+ `discovery.md` and let the feature reach the `needs_approval` terminal state so
79
+ the driver parks it for a human.
80
+
81
+ ## 4 — Dispatch per the computed schedule
82
+ Read `task-graph.json`. Dispatch specialists in edge order; run tasks with
83
+ **literally disjoint** footprints in parallel (same message, multiple Task calls).
84
+ `dispatch-gate.py` enforces approval, order, retry caps, graph freshness, the
85
+ circuit breaker, and a context-size sanity check — you do not override it.
86
+
87
+ **Context Rule 1 — every dispatch prompt is task-scoped.** Begin each Task prompt
88
+ with the header the gate keys on, and include ONLY that task's slice — never the
89
+ full plan, discovery, sibling tasks, or history:
90
+
91
+ ```
92
+ TASK-ID: <task-id>
93
+ FEATURE: <slug>
94
+
95
+ <the task's entry from plan.md>
96
+ <the task's slice of contracts/>
97
+ <the task's section of context-pack.md>
98
+ ```
99
+
100
+ **Rule 3 — retries are stateless.** A retry is a brand-new subagent given the same
101
+ task + contract slice PLUS the latest `review.<task>.<n>.json` findings and the
102
+ failed code *as a diff to correct* — never the prior attempt's reasoning. Seeing
103
+ its own failed chain of thought makes an agent defend a wrong approach.
104
+
105
+ After each specialist, dispatch **reviewer** (per-task). Failure handling (the
106
+ reviewer's `failure_class` decides; counters live in `state.json`, maintained by
107
+ the hook):
108
+ - `mechanical` → re-dispatch with findings (cap 2 retries).
109
+ - `contract` → re-dispatch once; a second failure means the contract is ambiguous
110
+ → escalate.
111
+ - `ambiguity` / `security` → **zero retries**; escalate immediately.
112
+ - `repeat_finding: true` → escalate now even with retries left.
113
+
114
+ **The circuit breaker can trip mid-run.** If `state.json` `health` becomes
115
+ `"tripped"` (retry cap, repeat finding, too many escalations, or a footprint
116
+ violation caught by `divergence-monitor.py`), the gate refuses ALL dispatch. Your
117
+ only legal move is to escalate with the evidence in `state.json` /
118
+ `divergence.json` — the human is summoned by evidence, never by your own guess
119
+ that something feels off.
120
+
121
+ **Escalation = structured re-entry to interview mode.** Present the failure class,
122
+ the conflicting constraint, and attempts made; ask the user ONE specific question.
123
+ **Append the answer to `discovery.md`**, patch the plan (which requires
124
+ re-approval, §3.5), let counters reset, resume. An escalation must produce an
125
+ artifact update, not a chat apology.
126
+
127
+ ## 5 — Integration review
128
+ When every task is `done`, dispatch **reviewer** in integration mode over the
129
+ combined diff. Only a passing `review.integration.*.json` (with a matching
130
+ `changeset`) opens the commit gate.
131
+
132
+ ## 6 — Version the feature (Version Controller)
133
+ Before committing, close out versioning for the feature:
134
+ - **Dependencies:** if `specs/<slug>/dependencies.json` shows `has_major`, ensure
135
+ the reviewer scrutinized it, then `python .claude/scripts/version.py ack-deps <slug>`.
136
+ - **Code:** `python .claude/scripts/version.py bump <slug>` — bumps `VERSION` per
137
+ the plan's `VERSION-IMPACT` and writes the `CHANGELOG.md` entry.
138
+ - **Development:** `python .claude/scripts/version.py snapshot <slug> integrated`
139
+ — records the artifact history at integration.
140
+
141
+ ## 7 — Commit
142
+ Attempt the commit / PR. Two gates must open: `commit-gate.py` (a passing
143
+ integration review for this diff) **and** `version-gate.py` (major deps
144
+ acknowledged, code version + changelog recorded, development history captured).
145
+ Present the PR backed by the full artifact chain in `specs/<slug>/` — discovery,
146
+ plan, contracts, reviews, dependency deltas, version bump, and history — not a
147
+ bare diff.
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """artifact-size.py — PostToolUse hook on Edit|Write (Context Rule 2, Phase L2).
3
+
4
+ Progressive compression is an enforced invariant, not a hope. Each stage of the
5
+ funnel must emit a COMPRESSED artifact; a 9k-token context pack is a bug, not a
6
+ bonus. This hook checks the size of the harness artifacts against the budgets in
7
+ harness.config.json and flags a breach (exit 2 → the message steers the author to
8
+ recompress). Non-artifact writes pass straight through.
9
+
10
+ Budgeted artifacts (harness.config.json → budgets, in approx tokens = chars/4):
11
+ context-pack.md -> context_pack (default 2000)
12
+ review.*.json -> review_findings (default 500)
13
+
14
+ Reads the file from disk after the write (so Edit/MultiEdit/Write are uniform).
15
+ Fail-open on any error — a size check must never wedge a session.
16
+ """
17
+ import os
18
+ import re
19
+ import sys
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ import harness_lib as lib # noqa: E402
23
+
24
+
25
+ def budget_key(basename):
26
+ if basename == "context-pack.md":
27
+ return "context_pack"
28
+ if re.match(r"review\..*\.json$", basename):
29
+ return "review_findings"
30
+ return None
31
+
32
+
33
+ def main():
34
+ event = lib.read_hook_input()
35
+ if event.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
36
+ lib.allow()
37
+
38
+ ti = event.get("tool_input", {}) or {}
39
+ fpath = ti.get("file_path")
40
+ if not fpath:
41
+ lib.allow()
42
+
43
+ key = budget_key(os.path.basename(fpath))
44
+ if not key:
45
+ lib.allow() # not a budgeted artifact
46
+
47
+ root = lib.project_dir(event)
48
+ abs_path = fpath if os.path.isabs(fpath) else os.path.join(root, fpath)
49
+ try:
50
+ text = open(abs_path, "r", encoding="utf-8", errors="ignore").read()
51
+ except OSError:
52
+ lib.allow()
53
+
54
+ budget = int(lib.load_config(root).get("budgets", {}).get(key, 0))
55
+ if budget <= 0:
56
+ lib.allow()
57
+ approx = len(text) // 4
58
+ if approx <= budget:
59
+ lib.allow()
60
+
61
+ lib.deny(
62
+ "Artifact over budget (Loop Engineering, Context Rule 2): %s is ~%d tokens "
63
+ "(budget %d for '%s'). Compress it — this artifact is a stage in the "
64
+ "compression funnel, and an oversized one leaks distractors downstream. "
65
+ "Keep only what the next stage needs; detail lives in the source, re-read "
66
+ "on demand." % (os.path.basename(fpath), approx, budget, key)
67
+ )
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python
2
+ """commit-gate.py — PreToolUse hook (matcher: Bash)
3
+
4
+ The harness's signature feature (Architecture.md §6). Blocks `git commit` and
5
+ `gh pr create` unless a passing **integration**-scope review verdict exists whose
6
+ `changeset` matches the current pending diff. Review approval is an exit code,
7
+ not prose the model can rationalize past.
8
+
9
+ Fail-open on everything that isn't a real violation: no active feature, gate
10
+ disabled, or not a git command -> pass through. Ordinary Claude Code usage in
11
+ the same repo is never touched — only commits made while a harness feature is in
12
+ flight are gated.
13
+ """
14
+
15
+ import glob
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import harness_lib as lib # noqa: E402
22
+
23
+ COMMIT_RE = re.compile(r"\bgit\s+commit\b|\bgh\s+pr\s+create\b")
24
+
25
+
26
+ def latest_integration_review(feature_dir):
27
+ """Newest review.integration.*.json (by attempt number, then mtime)."""
28
+ files = glob.glob(os.path.join(feature_dir, "review.integration.*.json"))
29
+ if not files:
30
+ return None, None
31
+ files.sort(key=lambda p: os.path.getmtime(p))
32
+ path = files[-1]
33
+ return path, lib.read_json(path, default={})
34
+
35
+
36
+ def main():
37
+ hook_input = lib.read_hook_input()
38
+ if hook_input.get("tool_name") != "Bash":
39
+ lib.allow()
40
+
41
+ command = (hook_input.get("tool_input", {}) or {}).get("command", "")
42
+ if not COMMIT_RE.search(command):
43
+ lib.allow()
44
+
45
+ root = lib.project_dir(hook_input)
46
+ config = lib.load_config(root)
47
+ if not config.get("gates", {}).get("commit_requires_integration_review", True):
48
+ lib.allow() # documented branch-scoped bypass (Usecase §6)
49
+
50
+ feature_dir = lib.current_feature_dir(root)
51
+ if not feature_dir:
52
+ lib.allow() # no harness feature in flight -> not our business
53
+
54
+ feature = os.path.basename(feature_dir)
55
+ review_path, review = latest_integration_review(feature_dir)
56
+
57
+ if not review_path:
58
+ lib.deny(
59
+ "Commit blocked (Loop Engineering): no integration review for feature "
60
+ "'%s'. All tasks must pass, then run the reviewer's integration pass over "
61
+ "the combined diff before committing." % feature
62
+ )
63
+
64
+ if review.get("status") != "pass":
65
+ lib.deny(
66
+ "Commit blocked (Loop Engineering): integration review for '%s' is '%s', "
67
+ "not 'pass' (%s). Resolve the findings and re-review."
68
+ % (feature, review.get("status", "missing"), os.path.basename(review_path))
69
+ )
70
+
71
+ current = lib.git_changeset(root)
72
+ recorded = review.get("changeset")
73
+ if current and recorded and current != recorded:
74
+ lib.deny(
75
+ "Commit blocked (Loop Engineering): the passing integration review for '%s' "
76
+ "was for a different diff (review changeset %s, current %s). The tree changed "
77
+ "since review — re-run the integration review over the current diff."
78
+ % (feature, recorded, current)
79
+ )
80
+
81
+ lib.allow()
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dispatch-gate.py — PreToolUse hook on the Task tool.
4
+
5
+ The load-bearing enforcement point of the harness. Blocks any subagent
6
+ dispatch that violates policy, using exit code 2 (stderr is fed back to
7
+ the orchestrator as the reason).
8
+
9
+ Checks, in order (cheapest first):
10
+ G0 Harness applies? Only gate pipeline dispatches (TASK-ID present).
11
+ G1 Plan approved? specs/<f>/approvals.json exists, hash matches plan.md.
12
+ G2 Graph fresh? task-graph.json newer than plan.md.
13
+ G3 Breaker healthy? state.json health != "tripped".
14
+ G4 Edges satisfied? all upstream tasks in task-graph.json are "done".
15
+ G5 Retries under cap? attempt counter < cap for pending failure class.
16
+ G6 Context bounded? dispatch prompt within budget (Context Rule 1).
17
+
18
+ On allow, records specs/<f>/.current.json (the task now in flight) so the
19
+ divergence-monitor knows which footprint to police for the writes that follow.
20
+
21
+ Dispatch prompt convention (enforced by G0): the orchestrator's Task prompt
22
+ must begin with a header block:
23
+
24
+ TASK-ID: backend-orders-api
25
+ FEATURE: order-cancellation
26
+
27
+ Exit codes:
28
+ 0 = allow dispatch
29
+ 2 = block dispatch (stderr message tells the orchestrator its legal move)
30
+ """
31
+
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ # Prefer the project dir Claude Code passes; fall back to cwd (hooks run from the
40
+ # repo root, so both agree in practice — the env var just hardens odd launches).
41
+ REPO = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd())
42
+ CONFIG_PATH = REPO / "harness.config.json"
43
+
44
+ DEFAULT_CAPS = {"mechanical": 2, "contract": 1, "ambiguity": 0, "security": 0}
45
+ # Context Rule 1: a specialist gets task + contract slice + (retry) findings —
46
+ # never the full plan/discovery/history. A bloated dispatch prompt is that leak.
47
+ DEFAULT_PROMPT_TOKEN_MAX = 8000
48
+
49
+
50
+ def block(msg: str) -> None:
51
+ print(f"[dispatch-gate] BLOCKED: {msg}", file=sys.stderr)
52
+ sys.exit(2)
53
+
54
+
55
+ def load_json(path: Path, what: str, required: bool = True):
56
+ if not path.exists():
57
+ if required:
58
+ block(f"{what} not found at {path}. It must exist before dispatch.")
59
+ return None
60
+ try:
61
+ return json.loads(path.read_text())
62
+ except json.JSONDecodeError:
63
+ block(f"{what} at {path} is not valid JSON. Fix or regenerate it.")
64
+
65
+
66
+ def main() -> None:
67
+ event = json.load(sys.stdin)
68
+
69
+ if event.get("tool_name") != "Task":
70
+ sys.exit(0) # not ours
71
+
72
+ prompt = (event.get("tool_input") or {}).get("prompt", "") or ""
73
+
74
+ # ---- G0: does the harness govern this dispatch? -------------------
75
+ m_task = re.search(r"^TASK-ID:\s*(\S+)", prompt, re.MULTILINE)
76
+ m_feat = re.search(r"^FEATURE:\s*(\S+)", prompt, re.MULTILINE)
77
+ if not m_task or not m_feat:
78
+ # Non-pipeline subagent use (e.g. context-researcher during planning,
79
+ # or ad-hoc usage) is allowed through untouched.
80
+ sys.exit(0)
81
+
82
+ task_id, feature = m_task.group(1), m_feat.group(1)
83
+ spec = REPO / "specs" / feature
84
+ if not spec.is_dir():
85
+ block(f"specs/{feature}/ does not exist. Run /feature to start the pipeline.")
86
+
87
+ cfg = load_json(CONFIG_PATH, "harness.config.json", required=False) or {}
88
+ caps = {**DEFAULT_CAPS, **cfg.get("retries", {})}
89
+
90
+ # ---- G6: context is bounded (Rule 1 sanity check) ------------------
91
+ # Cheap and early: a dispatch prompt carrying the whole plan/history is the
92
+ # context-distractor the loop layer exists to prevent. Approx tokens = chars/4.
93
+ prompt_token_max = int(cfg.get("budgets", {}).get("dispatch_prompt_max", DEFAULT_PROMPT_TOKEN_MAX))
94
+ approx_tokens = len(prompt) // 4
95
+ if approx_tokens > prompt_token_max:
96
+ block(
97
+ f"dispatch prompt for '{task_id}' is ~{approx_tokens} tokens "
98
+ f"(budget {prompt_token_max}). Context Rule 1: a specialist gets only "
99
+ "its task entry, its contracts/ slice, and (on retry) the latest "
100
+ "findings — never the full plan, discovery, or history. Rebuild the "
101
+ "prompt from disk artifacts, task-scoped."
102
+ )
103
+
104
+ plan = spec / "plan.md"
105
+ graph_p = spec / "task-graph.json"
106
+ approvals_p = spec / "approvals.json"
107
+ state_p = spec / "state.json"
108
+
109
+ if not plan.exists():
110
+ block(f"specs/{feature}/plan.md missing. Planning phase incomplete.")
111
+
112
+ # ---- G1: plan approval + hash match (the autonomy boundary) -------
113
+ approvals = load_json(approvals_p, "approvals.json")
114
+ plan_hash = hashlib.sha256(plan.read_bytes()).hexdigest()
115
+ if approvals.get("plan_hash") != plan_hash:
116
+ block(
117
+ "plan.md has changed since human approval (hash mismatch). "
118
+ "Automation is frozen. Present the revised draft plan to the user "
119
+ "and obtain re-approval (rewrite approvals.json) before any dispatch."
120
+ )
121
+
122
+ # ---- G2: task-graph freshness --------------------------------------
123
+ if not graph_p.exists():
124
+ block("task-graph.json missing. Run .claude/scripts/extract-deps.py first.")
125
+ if graph_p.stat().st_mtime < plan.stat().st_mtime:
126
+ block(
127
+ "task-graph.json is older than plan.md (stale schedule). "
128
+ "Regenerate it with extract-deps.py before dispatching."
129
+ )
130
+ graph = load_json(graph_p, "task-graph.json")
131
+
132
+ known = {t["id"] for t in graph.get("tasks", [])}
133
+ if task_id not in known:
134
+ block(f"task '{task_id}' is not in task-graph.json. Dispatch only planned tasks.")
135
+
136
+ state = load_json(state_p, "state.json", required=False) or {"tasks": {}, "health": "ok"}
137
+
138
+ # ---- G3: circuit breaker -------------------------------------------
139
+ if state.get("health") == "tripped":
140
+ reasons = ", ".join(state.get("health_reasons", [])) or "unspecified"
141
+ block(
142
+ f"circuit breaker is TRIPPED ({reasons}). All dispatches refused. "
143
+ "Your only legal move is to escalate to the user with the evidence "
144
+ "in state.json, then resume after human resolution."
145
+ )
146
+
147
+ # ---- G4: dependency edges satisfied --------------------------------
148
+ unmet = [
149
+ e["from"]
150
+ for e in graph.get("edges", [])
151
+ if e.get("to") == task_id
152
+ and state["tasks"].get(e["from"], {}).get("status") != "done"
153
+ ]
154
+ if unmet:
155
+ block(
156
+ f"upstream tasks not done for '{task_id}': {', '.join(sorted(set(unmet)))}. "
157
+ "Dispatch tasks in task-graph order."
158
+ )
159
+
160
+ # ---- G5: retry cap ---------------------------------------------------
161
+ t = state["tasks"].get(task_id, {})
162
+ attempts = int(t.get("attempts", 0))
163
+ fclass = t.get("pending_failure_class") # set by state-updater from review.json
164
+ if fclass is not None:
165
+ cap = int(caps.get(fclass, 0))
166
+ if attempts > cap: # attempts counts completed tries; > cap means budget spent
167
+ block(
168
+ f"retry cap reached for '{task_id}' "
169
+ f"(class={fclass}, attempts={attempts}, cap={cap} retries). "
170
+ "Do NOT re-dispatch. Escalate to the user with the latest "
171
+ f"review findings in specs/{feature}/."
172
+ )
173
+ if fclass in ("ambiguity", "security"):
174
+ block(
175
+ f"failure class '{fclass}' permits zero retries for '{task_id}'. "
176
+ "Escalate to the user immediately."
177
+ )
178
+
179
+ # ---- record the in-flight task for the divergence-monitor ----------
180
+ # A pointer file, not state.json (state.json stays single-writer:
181
+ # state-updater). The Edit/Write hook reads this to know whose footprint
182
+ # to police for the writes this dispatch is about to make.
183
+ try:
184
+ (spec / ".current.json").write_text(
185
+ json.dumps({"task_id": task_id, "feature": feature}), encoding="utf-8")
186
+ except OSError:
187
+ pass # advisory only — never fail a legal dispatch on a write hiccup
188
+
189
+ sys.exit(0) # all gates passed — dispatch allowed
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()