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,148 @@
1
+ """req_lib.py — the requirements/intention quality checker (the extended human gate).
2
+
3
+ Wrong implementations are born at the requirements and decomposition stages, not
4
+ at code. This module detects — mechanically, from disk, never by asking an agent
5
+ to grade itself (design principle D-3) — the four conditions that must summon a
6
+ human before automation proceeds:
7
+
8
+ R1 ambiguous / unorganized intention — Intent & Scope present, no unresolved
9
+ markers (TBD / ??? / "clarify" / …)
10
+ R2 vague / unstructured requirements — required sections present, and >= N
11
+ structured acceptance criteria (AC1, AC2…)
12
+ R3 wrong flow — phase artifacts exist in order
13
+ (discovery -> context-pack -> plan -> graph)
14
+ R4 diverted from user requirements — every acceptance criterion is covered
15
+ by a task; no task cites an AC that is
16
+ not in discovery.md (scope creep / drift)
17
+
18
+ Each check returns a list of human-readable problem strings; empty == clean.
19
+ """
20
+
21
+ import os
22
+ import re
23
+
24
+ REQUIRED_SECTIONS = ("intent", "edge case", "non-functional", "acceptance criteria")
25
+
26
+ # Markers that mean the intention is not actually resolved yet.
27
+ UNCERTAINTY = (
28
+ r"\bTBD\b", r"\bTODO\b", r"\bXXX\b", r"\?\?\?",
29
+ "to be decided", "to be determined", "not sure", "unclear",
30
+ "figure out later", "clarify with", "decide later", "revisit later",
31
+ )
32
+
33
+ AC_RE = re.compile(r"\bAC\d+\b")
34
+ TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)```", re.S)
35
+
36
+
37
+ def _read(path):
38
+ try:
39
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
40
+ return fh.read()
41
+ except OSError:
42
+ return None
43
+
44
+
45
+ def _headers(text):
46
+ return "\n".join(l.strip().lower() for l in text.splitlines() if l.lstrip().startswith("#"))
47
+
48
+
49
+ def acceptance_ids(spec_dir):
50
+ text = _read(os.path.join(spec_dir, "discovery.md")) or ""
51
+ return set(AC_RE.findall(text))
52
+
53
+
54
+ def _satisfies(block):
55
+ m = re.search(r"^\s*satisfies:\s*(.*)$", block, re.M)
56
+ ids = set()
57
+ if m:
58
+ ids |= set(AC_RE.findall(m.group(1)))
59
+ # also allow a dash-list under `satisfies:`
60
+ for line in block.splitlines():
61
+ if re.match(r"^\s*-\s*AC\d+", line):
62
+ ids |= set(AC_RE.findall(line))
63
+ return ids
64
+
65
+
66
+ def plan_task_coverage(spec_dir):
67
+ """Union of AC ids referenced across all plan.md task blocks."""
68
+ text = _read(os.path.join(spec_dir, "plan.md")) or ""
69
+ covered = set()
70
+ for block in TASK_BLOCK_RE.findall(text):
71
+ covered |= _satisfies(block)
72
+ return covered
73
+
74
+
75
+ def check_discovery(spec_dir, cfg=None):
76
+ cfg = cfg or {}
77
+ disc = os.path.join(spec_dir, "discovery.md")
78
+ text = _read(disc)
79
+ if text is None:
80
+ return ["R1: discovery.md is missing — run the interview before any pipeline work."]
81
+
82
+ problems = []
83
+ heads = _headers(text)
84
+ for sec in REQUIRED_SECTIONS:
85
+ if sec not in heads:
86
+ problems.append("R2: discovery.md has no '%s' section — requirements are unstructured." % sec)
87
+
88
+ min_ac = int(cfg.get("min_acceptance_criteria", 1))
89
+ n_ac = len(set(AC_RE.findall(text)))
90
+ if n_ac < min_ac:
91
+ problems.append(
92
+ "R2: fewer than %d structured acceptance criteria (label them AC1, AC2, …) "
93
+ "in discovery.md — vague success criteria produce ambiguity failures downstream." % min_ac)
94
+
95
+ for pat in UNCERTAINTY:
96
+ if re.search(pat, text, re.I):
97
+ problems.append(
98
+ "R1: unresolved marker matching '%s' in discovery.md — the intention is still "
99
+ "ambiguous. Resolve it with the user, don't let automation guess." % pat)
100
+ break
101
+ return problems
102
+
103
+
104
+ def check_flow(spec_dir, is_specialist):
105
+ """R3 — phases must have run in order before an implementation dispatch."""
106
+ if not is_specialist:
107
+ return []
108
+ problems = []
109
+ for fname, phase in (("context-pack.md", "context research"),
110
+ ("plan.md", "planning"),
111
+ ("task-graph.json", "scheduling")):
112
+ if not os.path.exists(os.path.join(spec_dir, fname)):
113
+ problems.append(
114
+ "R3: wrong flow — dispatching implementation but %s (%s phase) is missing. "
115
+ "Phases run discovery -> context -> plan -> schedule -> implement, in order."
116
+ % (fname, phase))
117
+ return problems
118
+
119
+
120
+ def check_coverage(spec_dir, cfg=None):
121
+ """R4 — plan must cover exactly the acceptance criteria: no gaps, no creep."""
122
+ cfg = cfg or {}
123
+ if not cfg.get("require_coverage", True):
124
+ return []
125
+ acs = acceptance_ids(spec_dir)
126
+ if not acs:
127
+ return [] # missing ACs already reported by check_discovery
128
+ covered = plan_task_coverage(spec_dir)
129
+ problems = []
130
+ uncovered = acs - covered
131
+ if uncovered:
132
+ problems.append(
133
+ "R4: acceptance criteria not covered by any task: %s. The plan under-delivers "
134
+ "or has diverged from what the user asked for." % ", ".join(sorted(uncovered)))
135
+ unknown = covered - acs
136
+ if unknown:
137
+ problems.append(
138
+ "R4: tasks claim acceptance criteria absent from discovery.md: %s. This is scope "
139
+ "creep / diversion — the plan is building something not requested." % ", ".join(sorted(unknown)))
140
+ return problems
141
+
142
+
143
+ def check_all(spec_dir, is_specialist, cfg=None):
144
+ problems = check_discovery(spec_dir, cfg)
145
+ problems += check_flow(spec_dir, is_specialist)
146
+ if is_specialist or os.path.exists(os.path.join(spec_dir, "plan.md")):
147
+ problems += check_coverage(spec_dir, cfg)
148
+ return problems
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """requirements-gate.py — PreToolUse hook on Task (the EXTENDED human gate).
3
+
4
+ Runs alongside dispatch-gate. Where dispatch-gate enforces the *schedule*
5
+ (approval hash, order, caps, breaker), this gate enforces *intention and
6
+ requirement quality* — it refuses to let automation proceed on ambiguous
7
+ intentions, vague/unstructured requirements, a wrong flow, or a plan that has
8
+ diverted from what the user asked for. On a block, the only legal move is to
9
+ re-enter interview mode with the user: these conditions route to a human by
10
+ design, because retrying them just manufactures confident wrong guesses.
11
+
12
+ Gated when the dispatch prompt carries `FEATURE: <slug>`:
13
+ * any pipeline dispatch -> R1 (intent), R2 (structured requirements)
14
+ * a specialist (TASK-ID set) -> also R3 (flow order), R4 (requirement coverage)
15
+
16
+ Non-pipeline Task use (no FEATURE header) passes through untouched.
17
+ """
18
+ import os
19
+ import re
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 req_lib as req # noqa: E402
25
+
26
+
27
+ def main():
28
+ event = lib.read_hook_input()
29
+ if event.get("tool_name") != "Task":
30
+ lib.allow()
31
+
32
+ prompt = (event.get("tool_input", {}) or {}).get("prompt", "") or ""
33
+ m_feat = re.search(r"^FEATURE:\s*(\S+)", prompt, re.MULTILINE)
34
+ if not m_feat:
35
+ lib.allow() # not a governed pipeline dispatch
36
+
37
+ root = lib.project_dir(event)
38
+ cfg = lib.load_config(root).get("requirements", {})
39
+ if cfg.get("enabled", True) is False:
40
+ lib.allow()
41
+
42
+ feature = m_feat.group(1)
43
+ spec_dir = os.path.join(root, "specs", feature)
44
+ is_specialist = bool(re.search(r"^TASK-ID:\s*\S+", prompt, re.MULTILINE))
45
+
46
+ problems = req.check_all(spec_dir, is_specialist, cfg)
47
+ if not problems:
48
+ lib.allow()
49
+
50
+ lib.deny(
51
+ "Human gate — requirements/intention not ready (Loop Engineering):\n"
52
+ " - " + "\n - ".join(problems) + "\n\n"
53
+ "These route to the USER, not a retry. Re-enter interview mode "
54
+ "(discovery-interview skill): resolve the ambiguity, structure the "
55
+ "acceptance criteria (AC1, AC2, …), and make the plan cover exactly what "
56
+ "was asked. Update discovery.md / plan.md, then re-approve. Do not dispatch "
57
+ "around this — a wrong requirement built perfectly is still wrong."
58
+ )
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python
2
+ """retrieval-nudge.py — PreToolUse hook (matcher: Grep|Glob)
3
+
4
+ Encodes the harness retrieval order: **Graphify -> skills -> memory**
5
+ (Architecture.md §6). A broad, exploratory file scan is redirected to a
6
+ dependency-aware `graphify query` first. Narrow, file-scoped searches pass
7
+ straight through — the nudge steers discovery, it does not police every grep.
8
+
9
+ Graphify-availability routing (the "search for context with graphify, and if it
10
+ isn't available, auto-init then use the graphify skill" flow):
11
+
12
+ * graph built -> steer to `graphify query "<derived question>"`
13
+ * installed, no graph -> instruct: run `/graphify .` to build the graph,
14
+ then query it (auto-init / setup)
15
+ * not installed -> instruct: run `/graphify .` — the skill self-installs
16
+ via uv/pip on first run, then builds the graph
17
+
18
+ Loop-safety: the hook blocks a given session at most once. It drops a marker on
19
+ first fire; any subsequent broad search in the same session passes through. So
20
+ the worst case is a single steering message — a subagent can never get wedged.
21
+ """
22
+
23
+ import os
24
+ import sys
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ import harness_lib as lib # noqa: E402
28
+
29
+
30
+ def _abs(root, p):
31
+ return p if os.path.isabs(p) else os.path.join(root, p)
32
+
33
+
34
+ def is_satisfied_or_already_nudged(root, hook_input):
35
+ """Graph-first retrieval is satisfied if the session already ran a graphify
36
+ query, OR we already nudged this session (one-shot — never trap a loop)."""
37
+ if lib.graphify_used_this_session(hook_input.get("transcript_path")):
38
+ return True
39
+ return _nudge_marker(root, hook_input, write=False)
40
+
41
+
42
+ def _nudge_marker(root, hook_input, write):
43
+ session = hook_input.get("session_id") or "default"
44
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(session))
45
+ marker = os.path.join(root, ".claude", ".harness", "nudged-" + safe + ".flag")
46
+ if write:
47
+ try:
48
+ os.makedirs(os.path.dirname(marker), exist_ok=True)
49
+ with open(marker, "w", encoding="utf-8") as fh:
50
+ fh.write("1")
51
+ except Exception:
52
+ pass
53
+ return True
54
+ return os.path.exists(marker)
55
+
56
+
57
+ def is_broad_search(root, tool_name, ti):
58
+ """True when the search is repo-wide exploration rather than a localized look.
59
+
60
+ Deterministic and scope-based (not pattern-guessing): a search is *narrow*
61
+ (passes through) when it is confined to a specific file, a specific
62
+ subdirectory, or a literal filename glob. Everything else is broad.
63
+ """
64
+ path = ti.get("path") or ""
65
+ # file-scoped -> always narrow
66
+ if path and os.path.isfile(_abs(root, path)):
67
+ return False
68
+ # explicit subdirectory (anything but repo root / cwd) -> narrow
69
+ if path and path not in (".", "./", "") and os.path.abspath(_abs(root, path)) != os.path.abspath(root):
70
+ return False
71
+
72
+ if tool_name == "Grep":
73
+ glob = ti.get("glob") or ""
74
+ # a literal filename glob (no wildcard) is a targeted lookup
75
+ if glob and "*" not in glob:
76
+ return False
77
+ return True
78
+
79
+ if tool_name == "Glob":
80
+ pattern = ti.get("pattern") or ""
81
+ # concrete directory prefix before the first wildcard -> targeted
82
+ head = pattern.split("*", 1)[0]
83
+ prefix = os.path.dirname(head)
84
+ if prefix and os.path.isdir(_abs(root, prefix)):
85
+ return False
86
+ return True
87
+
88
+ return False
89
+
90
+
91
+ def derived_question(ti):
92
+ term = (ti.get("pattern") or ti.get("glob") or "").strip()
93
+ if not term or term in ("**", "**/*", "*"):
94
+ return "what are the main modules here and how do they depend on each other"
95
+ return 'where is `%s` defined and used, and what depends on it' % term
96
+
97
+
98
+ def compose(root, tool_name, ti, gstate, auto_init):
99
+ q = derived_question(ti)
100
+ tail = ("\nThis is a one-time nudge for this session — if the graph can't answer, "
101
+ "just re-run your %s and it will pass through." % tool_name)
102
+
103
+ if gstate["graph_built"]:
104
+ return ("Graph-first retrieval (Loop Engineering).\n"
105
+ "A knowledge graph already exists at %s. Query it before a raw %s — "
106
+ "it returns dependency-aware context (importers, shared dependents) "
107
+ "that a text scan cannot:\n\n"
108
+ ' graphify query "%s"\n' % (gstate["graph_json"], tool_name, q)) + tail
109
+
110
+ if gstate["installed"]:
111
+ return ("Graph-first retrieval (Loop Engineering).\n"
112
+ "Graphify is installed but no graph has been built for this repo yet. "
113
+ "Initialize it before broad file scanning:\n\n"
114
+ " /graphify . # build the knowledge graph (one-time setup)\n"
115
+ ' graphify query "%s"\n' % q) + tail
116
+
117
+ # not installed -> route to the self-installing skill
118
+ if auto_init:
119
+ return ("Graph-first retrieval (Loop Engineering).\n"
120
+ "Graphify is not available in this project. Auto-initialize it before "
121
+ "broad file scanning — the /graphify skill self-installs (uv/pip) on "
122
+ "first run, then builds the graph:\n\n"
123
+ " /graphify . # install + build the knowledge graph\n"
124
+ ' graphify query "%s"\n' % q) + tail
125
+ return ("Graph-first retrieval (Loop Engineering): graphify is not installed. "
126
+ "Consider `/graphify .` to enable dependency-aware context.") + tail
127
+
128
+
129
+ def main():
130
+ hook_input = lib.read_hook_input()
131
+ tool_name = hook_input.get("tool_name", "")
132
+ if tool_name not in ("Grep", "Glob"):
133
+ lib.allow()
134
+
135
+ root = lib.project_dir(hook_input)
136
+ config = lib.load_config(root)
137
+ nudge_cfg = config.get("retrieval_nudge", {})
138
+ mode = nudge_cfg.get("mode", "block")
139
+ if mode == "off":
140
+ lib.allow()
141
+
142
+ ti = hook_input.get("tool_input", {}) or {}
143
+ if not is_broad_search(root, tool_name, ti):
144
+ lib.allow()
145
+ if is_satisfied_or_already_nudged(root, hook_input):
146
+ lib.allow()
147
+
148
+ gstate = lib.graphify_state(root, config)
149
+ message = compose(root, tool_name, ti, gstate, nudge_cfg.get("auto_init", True))
150
+
151
+ # Record the one-shot marker BEFORE emitting, so a retry always progresses.
152
+ _nudge_marker(root, hook_input, write=True)
153
+
154
+ if mode == "warn":
155
+ lib.warn(message)
156
+ lib.deny(message)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ session-rehydrate.py — SessionStart hook (matchers: "compact", "resume").
4
+
5
+ The second half of the compaction-indifference design. precompact-guard.py
6
+ snapshots the load-bearing minimum to disk; this hook injects it back into
7
+ the fresh/compacted context. Stdout from a SessionStart hook is added to
8
+ Claude's context, which is exactly the channel we want.
9
+
10
+ Also serves the loop layer (DevelopmentUpdates.md §4): when the outer driver
11
+ resumes a parked feature in a brand-new session, this same hook rehydrates
12
+ it from artifacts — resume-from-artifacts and recover-from-compaction are
13
+ deliberately the same mechanism.
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ REPO = Path.cwd()
21
+ ACTIVE_POINTER = REPO / "specs" / "_active.json"
22
+
23
+
24
+ def main() -> None:
25
+ # Snapshot content may contain non-ASCII; force UTF-8 stdout so injection
26
+ # never dies on a Windows cp1252 console.
27
+ try:
28
+ sys.stdout.reconfigure(encoding="utf-8")
29
+ except Exception:
30
+ pass
31
+
32
+ try:
33
+ event = json.load(sys.stdin)
34
+ except json.JSONDecodeError:
35
+ sys.exit(0)
36
+
37
+ if event.get("source") not in ("compact", "resume"):
38
+ sys.exit(0) # fresh interactive sessions start clean on purpose
39
+
40
+ if not ACTIVE_POINTER.exists():
41
+ sys.exit(0)
42
+
43
+ try:
44
+ feature = json.loads(ACTIVE_POINTER.read_text(encoding="utf-8")).get("feature")
45
+ except (json.JSONDecodeError, OSError):
46
+ sys.exit(0)
47
+ if not feature:
48
+ sys.exit(0)
49
+
50
+ snap = REPO / "specs" / feature / "pipeline-context.md"
51
+ if snap.exists():
52
+ # stdout -> injected into context
53
+ print(snap.read_text(encoding="utf-8"))
54
+ else:
55
+ print(
56
+ f"# PIPELINE REHYDRATION\n"
57
+ f"Active feature: {feature}. No snapshot found — rehydrate manually by "
58
+ f"reading specs/{feature}/state.json, plan.md, and task-graph.json, "
59
+ f"then continue per task-graph order."
60
+ )
61
+ sys.exit(0)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python
2
+ """state-updater.py — SubagentStop + PostToolUse(Task) hook
3
+
4
+ The single writer of specs/<f>/state.json (Architecture.md §6). Agents never
5
+ edit state; this hook reconciles it from the review verdicts on disk each time a
6
+ subagent finishes. Reconciler pattern — it does not need to know which subagent
7
+ just ran; it re-derives the whole state from the review.*.json files + the task
8
+ graph. Idempotent, and always exits 0 (it updates, it never gates).
9
+
10
+ Per-task status: pending -> needs_retry -> done (or -> escalate)
11
+ Counters live here, on disk, so context compaction can never turn a finite retry
12
+ loop into an infinite one.
13
+ """
14
+
15
+ import glob
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import harness_lib as lib # noqa: E402
22
+ import loop_lib as loop # noqa: E402
23
+
24
+ REVIEW_RE = re.compile(r"review\.(?P<task>.+)\.(?P<n>\d+)\.json$")
25
+
26
+
27
+ def reviews_by_task(feature_dir):
28
+ out = {}
29
+ for path in glob.glob(os.path.join(feature_dir, "review.*.json")):
30
+ m = REVIEW_RE.search(os.path.basename(path))
31
+ if not m:
32
+ continue
33
+ out.setdefault(m.group("task"), []).append((int(m.group("n")), path))
34
+ for task in out:
35
+ out[task].sort(key=lambda x: x[0])
36
+ return out
37
+
38
+
39
+ def finding_keys(review):
40
+ keys = set()
41
+ for f in review.get("findings", []) or []:
42
+ keys.add((f.get("file", ""), f.get("detail", "") or f.get("id", "")))
43
+ return keys
44
+
45
+
46
+ def main():
47
+ hook_input = lib.read_hook_input()
48
+ root = lib.project_dir(hook_input)
49
+ feature_dir = lib.current_feature_dir(root)
50
+ if not feature_dir:
51
+ lib.allow()
52
+
53
+ state_path = os.path.join(feature_dir, "state.json")
54
+ state = lib.read_json(state_path, default={}) or {}
55
+ state.setdefault("feature", os.path.basename(feature_dir))
56
+ state.setdefault("tasks", {})
57
+
58
+ graph = lib.read_json(os.path.join(feature_dir, "task-graph.json"), default={}) or {}
59
+ for t in graph.get("tasks", []):
60
+ state["tasks"].setdefault(t["id"], {"status": "pending", "attempts": 0})
61
+
62
+ by_task = reviews_by_task(feature_dir)
63
+ for task, entries in by_task.items():
64
+ latest_n, latest_path = entries[-1]
65
+ latest = lib.read_json(latest_path, default={}) or {}
66
+ ts = state["tasks"].setdefault(task, {"status": "pending", "attempts": 0})
67
+ ts["attempts"] = latest_n
68
+ ts["last_review"] = os.path.basename(latest_path)
69
+
70
+ if latest.get("status") == "pass":
71
+ ts["status"] = "done"
72
+ ts.pop("pending_failure_class", None)
73
+ continue
74
+
75
+ ts["status"] = "needs_retry"
76
+ ts["pending_failure_class"] = latest.get("failure_class", "mechanical")
77
+
78
+ # Repeated-finding early stop: reviewer flag, or identical findings twice.
79
+ repeat = bool(latest.get("repeat_finding"))
80
+ if not repeat and len(entries) >= 2:
81
+ prev = lib.read_json(entries[-2][1], default={}) or {}
82
+ repeat = bool(finding_keys(latest) & finding_keys(prev))
83
+ if repeat:
84
+ ts["status"] = "escalate"
85
+ ts["repeat_finding"] = True
86
+
87
+ # Mirror the in-flight task (set by dispatch-gate's .current.json) into state
88
+ # so the PreCompact snapshot captures it as part of the load-bearing minimum.
89
+ cur = loop.read_current(feature_dir)
90
+ if cur and cur.get("task_id"):
91
+ state["current_task"] = cur["task_id"]
92
+
93
+ # Derive the pipeline phase (part of Rule 4's load-bearing minimum). Purely a
94
+ # function of which artifacts exist — the snapshot must never guess.
95
+ tvals = state["tasks"].values()
96
+ if not graph.get("tasks"):
97
+ state["phase"] = "planning"
98
+ elif (not state["tasks"]) or any(t.get("status") != "done" for t in tvals):
99
+ state["phase"] = "execution"
100
+ else:
101
+ integ = glob.glob(os.path.join(feature_dir, "review.integration.*.json"))
102
+ passed = any((lib.read_json(p, default={}) or {}).get("status") == "pass" for p in integ)
103
+ state["phase"] = "committed" if passed else "integration"
104
+
105
+ # Derive circuit-breaker health from the disk signals just reconciled
106
+ # (retry caps, repeat findings, escalation count, footprint violations).
107
+ loop.recompute_health(state, feature_dir, lib.load_config(root))
108
+
109
+ lib.write_json(state_path, state)
110
+
111
+ # Maintain specs/_active.json — the pointer precompact-guard.py and
112
+ # session-rehydrate.py read to find the feature to snapshot / rehydrate.
113
+ lib.write_json(os.path.join(lib.specs_root(root), "_active.json"),
114
+ {"feature": os.path.basename(feature_dir)})
115
+ lib.allow()
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python
2
+ """trace-emitter.py — registered on ALL lifecycle events
3
+
4
+ Maps hook events to observability spans/scores (Architecture.md §8):
5
+ session_id -> Langfuse trace id
6
+ PreToolUse(Task)/SubagentStop -> subagent span open/close (no SubagentStart
7
+ exists, so open is approximated from dispatch)
8
+ tool calls -> child events
9
+ review verdicts -> Langfuse scores; failed reviews -> dataset flag
10
+
11
+ Fail-open, always. Observability must never become an availability dependency:
12
+ every event is appended to .claude/traces/<session>.jsonl locally, and Langfuse
13
+ delivery is a best-effort urllib POST wrapped in try/except with a short timeout.
14
+ If Langfuse is unreachable, the local trace is the source of truth. Exit 0 no
15
+ matter what — a telemetry failure must never block the pipeline.
16
+ """
17
+
18
+ import base64
19
+ import glob
20
+ import json
21
+ import os
22
+ import sys
23
+ import urllib.request
24
+ from datetime import datetime, timezone
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ import harness_lib as lib # noqa: E402
28
+
29
+
30
+ def now_iso():
31
+ return datetime.now(timezone.utc).isoformat()
32
+
33
+
34
+ def local_log(root, session, record):
35
+ try:
36
+ d = os.path.join(root, ".claude", "traces")
37
+ os.makedirs(d, exist_ok=True)
38
+ with open(os.path.join(d, (session or "session") + ".jsonl"), "a", encoding="utf-8") as fh:
39
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
40
+ except Exception:
41
+ pass
42
+
43
+
44
+ def latest_review(feature_dir):
45
+ files = glob.glob(os.path.join(feature_dir, "review.*.json")) if feature_dir else []
46
+ if not files:
47
+ return None
48
+ files.sort(key=os.path.getmtime)
49
+ return lib.read_json(files[-1])
50
+
51
+
52
+ def langfuse_post(host, public, secret, batch):
53
+ body = json.dumps({"batch": batch}).encode("utf-8")
54
+ auth = base64.b64encode(("%s:%s" % (public, secret)).encode()).decode()
55
+ req = urllib.request.Request(
56
+ host.rstrip("/") + "/api/public/ingestion",
57
+ data=body,
58
+ headers={"Content-Type": "application/json", "Authorization": "Basic " + auth},
59
+ method="POST",
60
+ )
61
+ urllib.request.urlopen(req, timeout=3).read()
62
+
63
+
64
+ def env_ref(value):
65
+ """Config values may be `env:VAR` references (never literal secrets on disk)."""
66
+ if isinstance(value, str) and value.startswith("env:"):
67
+ return os.environ.get(value[4:], "")
68
+ return value or ""
69
+
70
+
71
+ def main():
72
+ hook_input = lib.read_hook_input()
73
+ root = lib.project_dir(hook_input)
74
+ session = hook_input.get("session_id") or "session"
75
+ event = hook_input.get("hook_event_name", "unknown")
76
+ tool = hook_input.get("tool_name", "")
77
+
78
+ record = {"ts": now_iso(), "event": event, "tool": tool, "session": session}
79
+ feature_dir = lib.current_feature_dir(root)
80
+ if feature_dir:
81
+ record["feature"] = os.path.basename(feature_dir)
82
+
83
+ review = latest_review(feature_dir) if event == "SubagentStop" else None
84
+ if review:
85
+ record["review"] = {
86
+ "task": review.get("task"),
87
+ "status": review.get("status"),
88
+ "failure_class": review.get("failure_class"),
89
+ }
90
+
91
+ local_log(root, session, record)
92
+
93
+ config = lib.load_config(root)
94
+ obs = config.get("observability", {})
95
+ if not obs.get("enabled", True) or obs.get("provider") != "langfuse":
96
+ lib.allow()
97
+
98
+ host = env_ref(obs.get("host")) or os.environ.get("LANGFUSE_HOST", "")
99
+ public = env_ref(obs.get("public_key")) or os.environ.get("LANGFUSE_PUBLIC_KEY", "")
100
+ secret = env_ref(obs.get("secret_key")) or os.environ.get("LANGFUSE_SECRET_KEY", "")
101
+ if not (host and public and secret):
102
+ lib.allow() # unconfigured -> local trace only, silently
103
+
104
+ batch = [
105
+ {"type": "trace-create", "id": session, "timestamp": record["ts"],
106
+ "body": {"id": session, "name": record.get("feature", "loop-engineering")}},
107
+ {"type": "event-create", "id": session + ":" + record["ts"], "timestamp": record["ts"],
108
+ "body": {"traceId": session, "name": event, "metadata": record}},
109
+ ]
110
+ if review and review.get("status"):
111
+ batch.append({
112
+ "type": "score-create", "id": session + ":score:" + record["ts"],
113
+ "timestamp": record["ts"],
114
+ "body": {"traceId": session, "name": "review",
115
+ "value": 1 if review.get("status") == "pass" else 0,
116
+ "comment": review.get("failure_class") or ""},
117
+ })
118
+
119
+ try:
120
+ langfuse_post(host, public, secret, batch)
121
+ except Exception:
122
+ pass # fail-open: local trace already written
123
+
124
+ lib.allow()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()