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.
- package/README.md +266 -0
- package/bin/cli.js +166 -0
- package/docs/langfuse-dashboards.md +39 -0
- package/package.json +34 -0
- package/src/detect.js +79 -0
- package/src/install.js +58 -0
- package/src/loop.js +181 -0
- package/src/merge.js +173 -0
- package/src/metrics.js +70 -0
- package/src/uninstall.js +63 -0
- package/template/.claude/agents/context-researcher.md +47 -0
- package/template/.claude/agents/planner.md +71 -0
- package/template/.claude/agents/reviewer.md +64 -0
- package/template/.claude/agents/specialist-backend.md +34 -0
- package/template/.claude/agents/specialist-database.md +32 -0
- package/template/.claude/agents/specialist-frontend.md +35 -0
- package/template/.claude/commands/feature.md +147 -0
- package/template/.claude/hooks/artifact-size.py +71 -0
- package/template/.claude/hooks/commit-gate.py +85 -0
- package/template/.claude/hooks/dispatch-gate.py +193 -0
- package/template/.claude/hooks/divergence-monitor.py +98 -0
- package/template/.claude/hooks/harness_lib.py +260 -0
- package/template/.claude/hooks/loop_lib.py +108 -0
- package/template/.claude/hooks/precompact-guard.py +91 -0
- package/template/.claude/hooks/req_lib.py +148 -0
- package/template/.claude/hooks/requirements-gate.py +62 -0
- package/template/.claude/hooks/retrieval-nudge.py +160 -0
- package/template/.claude/hooks/session-rehydrate.py +65 -0
- package/template/.claude/hooks/state-updater.py +119 -0
- package/template/.claude/hooks/trace-emitter.py +128 -0
- package/template/.claude/hooks/version-gate.py +86 -0
- package/template/.claude/hooks/version-tracker.py +67 -0
- package/template/.claude/hooks/version_lib.py +243 -0
- package/template/.claude/scripts/approve-plan.py +91 -0
- package/template/.claude/scripts/changeset.py +18 -0
- package/template/.claude/scripts/extract-deps.py +288 -0
- package/template/.claude/scripts/version.py +144 -0
- package/template/.claude/skills/contract-writing/SKILL.md +50 -0
- package/template/.claude/skills/discovery-interview/SKILL.md +56 -0
- package/template/.claude/skills/review-taxonomy/SKILL.md +51 -0
- package/template/CLAUDE.harness.md +68 -0
- package/template/harness.config.json +58 -0
- 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()
|