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,288 @@
1
+ #!/usr/bin/env python
2
+ """extract-deps.py — deterministic task-dependency extractor (Architecture.md §5).
3
+
4
+ Turns the planner's per-task file footprints into a scheduling DAG. This is the
5
+ one component with real unit tests, because it is pure computation: given the
6
+ same plan.md and the same codebase graph it MUST emit a byte-identical
7
+ task-graph.json. Nothing here calls an LLM — that is the whole point of taking
8
+ dependency math away from the agents.
9
+
10
+ Usage
11
+ extract-deps.py --plan specs/<f>/plan.md --out specs/<f>/task-graph.json
12
+ extract-deps.py --files src/auth/ src/db/schema.prisma # standalone "what does this touch?"
13
+
14
+ Options
15
+ --graphify-out graphify-out/ structural graph directory (default)
16
+ --depth 1 hops to expand each footprint (default 1)
17
+
18
+ Footprint source of truth — plan.md declares each task in a fenced ```task block:
19
+
20
+ ```task
21
+ id: db-orders-migration
22
+ agent: specialist-database
23
+ footprint:
24
+ - db/migrations/
25
+ - db/schema.prisma
26
+ produces: [migration] # optional interface(s) this task defines
27
+ consumes: [] # optional interface(s) this task depends on
28
+ ```
29
+
30
+ Edges (a sequential dependency = the two tasks may NOT run concurrently):
31
+ * overlap — a footprint file is literally in both tasks
32
+ * shared_dependent — the graph expands both onto a common third file
33
+ * contract — one task consumes an interface another produces
34
+ Fully disjoint expanded footprints and no contract link => parallel-safe (no edge).
35
+ Direction: contract producer->consumer; otherwise earlier-in-plan -> later (a
36
+ total order, so the graph is always a DAG).
37
+ """
38
+
39
+ import argparse
40
+ import fnmatch
41
+ import hashlib
42
+ import json
43
+ import os
44
+ import re
45
+ import sys
46
+
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # plan.md parsing — a tiny, dependency-free subset parser for ```task blocks
50
+ # --------------------------------------------------------------------------- #
51
+ TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)```", re.S)
52
+
53
+
54
+ def _parse_list(inline):
55
+ inline = inline.strip()
56
+ if inline.startswith("[") and inline.endswith("]"):
57
+ inner = inline[1:-1].strip()
58
+ return [x.strip().strip("'\"") for x in inner.split(",") if x.strip()]
59
+ return []
60
+
61
+
62
+ def parse_plan(text):
63
+ """Return an ordered list of task dicts: id, agent, footprint, produces, consumes."""
64
+ tasks = []
65
+ for block in TASK_BLOCK_RE.findall(text):
66
+ task = {"id": None, "agent": None, "footprint": [], "produces": [], "consumes": []}
67
+ key = None
68
+ for raw in block.splitlines():
69
+ line = raw.rstrip()
70
+ if not line.strip():
71
+ continue
72
+ m = re.match(r"^(\w+):\s*(.*)$", line)
73
+ if m and not line.startswith((" ", "\t", "-")):
74
+ key, val = m.group(1), m.group(2).strip()
75
+ if key in ("footprint", "produces", "consumes"):
76
+ task[key] = _parse_list(val) if val else []
77
+ elif key in ("id", "agent"):
78
+ task[key] = val.strip("'\"")
79
+ continue
80
+ item = re.match(r"^\s*-\s*(.+)$", line)
81
+ if item and key in ("footprint", "produces", "consumes"):
82
+ task[key].append(item.group(1).strip().strip("'\""))
83
+ if task["id"]:
84
+ task["footprint"] = [norm(p) for p in task["footprint"]]
85
+ tasks.append(task)
86
+ return tasks
87
+
88
+
89
+ def norm(p):
90
+ return p.replace("\\", "/").strip()
91
+
92
+
93
+ # --------------------------------------------------------------------------- #
94
+ # graphify structural graph — defensive loader + undirected BFS expansion
95
+ # --------------------------------------------------------------------------- #
96
+ def load_graph(graphify_out):
97
+ path = os.path.join(graphify_out, "graph.json")
98
+ if not os.path.exists(path):
99
+ return None
100
+ try:
101
+ with open(path, "r", encoding="utf-8") as fh:
102
+ return json.load(fh)
103
+ except Exception:
104
+ return None
105
+
106
+
107
+ def node_file(node):
108
+ for k in ("file", "path", "source_location", "location", "filepath"):
109
+ v = node.get(k) if isinstance(node, dict) else None
110
+ if isinstance(v, str) and v:
111
+ return norm(v.split(":", 1)[0])
112
+ return None
113
+
114
+
115
+ def build_index(graph):
116
+ """Return (node_file_map, adjacency) from a best-effort read of graph.json."""
117
+ nodes = graph.get("nodes", []) if isinstance(graph, dict) else []
118
+ files, adj = {}, {}
119
+ for n in nodes:
120
+ nid = n.get("id") if isinstance(n, dict) else None
121
+ if nid is None:
122
+ continue
123
+ files[nid] = node_file(n)
124
+ adj.setdefault(nid, set())
125
+ for e in graph.get("edges", []) if isinstance(graph, dict) else []:
126
+ if isinstance(e, dict):
127
+ a, b = e.get("source", e.get("from")), e.get("target", e.get("to"))
128
+ elif isinstance(e, (list, tuple)) and len(e) >= 2:
129
+ a, b = e[0], e[1]
130
+ else:
131
+ continue
132
+ if a in adj and b in adj:
133
+ adj[a].add(b)
134
+ adj[b].add(a)
135
+ return files, adj
136
+
137
+
138
+ def matches(fpath, entry):
139
+ if fpath is None:
140
+ return False
141
+ entry = norm(entry)
142
+ if entry.endswith("/"):
143
+ return fpath.startswith(entry)
144
+ if any(c in entry for c in "*?["):
145
+ return fnmatch.fnmatch(fpath, entry)
146
+ return fpath == entry or fpath.startswith(entry + "/")
147
+
148
+
149
+ def expand(footprint, files, adj, depth):
150
+ """Expand a footprint to a sorted file set via <=depth undirected hops."""
151
+ if not files:
152
+ return sorted(set(footprint))
153
+ seeds = {nid for nid, f in files.items() if any(matches(f, e) for e in footprint)}
154
+ frontier, seen = set(seeds), set(seeds)
155
+ for _ in range(max(0, depth)):
156
+ nxt = set()
157
+ for nid in frontier:
158
+ nxt |= adj.get(nid, set())
159
+ nxt -= seen
160
+ seen |= nxt
161
+ frontier = nxt
162
+ expanded = {files[nid] for nid in seen if files.get(nid)}
163
+ return sorted(expanded | set(footprint))
164
+
165
+
166
+ # --------------------------------------------------------------------------- #
167
+ # edge computation
168
+ # --------------------------------------------------------------------------- #
169
+ def common_key(a_expanded, b_expanded):
170
+ """First shared file/dir between two expanded sets, deterministically."""
171
+ for x in sorted(a_expanded):
172
+ for y in sorted(b_expanded):
173
+ if x == y or x.startswith(y.rstrip("/") + "/") or y.startswith(x.rstrip("/") + "/"):
174
+ return x if len(x) <= len(y) else y
175
+ return None
176
+
177
+
178
+ def compute_edges(tasks):
179
+ order = {t["id"]: i for i, t in enumerate(tasks)}
180
+ raw = {t["id"]: set(t["footprint"]) for t in tasks}
181
+ exp = {t["id"]: set(t["_expanded"]) for t in tasks}
182
+ edges = {}
183
+
184
+ # contract edges: producer -> consumer
185
+ for p in tasks:
186
+ for c in tasks:
187
+ if p["id"] == c["id"]:
188
+ continue
189
+ shared = set(p.get("produces", [])) & set(c.get("consumes", []))
190
+ for iface in sorted(shared):
191
+ edges[(p["id"], c["id"])] = "contract: %s" % iface
192
+
193
+ # footprint edges: overlap / shared_dependent, directed by plan order
194
+ ids = [t["id"] for t in tasks]
195
+ for i in range(len(ids)):
196
+ for j in range(i + 1, len(ids)):
197
+ a, b = ids[i], ids[j]
198
+ key = common_key(exp[a], exp[b])
199
+ if key is None:
200
+ continue
201
+ frm, to = (a, b) if order[a] <= order[b] else (b, a)
202
+ if (frm, to) in edges or (to, frm) in edges:
203
+ continue # a contract edge already orders this pair
204
+ in_raw = any(matches(f, key) or matches(key, f) for f in raw[a]) and \
205
+ any(matches(f, key) or matches(key, f) for f in raw[b])
206
+ reason = ("overlap: %s" if in_raw else "shared_dependent: %s") % key
207
+ edges[(frm, to)] = reason
208
+
209
+ return [{"from": f, "to": t, "reason": r} for (f, t), r in
210
+ sorted(edges.items(), key=lambda kv: kv[0])]
211
+
212
+
213
+ # --------------------------------------------------------------------------- #
214
+ # entry points
215
+ # --------------------------------------------------------------------------- #
216
+ def build_task_graph(plan_path, graphify_out, depth):
217
+ text = open(plan_path, "r", encoding="utf-8").read()
218
+ tasks = parse_plan(text)
219
+ graph = load_graph(graphify_out)
220
+ files, adj = build_index(graph) if graph else ({}, {})
221
+
222
+ for t in sorted(tasks, key=lambda x: x["id"]):
223
+ t["_expanded"] = expand(t["footprint"], files, adj, depth)
224
+
225
+ tasks_sorted = sorted(tasks, key=lambda x: x["id"])
226
+ out_tasks = [{
227
+ "id": t["id"], "agent": t.get("agent"),
228
+ "footprint": sorted(t["footprint"]),
229
+ "expanded_footprint": t["_expanded"],
230
+ "produces": sorted(t.get("produces", [])),
231
+ "consumes": sorted(t.get("consumes", [])),
232
+ } for t in tasks_sorted]
233
+
234
+ gv = "none"
235
+ if isinstance(graph, dict):
236
+ gv = str(graph.get("graphify_version") or graph.get("version") or "unknown")
237
+
238
+ return {
239
+ "generated_from": {
240
+ "plan_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
241
+ "graphify_version": gv,
242
+ "dependency_depth": depth,
243
+ },
244
+ "tasks": out_tasks,
245
+ "edges": compute_edges(tasks_sorted),
246
+ }
247
+
248
+
249
+ def dumps_stable(obj):
250
+ return json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
251
+
252
+
253
+ def main(argv=None):
254
+ ap = argparse.ArgumentParser(description="Deterministic task-dependency extractor")
255
+ ap.add_argument("--plan")
256
+ ap.add_argument("--out")
257
+ ap.add_argument("--files", nargs="+")
258
+ ap.add_argument("--graphify-out", default="graphify-out")
259
+ ap.add_argument("--depth", type=int, default=None)
260
+ args = ap.parse_args(argv)
261
+
262
+ depth = args.depth if args.depth is not None else 1
263
+
264
+ if args.files:
265
+ graph = load_graph(args.graphify_out)
266
+ files, adj = build_index(graph) if graph else ({}, {})
267
+ touched = expand([norm(f) for f in args.files], files, adj, depth)
268
+ report = {"input": [norm(f) for f in args.files],
269
+ "depth": depth,
270
+ "graph": "present" if graph else "absent",
271
+ "touched": touched}
272
+ sys.stdout.write(dumps_stable(report))
273
+ return 0
274
+
275
+ if not (args.plan and args.out):
276
+ ap.error("provide either --files, or both --plan and --out")
277
+
278
+ result = build_task_graph(args.plan, args.graphify_out, depth)
279
+ os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
280
+ with open(args.out, "w", encoding="utf-8") as fh:
281
+ fh.write(dumps_stable(result))
282
+ sys.stderr.write("task-graph.json: %d tasks, %d edges\n"
283
+ % (len(result["tasks"]), len(result["edges"])))
284
+ return 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ raise SystemExit(main())
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """version.py — the Version Controller CLI.
3
+
4
+ python .claude/scripts/version.py baseline <feature> # capture pre-feature deps
5
+ python .claude/scripts/version.py deps <feature> # recompute + show dep changes
6
+ python .claude/scripts/version.py ack-deps <feature> [--who X] # acknowledge major bumps
7
+ python .claude/scripts/version.py bump <feature> [--level major|minor|patch] [--now TS]
8
+ python .claude/scripts/version.py snapshot <feature> <milestone> [--now TS]
9
+ python .claude/scripts/version.py status <feature>
10
+
11
+ Dependency baseline is captured at approval (approve-plan.py calls `baseline`);
12
+ the tracker hook diffs against it; `bump` writes VERSION + CHANGELOG; `snapshot`
13
+ records development history. Keep sha256/semver logic in version_lib so the hook
14
+ and this CLI never disagree.
15
+ """
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks"))
24
+ import harness_lib as lib # noqa: E402
25
+ import version_lib as ver # noqa: E402
26
+
27
+ REPO = str(Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd()))
28
+
29
+
30
+ def spec_of(feature):
31
+ return os.path.join(REPO, "specs", feature)
32
+
33
+
34
+ def _cfg():
35
+ return lib.load_config(REPO).get("versioning", {})
36
+
37
+
38
+ def cmd_baseline(a):
39
+ spec = spec_of(a.feature)
40
+ os.makedirs(spec, exist_ok=True)
41
+ deps = ver.snapshot_deps(REPO, _cfg().get("manifests"))
42
+ lib.write_json(os.path.join(spec, "dependencies.baseline.json"), deps)
43
+ sys.stderr.write("baseline: %d dependencies captured for '%s'\n" % (len(deps), a.feature))
44
+ return 0
45
+
46
+
47
+ def cmd_deps(a):
48
+ spec = spec_of(a.feature)
49
+ baseline = lib.read_json(os.path.join(spec, "dependencies.baseline.json"))
50
+ if baseline is None:
51
+ sys.stderr.write("no baseline — run: version.py baseline %s (at approval)\n" % a.feature)
52
+ return 1
53
+ changes = ver.diff_deps(baseline, ver.snapshot_deps(REPO, _cfg().get("manifests")))
54
+ prev = lib.read_json(os.path.join(spec, "dependencies.json"), default={}) or {}
55
+ lib.write_json(os.path.join(spec, "dependencies.json"), {
56
+ "changes": changes, "has_major": ver.has_major(changes),
57
+ "acknowledged": bool(prev.get("acknowledged")) and not ver.has_major(changes),
58
+ })
59
+ print(json.dumps(changes, indent=2))
60
+ return 0
61
+
62
+
63
+ def cmd_ack_deps(a):
64
+ spec = spec_of(a.feature)
65
+ dp = os.path.join(spec, "dependencies.json")
66
+ deps = lib.read_json(dp, default={}) or {}
67
+ deps["acknowledged"] = True
68
+ deps["acknowledged_by"] = a.who
69
+ deps["acknowledged_at"] = a.now or datetime.now(timezone.utc).isoformat()
70
+ lib.write_json(dp, deps)
71
+ sys.stderr.write("acknowledged major dependency changes for '%s'\n" % a.feature)
72
+ return 0
73
+
74
+
75
+ def cmd_bump(a):
76
+ spec = spec_of(a.feature)
77
+ level = a.level or ver.plan_version_impact(spec) or "minor"
78
+ current = ver.read_root_version(REPO)
79
+ new = ver.next_version(current, level)
80
+ now = a.now or datetime.now(timezone.utc).isoformat()
81
+
82
+ with open(os.path.join(REPO, "VERSION"), "w", encoding="utf-8") as fh:
83
+ fh.write(new + "\n")
84
+
85
+ deps = lib.read_json(os.path.join(spec, "dependencies.json"), default={}) or {}
86
+ dep_line = "none"
87
+ if deps.get("changes"):
88
+ dep_line = ", ".join("%s %s->%s (%s)" % (c["name"], c["from"], c["to"], c["severity"])
89
+ for c in deps["changes"])
90
+ entry = ("## %s — %s\n\n- Release level: **%s**\n- Feature: `%s`\n- Dependency changes: %s\n\n"
91
+ % (new, now[:10] if now else "", level, a.feature, dep_line))
92
+ clog = os.path.join(REPO, "CHANGELOG.md")
93
+ existing = ""
94
+ if os.path.exists(clog):
95
+ with open(clog, "r", encoding="utf-8", errors="ignore") as fh:
96
+ existing = fh.read()
97
+ header = "# Changelog\n\n"
98
+ body = existing[len(header):] if existing.startswith(header) else existing
99
+ with open(clog, "w", encoding="utf-8") as fh:
100
+ fh.write(header + entry + body)
101
+
102
+ lib.write_json(os.path.join(spec, "version.json"),
103
+ {"code_version": new, "level": level, "previous": current, "at": now})
104
+ sys.stderr.write("bumped %s -> %s (%s) and wrote CHANGELOG for '%s'\n" % (current, new, level, a.feature))
105
+ return 0
106
+
107
+
108
+ def cmd_snapshot(a):
109
+ dev = ver.record_snapshot(spec_of(a.feature), a.milestone,
110
+ now=a.now or datetime.now(timezone.utc).isoformat())
111
+ sys.stderr.write("dev-version %d snapshot '%s' for '%s'\n" % (dev, a.milestone, a.feature))
112
+ return 0
113
+
114
+
115
+ def cmd_status(a):
116
+ spec = spec_of(a.feature)
117
+ deps = lib.read_json(os.path.join(spec, "dependencies.json"), default={}) or {}
118
+ vj = lib.read_json(os.path.join(spec, "version.json"), default={}) or {}
119
+ hist = lib.read_json(os.path.join(spec, "history.json"), default={}) or {}
120
+ print("Version status — %s" % a.feature)
121
+ print(" code_version: %s (%s)" % (vj.get("code_version", "—"), vj.get("level", "—")))
122
+ print(" dependency changes: %d (major=%s, acknowledged=%s)"
123
+ % (len(deps.get("changes", [])), deps.get("has_major", False), deps.get("acknowledged", False)))
124
+ print(" dev_version: %s (%d milestones)" % (hist.get("dev_version", "—"), len(hist.get("entries", []))))
125
+ return 0
126
+
127
+
128
+ def main(argv=None):
129
+ ap = argparse.ArgumentParser()
130
+ sub = ap.add_subparsers(dest="cmd", required=True)
131
+ for name in ("baseline", "deps", "status"):
132
+ s = sub.add_parser(name); s.add_argument("feature")
133
+ s = sub.add_parser("ack-deps"); s.add_argument("feature"); s.add_argument("--who", default="human"); s.add_argument("--now")
134
+ s = sub.add_parser("bump"); s.add_argument("feature"); s.add_argument("--level", choices=["major", "minor", "patch"]); s.add_argument("--now")
135
+ s = sub.add_parser("snapshot"); s.add_argument("feature"); s.add_argument("milestone"); s.add_argument("--now")
136
+ a = ap.parse_args(argv)
137
+ return {
138
+ "baseline": cmd_baseline, "deps": cmd_deps, "ack-deps": cmd_ack_deps,
139
+ "bump": cmd_bump, "snapshot": cmd_snapshot, "status": cmd_status,
140
+ }[a.cmd](a)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: contract-writing
3
+ description: How to write the interface contracts that let specialists work in parallel without colliding. Use in the planner, before finalizing the task list — contracts are written before any implementation.
4
+ ---
5
+
6
+ # contract-writing
7
+
8
+ Contracts are the harness's answer to the cross-specialist seam problem. They fix
9
+ every interface **before** implementation, so a frontend task builds against the
10
+ agreed API shape rather than the backend's half-finished code. A contract is a
11
+ promise a reviewer can check a diff against.
12
+
13
+ ## Principle
14
+ Write the contract for a seam **only where two tasks meet**. Internal
15
+ implementation detail is not a contract. If exactly one task touches something,
16
+ it needs no contract — footprint ownership is enough.
17
+
18
+ ## The three usual contracts (write what the feature needs, name each interface)
19
+
20
+ `contracts/api.yaml` — endpoint shapes the backend *produces* and the frontend
21
+ *consumes*. Method, path, request body, response body, error responses. Field
22
+ names here are law: the classic seam bug is a frontend reading `refundId` when the
23
+ contract (and backend) say `refund_id`.
24
+
25
+ ```yaml
26
+ cancel-api: # <- the interface name tasks reference in produces/consumes
27
+ POST /orders/{id}/cancel:
28
+ request: { reason: string }
29
+ response: { status: "cancelled", refund_id: string, refunded_amount: number }
30
+ errors: { 409: "already cancelled", 422: "not cancellable" }
31
+ ```
32
+
33
+ `contracts/migration.md` — the DB shape the data task *produces*: tables/columns
34
+ added or changed, nullability, indexes, reversibility. Downstream API tasks
35
+ `consume` it.
36
+
37
+ `contracts/components.md` — component props / client-state contracts the frontend
38
+ owns and any shared UI seam.
39
+
40
+ ## Naming interfaces
41
+ Give each seam a short id (`cancel-api`, `migration`) and use it in the plan's
42
+ `produces:` / `consumes:` lists. `extract-deps.py` turns a produce→consume pair
43
+ into a sequential edge even when the two tasks share no files — that is how the
44
+ schedule knows the frontend waits on the backend.
45
+
46
+ ## Checklist before finalizing the plan
47
+ - Every cross-task seam has a named contract entry.
48
+ - Every task that depends on a seam lists it in `consumes:`.
49
+ - No contract describes a purely internal detail (over-specifying creates false
50
+ edges and needless serialization).
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: discovery-interview
3
+ description: Protocol-driven feature discovery. Use on the main thread at the start of /feature (and on every escalation re-entry) to interview the user for edge cases, non-functionals, and acceptance criteria, then write discovery.md.
4
+ ---
5
+
6
+ # discovery-interview
7
+
8
+ Discovery is a **phase on the main thread**, not a subagent — subagents cannot
9
+ talk to the user. Your job is to extract the requirements a plan can be built on
10
+ and the acceptance criteria a review can be judged against. Interview
11
+ protocol-driven, not generically.
12
+
13
+ ## Protocol — cover all four, in order
14
+
15
+ 1. **Intent & scope.** One sentence: what changes for the user? What is explicitly
16
+ out of scope? Name the modules you expect to touch (confirm against graphify).
17
+
18
+ 2. **Edge cases.** Push hard here — this is where ambiguity that later stalls the
19
+ pipeline is cheapest to resolve. Ask the awkward ones concretely:
20
+ - boundary states ("an already-refunded order?", "a partial shipment?")
21
+ - concurrency ("two cancels race?"), empty/limit cases, failure/rollback paths.
22
+
23
+ 3. **Non-functionals.** Idempotency, performance budgets, security/authz
24
+ constraints, observability/audit requirements, backward compatibility.
25
+
26
+ 4. **Acceptance criteria.** Concrete, checkable statements, each labelled with a
27
+ stable id — **AC1, AC2, AC3, …** — one per line:
28
+ ```
29
+ - AC1: cancelling a shipped order refunds only the unshipped portion
30
+ - AC2: refunds are idempotent (a repeated cancel does not double-refund)
31
+ ```
32
+ These ids are load-bearing: the planner tags each task with the ACs it
33
+ `satisfies:`, and the requirements gate refuses to proceed unless every AC is
34
+ covered by a task and no task invents an AC that isn't here (scope creep). They
35
+ are also the reviewer's rubric — vague criteria produce `ambiguity` failures.
36
+
37
+ ## Rules
38
+ - Ask, don't assume. One unresolved ambiguity here is worth ten confident wrong
39
+ guesses later. If the user says "you decide", record the decision explicitly as
40
+ a chosen default so the reviewer can check it.
41
+ - **Leave no unresolved markers.** `TBD`, `TODO`, `???`, "clarify with…", "not
42
+ sure", "decide later" in discovery.md will freeze the pipeline at the
43
+ requirements gate — resolve them with the user now, or record an explicit
44
+ chosen default. An unresolved marker is an unfinished interview.
45
+ - Keep it tight — a handful of high-leverage questions, not an interrogation.
46
+
47
+ ## Output — `specs/<feature>/discovery.md`
48
+ Sections (all four are required; the requirements gate checks for them):
49
+ **Intent & Scope** (explicit in-scope AND out-of-scope), **Edge Cases** (Q→A),
50
+ **Non-functionals**, **Acceptance Criteria** (AC1, AC2, … — checkable, one per line).
51
+
52
+ ## On escalation re-entry
53
+ When the orchestrator escalates, you are re-entered with a specific question and
54
+ the conflicting constraint. Get the answer, then **append** a dated
55
+ `## Escalation: <topic>` section to `discovery.md` — never rewrite prior content.
56
+ The append is the new requirements information the plan is patched from.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: review-taxonomy
3
+ description: The failure classification the reviewer must apply. Use in the reviewer to assign a failure_class, which sets the retry policy the dispatch gate enforces. Also defines the repeat-finding early-stop rule.
4
+ ---
5
+
6
+ # review-taxonomy
7
+
8
+ Your `failure_class` is not a label — it selects the retry policy the dispatch
9
+ gate enforces by exit code. Classify precisely; the wrong class either wastes
10
+ retries on an unretryable problem or hard-stops a fixable one.
11
+
12
+ ## Decision tree (apply top to bottom; first match wins)
13
+
14
+ 1. **security** — a vulnerability (injection, authz bypass, secret exposure,
15
+ unsafe deserialization). → `fail`, **0 retries**, hard stop for human review.
16
+ Rationale is policy, not capability: you do not let an agent iterate until the
17
+ scanner goes quiet. Never downgrade a real security finding to unblock a run.
18
+
19
+ 2. **ambiguity** — you cannot determine the *intended* behavior from discovery +
20
+ contracts (e.g. "should cancelling a partially-shipped order refund the shipped
21
+ portion?"). → `fail`, **0 retries**. Retrying ambiguity only manufactures
22
+ confident guesses; it must go back to the human. Name the exact undecided
23
+ question in a finding — the orchestrator will ask the user verbatim.
24
+
25
+ 3. **contract** — the implementation does not match `contracts/` (wrong field
26
+ name, wrong status code, missing endpoint). → `fail`, **1 retry**. A *second*
27
+ contract failure means the contract itself is ambiguous → escalate as ambiguity.
28
+
29
+ 4. **mechanical** — tests fail, type/lint errors, a missing migration; the fix is
30
+ derivable from the error output. → `fail`, **2 retries** (3 attempts total).
31
+
32
+ If none apply and the work meets the acceptance criteria and stays in footprint:
33
+ → `status: "pass"`, `failure_class: null`.
34
+
35
+ ## Findings
36
+ Each finding: `{ id, severity, file, detail }`. `detail` must be specific enough
37
+ that the specialist can act without guessing (name the file, the symbol, the
38
+ expected vs actual). Severity `error` blocks a pass; `warn` does not.
39
+
40
+ ## Repeat-finding early stop (mandatory)
41
+ Before writing your verdict, read the previous attempt's `review.<task>.*.json`.
42
+ If any finding is essentially identical to one you filed last attempt, set
43
+ `"repeat_finding": true`. Two identical failures predict a third — the harness
44
+ escalates immediately rather than spending the remaining retry budget confirming
45
+ the specialist doesn't understand the fix.
46
+
47
+ ## Integration mode
48
+ Same taxonomy, but over the combined diff plus the graphify pass on touched files.
49
+ The seam bug you are hunting (a parallel task still reading a renamed field)
50
+ usually classes as `contract`. Record `changeset` on a pass — the commit gate
51
+ checks it.
@@ -0,0 +1,68 @@
1
+ <!-- harness:begin v1.0 -->
2
+ ## Loop Engineering — Agent Harness
3
+
4
+ This repo runs the Loop Engineering harness. **Policy lives in exit codes and
5
+ on-disk artifacts, not in prose** — hooks and scripts enforce the rules below; you
6
+ exercise judgment within them. Files in `specs/<feature>/` are the pipeline;
7
+ `harness.config.json` holds every knob. Any gate that blocks tells you the legal
8
+ next move — do that, don't route around it.
9
+
10
+ ### Retrieval order — always graphify → skills → memory
11
+ Before broad file exploration, query the knowledge graph: `graphify query "<q>"`.
12
+ If `graphify-out/graph.json` is missing, initialize with `/graphify .` (it
13
+ self-installs and builds it). A hook nudges broad `Grep`/`Glob` toward the graph
14
+ once per session; narrow, file-scoped reads pass through.
15
+
16
+ ### Features run through `/feature` — everything else is untouched
17
+ Small edits do not use the pipeline. `/feature` opens a discovery interview, then
18
+ drives: context → plan + contracts → **plan approval** → dispatch → review →
19
+ integration review → **version** → commit. Humans are needed at exactly two
20
+ points: the interview/approval and escalations.
21
+
22
+ ### The enforced gates (you cannot talk past these)
23
+ - **Requirements gate** — refuses to proceed on an ambiguous intention, vague or
24
+ unstructured requirements (need Intent & Scope, edge cases, non-functionals, and
25
+ `AC1, AC2…` acceptance criteria — no TBD/??? markers), a wrong flow, or a plan
26
+ that has diverged from the acceptance criteria (an AC covered by no task, or a
27
+ task inventing scope). On block, **re-enter interview mode** — these go to the
28
+ user, never to a guess.
29
+ - **Plan-approval boundary** — `dispatch-gate.py` refuses every dispatch until
30
+ `specs/<f>/approvals.json` matches `plan.md`'s hash. Sign with
31
+ `python .claude/scripts/approve-plan.py <feature> --budget <tokens>`. Editing the
32
+ plan after approval re-freezes automation. In headless mode (`HARNESS_AUTO_APPROVE=1`)
33
+ self-approve after drafting — a logged decision — unless discovery is ambiguous.
34
+ - **Dispatch gate** — enforces approval, task-graph freshness, dependency order,
35
+ retry caps, the circuit breaker, and a context-size limit (Rule 1: a dispatch
36
+ prompt carries only the task's slice — never the full plan, discovery, or history).
37
+ - **Circuit breaker** — `state.json` `health` becomes `tripped` on retry caps,
38
+ repeat findings, too many escalations, footprint violations, or token-burndown
39
+ breach. Then all dispatch is refused and your only move is to escalate with the
40
+ evidence in `state.json` / `divergence.json`.
41
+ - **Commit gate** — `git commit` / `gh pr create` is blocked until a passing
42
+ **integration review** exists for the current diff.
43
+ - **Version gate** — commit is also blocked until the feature is versioned:
44
+ major dependency bumps acknowledged (`version.py ack-deps`), a code semver bump +
45
+ `CHANGELOG.md` entry (`version.py bump`), and a development snapshot
46
+ (`version.py snapshot <feature> integrated`).
47
+
48
+ ### Rules you must follow
49
+ - **State is hook-owned.** Never hand-edit `state.json`, `review.*.json`,
50
+ `approvals.json`, or the version/dependency/history files.
51
+ - **Task-scoped dispatch (Rule 1).** Begin each pipeline Task prompt with
52
+ `FEATURE: <slug>` (specialists add `TASK-ID: <id>`), and include only that task's
53
+ entry, its contracts slice, and — on retry — the latest findings.
54
+ - **Stateless retries (Rule 3).** A retry is a fresh subagent given the findings and
55
+ the failed code as a diff to correct — never its own prior reasoning.
56
+ - **Contracts before code.** Specialists implement against `contracts/`, stay inside
57
+ their declared footprint, and report anything cross-seam as a finding.
58
+ - **Escalation is structured re-entry.** Present the failure/condition and one
59
+ specific question; append the answer to `discovery.md`; re-approve if the plan
60
+ changed. An escalation produces an artifact update, not a chat apology.
61
+ - **Compaction is fine.** After a compact, trust `specs/<feature>/` and the injected
62
+ snapshot — re-read from disk rather than compacted memory.
63
+
64
+ Artifacts per feature live in `specs/<feature>/` (discovery, context-pack, plan,
65
+ contracts, task-graph, approvals, state, reviews, dependencies, version, history).
66
+ The autonomous driver processes `specs/_queue.json`; parked features await a human
67
+ in `specs/_answers.json`.
68
+ <!-- harness:end -->