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,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 -->
|