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