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