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,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""divergence-monitor.py — PostToolUse hook on Edit|Write (DevelopmentUpdates.md §5).
|
|
3
|
+
|
|
4
|
+
The footprint-violation detector: the approved plan *declared* which files each
|
|
5
|
+
task may touch, so any write outside that set is, by definition, implementation
|
|
6
|
+
wandering from the approved plan — divergence made measurable. It is the sharpest
|
|
7
|
+
goal-diversion signal precisely because footprints already live on disk in
|
|
8
|
+
task-graph.json; no agent judgment is involved (design principle D-3).
|
|
9
|
+
|
|
10
|
+
Thresholds (harness.config.json → circuit_breaker.footprint_violations):
|
|
11
|
+
warn (default 1) -> record + warn on stderr, let the run continue
|
|
12
|
+
halt (default 2) -> record + trip the breaker in state.json; exit 2 so the
|
|
13
|
+
subagent sees it immediately. dispatch-gate G3 then refuses
|
|
14
|
+
every further dispatch until a human resolves it.
|
|
15
|
+
|
|
16
|
+
Always safe: if there is no task in flight (.current.json absent) the edit is not
|
|
17
|
+
a pipeline edit and passes untouched. Harness/runtime paths are never policed.
|
|
18
|
+
"""
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
import harness_lib as lib # noqa: E402
|
|
24
|
+
import loop_lib as loop # noqa: E402
|
|
25
|
+
|
|
26
|
+
IGNORE_PREFIXES = ("specs/", ".claude/", "graphify-out/", ".git/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rel_to_repo(root, file_path):
|
|
30
|
+
if not file_path:
|
|
31
|
+
return None
|
|
32
|
+
ap = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
|
|
33
|
+
rel = os.path.relpath(os.path.abspath(ap), os.path.abspath(root))
|
|
34
|
+
return rel.replace("\\", "/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main():
|
|
38
|
+
event = lib.read_hook_input()
|
|
39
|
+
if event.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
|
|
40
|
+
lib.allow()
|
|
41
|
+
|
|
42
|
+
root = lib.project_dir(event)
|
|
43
|
+
ti = event.get("tool_input", {}) or {}
|
|
44
|
+
rel = rel_to_repo(root, ti.get("file_path"))
|
|
45
|
+
if not rel:
|
|
46
|
+
lib.allow()
|
|
47
|
+
|
|
48
|
+
feature_dir = lib.current_feature_dir(root)
|
|
49
|
+
if not feature_dir:
|
|
50
|
+
lib.allow()
|
|
51
|
+
cur = loop.read_current(feature_dir)
|
|
52
|
+
if not cur or not cur.get("task_id"):
|
|
53
|
+
lib.allow() # no task in flight -> not a policed pipeline edit
|
|
54
|
+
|
|
55
|
+
# never police harness/runtime artifacts
|
|
56
|
+
if any(rel == p.rstrip("/") or rel.startswith(p) for p in IGNORE_PREFIXES):
|
|
57
|
+
lib.allow()
|
|
58
|
+
|
|
59
|
+
task_id = cur["task_id"]
|
|
60
|
+
graph = lib.read_json(os.path.join(feature_dir, "task-graph.json"), default={}) or {}
|
|
61
|
+
task = next((t for t in graph.get("tasks", []) if t.get("id") == task_id), None)
|
|
62
|
+
if task is None:
|
|
63
|
+
lib.allow() # unknown task -> don't invent a violation
|
|
64
|
+
|
|
65
|
+
footprint = task.get("footprint", [])
|
|
66
|
+
if loop.in_footprint(rel, footprint):
|
|
67
|
+
lib.allow() # inside the declared footprint — all good
|
|
68
|
+
|
|
69
|
+
# ---- violation ----------------------------------------------------
|
|
70
|
+
config = lib.load_config(root)
|
|
71
|
+
cb = (config.get("circuit_breaker", {}) or {}).get("footprint_violations", {}) or {}
|
|
72
|
+
warn_at, halt_at = int(cb.get("warn", 1)), int(cb.get("halt", 2))
|
|
73
|
+
|
|
74
|
+
div = lib.read_json(loop.divergence_path(feature_dir), default={}) or {}
|
|
75
|
+
fpv = div.setdefault("footprint_violations", {})
|
|
76
|
+
fpv[task_id] = int(fpv.get(task_id, 0)) + 1
|
|
77
|
+
div.setdefault("events", []).append({"task": task_id, "file": rel})
|
|
78
|
+
lib.write_json(loop.divergence_path(feature_dir), div)
|
|
79
|
+
|
|
80
|
+
# recompute health from disk and persist (health is derived; writers converge)
|
|
81
|
+
state = lib.read_json(os.path.join(feature_dir, "state.json"), default={}) or {}
|
|
82
|
+
state.setdefault("tasks", {})
|
|
83
|
+
loop.recompute_health(state, feature_dir, config)
|
|
84
|
+
lib.write_json(os.path.join(feature_dir, "state.json"), state)
|
|
85
|
+
|
|
86
|
+
total = sum(fpv.values())
|
|
87
|
+
msg = ("Footprint violation: task '%s' wrote '%s', outside its declared "
|
|
88
|
+
"footprint %s. Violations this feature: %d." % (task_id, rel, footprint, total))
|
|
89
|
+
if total >= halt_at:
|
|
90
|
+
lib.deny(msg + " Circuit breaker TRIPPED — all further dispatch is refused. "
|
|
91
|
+
"Escalate to the user with the evidence in state.json / divergence.json.")
|
|
92
|
+
if total >= warn_at:
|
|
93
|
+
lib.warn(msg + " (warning; breaker trips at %d)" % halt_at)
|
|
94
|
+
lib.allow()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""harness_lib.py — tiny shared IO layer for the Loop Engineering hooks.
|
|
2
|
+
|
|
3
|
+
Design rule (Architecture.md §1): agents do judgment, scripts do computation.
|
|
4
|
+
This module is pure computation + IO. It never blocks; callers decide policy
|
|
5
|
+
via exit codes. Everything here fails *open* — a missing config, an unparseable
|
|
6
|
+
state file, or an absent graphify install must never crash a hook and wedge the
|
|
7
|
+
user's session. A crashed hook is worse than an un-enforced one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Config defaults. harness.config.json (repo root) overrides these shallowly.
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
DEFAULT_CONFIG = {
|
|
21
|
+
"graphify": {"binary": "graphify", "index_path": "graphify-out/"},
|
|
22
|
+
"retries": {"mechanical": 2, "contract": 1, "ambiguity": 0, "security": 0},
|
|
23
|
+
"dependency_depth": 1,
|
|
24
|
+
"retrieval_nudge": {
|
|
25
|
+
# "block" -> exit 2 (deny + steer). "warn" -> exit 0 with a note. "off".
|
|
26
|
+
"mode": "block",
|
|
27
|
+
# broad searches over more than this many chars of pattern still pass if
|
|
28
|
+
# they are scoped to a single existing file (see is_broad_search).
|
|
29
|
+
"auto_init": True,
|
|
30
|
+
},
|
|
31
|
+
"observability": {"provider": "langfuse", "enabled": True},
|
|
32
|
+
"gates": {"commit_requires_integration_review": True, "commit_requires_version": True},
|
|
33
|
+
"versioning": {
|
|
34
|
+
"enabled": True,
|
|
35
|
+
"manifests": ["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml"],
|
|
36
|
+
"require_major_dep_ack": True,
|
|
37
|
+
"require_code_bump": True,
|
|
38
|
+
"require_dev_history": True,
|
|
39
|
+
},
|
|
40
|
+
# ---- V1.0 loop layer (DevelopmentUpdates.md) -------------------------
|
|
41
|
+
"budgets": {
|
|
42
|
+
"context_pack": 2000,
|
|
43
|
+
"task_entry": 300,
|
|
44
|
+
"review_findings": 500,
|
|
45
|
+
"per_task_default": 15000,
|
|
46
|
+
"dispatch_prompt_max": 8000,
|
|
47
|
+
},
|
|
48
|
+
"circuit_breaker": {
|
|
49
|
+
"footprint_violations": {"warn": 1, "halt": 2},
|
|
50
|
+
"max_escalations_per_feature": 2,
|
|
51
|
+
"burndown": {"warn_pct": 80, "halt_pct": 100},
|
|
52
|
+
},
|
|
53
|
+
"requirements": {
|
|
54
|
+
"enabled": True,
|
|
55
|
+
"min_acceptance_criteria": 1,
|
|
56
|
+
"require_coverage": True,
|
|
57
|
+
},
|
|
58
|
+
"loop": {
|
|
59
|
+
"queue": "specs/_queue.json",
|
|
60
|
+
"answer_queue": "specs/_answers.json",
|
|
61
|
+
"headless": True,
|
|
62
|
+
"auto_approve": False,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_hook_input():
|
|
68
|
+
"""Parse the JSON object Claude Code writes to a hook's stdin.
|
|
69
|
+
|
|
70
|
+
Returns {} on any error so a malformed payload degrades to pass-through
|
|
71
|
+
rather than a stack trace in the user's terminal.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
raw = sys.stdin.read()
|
|
75
|
+
return json.loads(raw) if raw.strip() else {}
|
|
76
|
+
except Exception:
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def project_dir(hook_input=None):
|
|
81
|
+
"""Resolve the adopter repo root.
|
|
82
|
+
|
|
83
|
+
CLAUDE_PROJECT_DIR is set by Claude Code for every hook; fall back to the
|
|
84
|
+
hook payload's cwd, then the process cwd.
|
|
85
|
+
"""
|
|
86
|
+
env = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
87
|
+
if env:
|
|
88
|
+
return os.path.abspath(env)
|
|
89
|
+
if hook_input and hook_input.get("cwd"):
|
|
90
|
+
return os.path.abspath(hook_input["cwd"])
|
|
91
|
+
return os.path.abspath(os.getcwd())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _deep_merge(base, override):
|
|
95
|
+
out = dict(base)
|
|
96
|
+
for k, v in (override or {}).items():
|
|
97
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
98
|
+
out[k] = _deep_merge(out[k], v)
|
|
99
|
+
else:
|
|
100
|
+
out[k] = v
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_config(root):
|
|
105
|
+
"""Read harness.config.json merged over DEFAULT_CONFIG. Fail-open to defaults."""
|
|
106
|
+
path = os.path.join(root, "harness.config.json")
|
|
107
|
+
try:
|
|
108
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
109
|
+
return _deep_merge(DEFAULT_CONFIG, json.load(fh))
|
|
110
|
+
except Exception:
|
|
111
|
+
return dict(DEFAULT_CONFIG)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Feature / spec artifact chain
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
def specs_root(root):
|
|
118
|
+
return os.path.join(root, "specs")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def current_feature_dir(root):
|
|
122
|
+
"""The active feature = the specs/<f>/ dir with the most recently touched
|
|
123
|
+
state.json (falling back to directory mtime). Returns None if no features.
|
|
124
|
+
|
|
125
|
+
The orchestrator only works one feature at a time; recency is a good-enough
|
|
126
|
+
and fully deterministic-per-filesystem selector without extra bookkeeping.
|
|
127
|
+
"""
|
|
128
|
+
sroot = specs_root(root)
|
|
129
|
+
if not os.path.isdir(sroot):
|
|
130
|
+
return None
|
|
131
|
+
best, best_mtime = None, -1.0
|
|
132
|
+
for name in os.listdir(sroot):
|
|
133
|
+
d = os.path.join(sroot, name)
|
|
134
|
+
if not os.path.isdir(d):
|
|
135
|
+
continue
|
|
136
|
+
marker = os.path.join(d, "state.json")
|
|
137
|
+
try:
|
|
138
|
+
mtime = os.path.getmtime(marker if os.path.exists(marker) else d)
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
if mtime > best_mtime:
|
|
142
|
+
best, best_mtime = d, mtime
|
|
143
|
+
return best
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_json(path, default=None):
|
|
147
|
+
try:
|
|
148
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
149
|
+
return json.load(fh)
|
|
150
|
+
except Exception:
|
|
151
|
+
return default
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_json(path, obj):
|
|
155
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
156
|
+
tmp = path + ".tmp"
|
|
157
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
158
|
+
json.dump(obj, fh, indent=2, ensure_ascii=False)
|
|
159
|
+
os.replace(tmp, path)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Graphify state — the heart of the retrieval nudge
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
def graphify_state(root, config=None):
|
|
166
|
+
"""Return the graphify readiness of this repo.
|
|
167
|
+
|
|
168
|
+
{
|
|
169
|
+
"installed": bool, # binary on PATH or a saved interpreter recorded
|
|
170
|
+
"graph_built": bool, # graphify-out/graph.json exists (queryable now)
|
|
171
|
+
"index_path": str, # resolved absolute index dir
|
|
172
|
+
"graph_json": str, # resolved absolute graph.json path
|
|
173
|
+
}
|
|
174
|
+
Cheap by design: only shutil.which + os.path.exists. No subprocess, so it is
|
|
175
|
+
safe to call on every Grep/Glob PreToolUse without adding latency.
|
|
176
|
+
"""
|
|
177
|
+
config = config or load_config(root)
|
|
178
|
+
binary = config.get("graphify", {}).get("binary", "graphify")
|
|
179
|
+
index_rel = config.get("graphify", {}).get("index_path", "graphify-out/")
|
|
180
|
+
index_path = os.path.join(root, index_rel)
|
|
181
|
+
graph_json = os.path.join(index_path, "graph.json")
|
|
182
|
+
|
|
183
|
+
saved_interp = os.path.join(index_path, ".graphify_python")
|
|
184
|
+
installed = bool(shutil.which(binary)) or os.path.exists(saved_interp)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"installed": installed,
|
|
188
|
+
"graph_built": os.path.exists(graph_json),
|
|
189
|
+
"index_path": index_path,
|
|
190
|
+
"graph_json": graph_json,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def graphify_used_this_session(transcript_path):
|
|
195
|
+
"""Best-effort scan of the session transcript for prior graph-first retrieval.
|
|
196
|
+
|
|
197
|
+
True if any earlier tool call ran `graphify query|path|explain` or read from
|
|
198
|
+
graphify-out/. Used so the nudge fires once and then gets out of the way —
|
|
199
|
+
it steers the *first* broad search of a task, not every subsequent one.
|
|
200
|
+
|
|
201
|
+
Reads at most the tail of the transcript to bound cost.
|
|
202
|
+
"""
|
|
203
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
204
|
+
return False
|
|
205
|
+
needles = ("graphify query", "graphify path", "graphify explain", "graphify-out")
|
|
206
|
+
try:
|
|
207
|
+
# Transcripts are JSONL and can be large; read the last ~512 KB only.
|
|
208
|
+
size = os.path.getsize(transcript_path)
|
|
209
|
+
with open(transcript_path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
210
|
+
if size > 512 * 1024:
|
|
211
|
+
fh.seek(size - 512 * 1024)
|
|
212
|
+
fh.readline() # discard partial line
|
|
213
|
+
blob = fh.read()
|
|
214
|
+
return any(n in blob for n in needles)
|
|
215
|
+
except Exception:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def git_changeset(root):
|
|
220
|
+
"""A stable fingerprint of the current pending diff (staged + unstaged vs HEAD).
|
|
221
|
+
|
|
222
|
+
The commit gate compares this to the `changeset` recorded in the integration
|
|
223
|
+
review verdict — a review only opens the gate for the diff it actually saw.
|
|
224
|
+
The reviewer agent records its changeset with this same helper (see
|
|
225
|
+
scripts/changeset.py) so the two always agree. Returns None outside a repo.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
inside = subprocess.run(
|
|
229
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
230
|
+
cwd=root, capture_output=True, text=True, timeout=15)
|
|
231
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
232
|
+
return None # not a git repo -> no diff to fingerprint
|
|
233
|
+
parts = []
|
|
234
|
+
for args in (["git", "diff", "HEAD"], ["git", "diff", "--cached"]):
|
|
235
|
+
r = subprocess.run(args, cwd=root, capture_output=True, text=True, timeout=15)
|
|
236
|
+
parts.append(r.stdout or "")
|
|
237
|
+
blob = "\n".join(parts)
|
|
238
|
+
return "sha1:" + hashlib.sha1(blob.encode("utf-8", "ignore")).hexdigest()[:16]
|
|
239
|
+
except Exception:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def deny(message):
|
|
244
|
+
"""Emit a PreToolUse block: stderr message + exit code 2.
|
|
245
|
+
|
|
246
|
+
Exit 2 is the Claude Code contract for "deny this tool call and feed stderr
|
|
247
|
+
back to the model" — the message becomes steering, not a user-facing error.
|
|
248
|
+
"""
|
|
249
|
+
sys.stderr.write(message.rstrip() + "\n")
|
|
250
|
+
sys.exit(2)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def warn(message):
|
|
254
|
+
"""Non-blocking note: surfaced to the model, tool still runs (exit 0)."""
|
|
255
|
+
sys.stderr.write(message.rstrip() + "\n")
|
|
256
|
+
sys.exit(0)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def allow():
|
|
260
|
+
sys.exit(0)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""loop_lib.py — shared helpers for the V1.0 loop layer (DevelopmentUpdates.md).
|
|
2
|
+
|
|
3
|
+
Two things live here so the divergence-monitor and the state-updater agree
|
|
4
|
+
byte-for-byte on what "healthy" means:
|
|
5
|
+
|
|
6
|
+
* footprint matching (is this file inside a task's declared footprint?)
|
|
7
|
+
* recompute_health() — derive state.json's `health` from disk signals ONLY.
|
|
8
|
+
|
|
9
|
+
Design principle D-3: divergence is detected mechanically from disk, never by an
|
|
10
|
+
agent's self-assessment. `health` is therefore a pure function of on-disk
|
|
11
|
+
artifacts (review verdicts in state.json + footprint counts in divergence.json +
|
|
12
|
+
the approved budget). Any hook can recompute it and land on the same answer, so
|
|
13
|
+
it does not matter who writes it last — they converge.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
20
|
+
import sys
|
|
21
|
+
sys.path.insert(0, _here)
|
|
22
|
+
import harness_lib as lib # noqa: E402
|
|
23
|
+
|
|
24
|
+
DEFAULT_CAPS = {"mechanical": 2, "contract": 1, "ambiguity": 0, "security": 0}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _norm(p):
|
|
28
|
+
return str(p).replace("\\", "/").strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def in_footprint(rel_path, footprint):
|
|
32
|
+
"""True if rel_path is covered by any footprint entry (dir/, file, or glob).
|
|
33
|
+
Mirrors extract-deps.py matching so the gate and the plan speak one language."""
|
|
34
|
+
rel = _norm(rel_path)
|
|
35
|
+
for entry in footprint or []:
|
|
36
|
+
e = _norm(entry)
|
|
37
|
+
if not e:
|
|
38
|
+
continue
|
|
39
|
+
if e.endswith("/") and rel.startswith(e):
|
|
40
|
+
return True
|
|
41
|
+
if any(c in e for c in "*?[") and fnmatch.fnmatch(rel, e):
|
|
42
|
+
return True
|
|
43
|
+
if rel == e or rel.startswith(e + "/"):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_current(spec_dir):
|
|
49
|
+
"""The task the dispatch-gate marked in flight (or None)."""
|
|
50
|
+
return lib.read_json(os.path.join(spec_dir, ".current.json"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def divergence_path(spec_dir):
|
|
54
|
+
return os.path.join(spec_dir, "divergence.json")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def recompute_health(state, spec_dir, config):
|
|
58
|
+
"""Derive and set state['health'] / state['health_reasons'] from disk signals.
|
|
59
|
+
|
|
60
|
+
Mutates and returns `state`; the CALLER writes state.json (so there is one
|
|
61
|
+
write per hook invocation). Every reason added is a HALT-level condition —
|
|
62
|
+
health flips to 'tripped' iff at least one is present, which is what
|
|
63
|
+
dispatch-gate G3 refuses on. Warn-level signals (e.g. a first footprint
|
|
64
|
+
violation) intentionally do not trip; they wait for the halt threshold.
|
|
65
|
+
"""
|
|
66
|
+
caps = {**DEFAULT_CAPS, **config.get("retries", {})}
|
|
67
|
+
cb = config.get("circuit_breaker", {})
|
|
68
|
+
max_esc = int(cb.get("max_escalations_per_feature", 2))
|
|
69
|
+
halt_fp = int((cb.get("footprint_violations", {}) or {}).get("halt", 2))
|
|
70
|
+
halt_pct = int((cb.get("burndown", {}) or {}).get("halt_pct", 100))
|
|
71
|
+
|
|
72
|
+
reasons = []
|
|
73
|
+
tasks = state.get("tasks", {}) or {}
|
|
74
|
+
|
|
75
|
+
for tid, t in sorted(tasks.items()):
|
|
76
|
+
fc = t.get("pending_failure_class")
|
|
77
|
+
if fc:
|
|
78
|
+
cap = int(caps.get(fc, 0))
|
|
79
|
+
if int(t.get("attempts", 0)) > cap:
|
|
80
|
+
reasons.append("retry_cap:%s(class=%s,attempts=%s,cap=%s)"
|
|
81
|
+
% (tid, fc, t.get("attempts", 0), cap))
|
|
82
|
+
if fc in ("ambiguity", "security"):
|
|
83
|
+
reasons.append("zero_retry_class:%s(%s)" % (tid, fc))
|
|
84
|
+
if t.get("status") == "escalate" or t.get("repeat_finding"):
|
|
85
|
+
reasons.append("repeat_finding:%s" % tid)
|
|
86
|
+
|
|
87
|
+
escalations = sum(1 for t in tasks.values() if t.get("status") == "escalate")
|
|
88
|
+
if escalations > max_esc:
|
|
89
|
+
reasons.append("too_many_escalations:%d>%d (feature likely over-scoped)"
|
|
90
|
+
% (escalations, max_esc))
|
|
91
|
+
|
|
92
|
+
div = lib.read_json(divergence_path(spec_dir), default={}) or {}
|
|
93
|
+
fp_total = sum((div.get("footprint_violations", {}) or {}).values())
|
|
94
|
+
if fp_total >= halt_fp:
|
|
95
|
+
reasons.append("footprint_violations:%d>=halt %d" % (fp_total, halt_fp))
|
|
96
|
+
|
|
97
|
+
# Token burndown vs the approved budget, if both are on disk.
|
|
98
|
+
spent = div.get("tokens_spent")
|
|
99
|
+
approvals = lib.read_json(os.path.join(spec_dir, "approvals.json"), default={}) or {}
|
|
100
|
+
budget = approvals.get("budget")
|
|
101
|
+
if isinstance(spent, (int, float)) and isinstance(budget, (int, float)) and budget > 0:
|
|
102
|
+
pct = 100.0 * spent / budget
|
|
103
|
+
if pct >= halt_pct:
|
|
104
|
+
reasons.append("burndown:%d%%>=halt %d%%" % (int(pct), halt_pct))
|
|
105
|
+
|
|
106
|
+
state["health"] = "tripped" if reasons else "ok"
|
|
107
|
+
state["health_reasons"] = reasons
|
|
108
|
+
return state
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
precompact-guard.py — PreCompact hook.
|
|
4
|
+
|
|
5
|
+
Design intent (Rule 4, DevelopmentUpdates.md): we do NOT fight compaction —
|
|
6
|
+
we make the pipeline indifferent to it. This hook's job is to guarantee that
|
|
7
|
+
the load-bearing minimum survives on DISK before the context window is
|
|
8
|
+
compressed:
|
|
9
|
+
|
|
10
|
+
- current feature (pointer to specs/<f>/)
|
|
11
|
+
- current task id
|
|
12
|
+
- current pipeline phase
|
|
13
|
+
- attempt counters / breaker health (already in state.json)
|
|
14
|
+
|
|
15
|
+
It writes a tiny snapshot file, specs/<f>/pipeline-context.md, that the
|
|
16
|
+
SessionStart(source=compact) rehydration hook injects back into context
|
|
17
|
+
right after compaction. Everything else in the window is allowed to
|
|
18
|
+
compact freely, because it can be re-read from specs/ on demand.
|
|
19
|
+
|
|
20
|
+
PreCompact hooks cannot block compaction — and we don't want to. Exit 0 always.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
REPO = Path.cwd()
|
|
29
|
+
ACTIVE_POINTER = REPO / "specs" / "_active.json" # {"feature": "...", maintained by state-updater}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
try:
|
|
34
|
+
event = json.load(sys.stdin)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
sys.exit(0) # never break compaction
|
|
37
|
+
|
|
38
|
+
if not ACTIVE_POINTER.exists():
|
|
39
|
+
sys.exit(0) # no pipeline in flight — nothing to protect
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
feature = json.loads(ACTIVE_POINTER.read_text(encoding="utf-8")).get("feature")
|
|
43
|
+
except (json.JSONDecodeError, OSError):
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
if not feature:
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
|
|
48
|
+
spec = REPO / "specs" / feature
|
|
49
|
+
state_p = spec / "state.json"
|
|
50
|
+
state = {}
|
|
51
|
+
if state_p.exists():
|
|
52
|
+
try:
|
|
53
|
+
state = json.loads(state_p.read_text(encoding="utf-8"))
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
state = {}
|
|
56
|
+
|
|
57
|
+
current_task = state.get("current_task", "unknown")
|
|
58
|
+
phase = state.get("phase", "unknown")
|
|
59
|
+
health = state.get("health", "ok")
|
|
60
|
+
|
|
61
|
+
snapshot = f"""# PIPELINE CONTEXT SNAPSHOT (load-bearing minimum)
|
|
62
|
+
_Written by precompact-guard at {datetime.now(timezone.utc).isoformat()} \
|
|
63
|
+
(trigger: {event.get('trigger', 'unknown')})_
|
|
64
|
+
|
|
65
|
+
- **FEATURE:** {feature}
|
|
66
|
+
- **ARTIFACTS:** specs/{feature}/ <- re-read plan.md, task-graph.json,
|
|
67
|
+
state.json, contracts/ from here instead of relying on compacted memory.
|
|
68
|
+
- **CURRENT TASK:** {current_task}
|
|
69
|
+
- **PIPELINE PHASE:** {phase}
|
|
70
|
+
- **BREAKER HEALTH:** {health}
|
|
71
|
+
|
|
72
|
+
## Standing orders after compaction
|
|
73
|
+
1. Treat this snapshot + specs/{feature}/ as the ONLY source of truth.
|
|
74
|
+
Do not trust compacted recollections of plan details or review findings.
|
|
75
|
+
2. Resume by re-reading state.json, then continue dispatching per
|
|
76
|
+
task-graph.json. dispatch-gate.py will enforce order, approval hash,
|
|
77
|
+
retry caps, and breaker state regardless.
|
|
78
|
+
3. Never read source code or raw diffs into this (orchestrator) context —
|
|
79
|
+
that work belongs to subagents (compression funnel, Rule 2).
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
spec.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
(spec / "pipeline-context.md").write_text(snapshot, encoding="utf-8")
|
|
84
|
+
except OSError as e:
|
|
85
|
+
print(f"[precompact-guard] snapshot write failed: {e}", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
sys.exit(0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|