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,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""version-gate.py — PreToolUse hook on Bash (the Version Controller's commit gate).
|
|
3
|
+
|
|
4
|
+
Runs beside commit-gate.py. Where commit-gate enforces *review*, this enforces
|
|
5
|
+
*versioning*: a feature does not commit until all three responsibilities are
|
|
6
|
+
satisfied for it. Policy in an exit code, not a checklist someone remembers.
|
|
7
|
+
|
|
8
|
+
Blocks `git commit` / `gh pr create` when, for the active feature:
|
|
9
|
+
V1 (dependency) a MAJOR dependency bump is unacknowledged
|
|
10
|
+
V2 (code) no semver bump / CHANGELOG entry recorded for the feature
|
|
11
|
+
V3 (development) no development-version history captured
|
|
12
|
+
|
|
13
|
+
Each sub-check is individually toggleable in harness.config.json → versioning;
|
|
14
|
+
the whole gate is bypassable via gates.commit_requires_version:false (a logged,
|
|
15
|
+
branch-scoped decision). Non-feature commits pass through untouched.
|
|
16
|
+
"""
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
import harness_lib as lib # noqa: E402
|
|
23
|
+
import version_lib as ver # noqa: E402
|
|
24
|
+
|
|
25
|
+
COMMIT_RE = re.compile(r"\bgit\s+commit\b|\bgh\s+pr\s+create\b")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
event = lib.read_hook_input()
|
|
30
|
+
if event.get("tool_name") != "Bash":
|
|
31
|
+
lib.allow()
|
|
32
|
+
if not COMMIT_RE.search((event.get("tool_input", {}) or {}).get("command", "")):
|
|
33
|
+
lib.allow()
|
|
34
|
+
|
|
35
|
+
root = lib.project_dir(event)
|
|
36
|
+
config = lib.load_config(root)
|
|
37
|
+
if not config.get("gates", {}).get("commit_requires_version", True):
|
|
38
|
+
lib.allow()
|
|
39
|
+
vcfg = config.get("versioning", {})
|
|
40
|
+
if vcfg.get("enabled", True) is False:
|
|
41
|
+
lib.allow()
|
|
42
|
+
|
|
43
|
+
feature_dir = lib.current_feature_dir(root)
|
|
44
|
+
if not feature_dir:
|
|
45
|
+
lib.allow()
|
|
46
|
+
feature = os.path.basename(feature_dir)
|
|
47
|
+
|
|
48
|
+
# V1 — dependency: major bumps acknowledged
|
|
49
|
+
if vcfg.get("require_major_dep_ack", True):
|
|
50
|
+
deps = lib.read_json(os.path.join(feature_dir, "dependencies.json"), default={}) or {}
|
|
51
|
+
if deps.get("has_major") and not deps.get("acknowledged"):
|
|
52
|
+
majors = [c["name"] for c in deps.get("changes", []) if c.get("severity") == "major"]
|
|
53
|
+
lib.deny(
|
|
54
|
+
"Commit blocked (version): unacknowledged MAJOR dependency change in '%s' "
|
|
55
|
+
"(%s). Review the bump, then: python .claude/scripts/version.py ack-deps %s"
|
|
56
|
+
% (feature, ", ".join(majors), feature))
|
|
57
|
+
|
|
58
|
+
# V2 — code: semver bump + changelog for this feature
|
|
59
|
+
if vcfg.get("require_code_bump", True):
|
|
60
|
+
vj = lib.read_json(os.path.join(feature_dir, "version.json"), default={}) or {}
|
|
61
|
+
code_version = vj.get("code_version")
|
|
62
|
+
changelog = os.path.join(root, "CHANGELOG.md")
|
|
63
|
+
logged = False
|
|
64
|
+
if code_version and os.path.exists(changelog):
|
|
65
|
+
try:
|
|
66
|
+
logged = code_version in open(changelog, "r", encoding="utf-8", errors="ignore").read()
|
|
67
|
+
except OSError:
|
|
68
|
+
logged = False
|
|
69
|
+
if not (code_version and logged):
|
|
70
|
+
lib.deny(
|
|
71
|
+
"Commit blocked (version): feature '%s' has no recorded code version + "
|
|
72
|
+
"CHANGELOG entry. Bump it: python .claude/scripts/version.py bump %s" % (feature, feature))
|
|
73
|
+
|
|
74
|
+
# V3 — development: version history captured
|
|
75
|
+
if vcfg.get("require_dev_history", True):
|
|
76
|
+
hist = lib.read_json(os.path.join(feature_dir, "history.json"), default={}) or {}
|
|
77
|
+
if not hist.get("entries"):
|
|
78
|
+
lib.deny(
|
|
79
|
+
"Commit blocked (version): no development-version history for '%s'. "
|
|
80
|
+
"Snapshot it: python .claude/scripts/version.py snapshot %s integrated" % (feature, feature))
|
|
81
|
+
|
|
82
|
+
lib.allow()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""version-tracker.py — PostToolUse hook on Edit|Write (dependency versioning).
|
|
3
|
+
|
|
4
|
+
When a specialist edits a dependency manifest (package.json, requirements.txt,
|
|
5
|
+
pyproject.toml, go.mod, Cargo.toml), recompute the change set for the active
|
|
6
|
+
feature against its baseline (captured at approval) and record it in
|
|
7
|
+
specs/<f>/dependencies.json. This is the producer; version-gate.py enforces that
|
|
8
|
+
a major bump is acknowledged before commit.
|
|
9
|
+
|
|
10
|
+
Records only — always exits 0 (a first out-of-footprint concern is the
|
|
11
|
+
divergence-monitor's job; this one just keeps the dependency ledger current). If
|
|
12
|
+
no baseline exists yet (feature not approved, or non-pipeline edit), it no-ops:
|
|
13
|
+
the baseline must be the pre-feature state, so it is captured at approval, never
|
|
14
|
+
guessed here.
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
import harness_lib as lib # noqa: E402
|
|
21
|
+
import version_lib as ver # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
event = lib.read_hook_input()
|
|
26
|
+
if event.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
|
|
27
|
+
lib.allow()
|
|
28
|
+
|
|
29
|
+
fpath = (event.get("tool_input", {}) or {}).get("file_path")
|
|
30
|
+
if not fpath:
|
|
31
|
+
lib.allow()
|
|
32
|
+
|
|
33
|
+
root = lib.project_dir(event)
|
|
34
|
+
config = lib.load_config(root)
|
|
35
|
+
vcfg = config.get("versioning", {})
|
|
36
|
+
if vcfg.get("enabled", True) is False:
|
|
37
|
+
lib.allow()
|
|
38
|
+
manifests = vcfg.get("manifests", ver.DEFAULT_MANIFESTS)
|
|
39
|
+
if not ver.is_manifest(os.path.basename(fpath), manifests):
|
|
40
|
+
lib.allow()
|
|
41
|
+
|
|
42
|
+
feature_dir = lib.current_feature_dir(root)
|
|
43
|
+
if not feature_dir:
|
|
44
|
+
lib.allow()
|
|
45
|
+
baseline = lib.read_json(os.path.join(feature_dir, "dependencies.baseline.json"))
|
|
46
|
+
if not baseline:
|
|
47
|
+
lib.allow() # no pre-feature baseline captured -> nothing to diff against
|
|
48
|
+
|
|
49
|
+
current = ver.snapshot_deps(root, manifests)
|
|
50
|
+
changes = ver.diff_deps(baseline, current)
|
|
51
|
+
prev = lib.read_json(os.path.join(feature_dir, "dependencies.json"), default={}) or {}
|
|
52
|
+
lib.write_json(os.path.join(feature_dir, "dependencies.json"), {
|
|
53
|
+
"changes": changes,
|
|
54
|
+
"has_major": ver.has_major(changes),
|
|
55
|
+
"acknowledged": bool(prev.get("acknowledged")) and not ver.has_major(changes),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if ver.has_major(changes):
|
|
59
|
+
majors = [c["name"] for c in changes if c["severity"] == "major"]
|
|
60
|
+
lib.warn("Dependency versioning: MAJOR change to %s. This must be acknowledged "
|
|
61
|
+
"before commit (version-gate) — run `python .claude/scripts/version.py "
|
|
62
|
+
"ack-deps <feature>` once reviewed." % ", ".join(majors))
|
|
63
|
+
lib.allow()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""version_lib.py — the Version Controller core (dependency + code + development).
|
|
2
|
+
|
|
3
|
+
Three responsibilities, one deterministic engine (scripts do computation; files
|
|
4
|
+
carry state — same ethos as the rest of the harness):
|
|
5
|
+
|
|
6
|
+
* Dependency versioning — parse manifests, diff current vs the feature's
|
|
7
|
+
baseline, classify each change major/minor/patch, flag major bumps.
|
|
8
|
+
* Code versioning — semver bump derived from a declared impact level, with a
|
|
9
|
+
CHANGELOG.md entry.
|
|
10
|
+
* Development versioning — hash-snapshot the spec artifacts at each milestone
|
|
11
|
+
so a feature's evolution (approved -> integrated -> committed) is auditable.
|
|
12
|
+
|
|
13
|
+
Everything here is pure/deterministic and fails open on parse errors — a manifest
|
|
14
|
+
we can't read is skipped, never crashes a hook.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
import sys
|
|
24
|
+
sys.path.insert(0, _here)
|
|
25
|
+
import harness_lib as lib # noqa: E402
|
|
26
|
+
|
|
27
|
+
DEFAULT_MANIFESTS = ["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml"]
|
|
28
|
+
_SEMVER = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
# semver
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
def parse_semver(v):
|
|
35
|
+
if not v:
|
|
36
|
+
return None
|
|
37
|
+
m = _SEMVER.search(str(v))
|
|
38
|
+
if not m:
|
|
39
|
+
return None
|
|
40
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classify(old, new):
|
|
44
|
+
a, b = parse_semver(old), parse_semver(new)
|
|
45
|
+
if a is None or b is None:
|
|
46
|
+
return "unknown"
|
|
47
|
+
if a[0] != b[0]:
|
|
48
|
+
return "major"
|
|
49
|
+
if a[1] != b[1]:
|
|
50
|
+
return "minor"
|
|
51
|
+
if a[2] != b[2]:
|
|
52
|
+
return "patch"
|
|
53
|
+
return "none"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def next_version(current, level):
|
|
57
|
+
a = parse_semver(current) or (0, 0, 0)
|
|
58
|
+
if level == "major":
|
|
59
|
+
return "%d.0.0" % (a[0] + 1)
|
|
60
|
+
if level == "patch":
|
|
61
|
+
return "%d.%d.%d" % (a[0], a[1], a[2] + 1)
|
|
62
|
+
return "%d.%d.0" % (a[0], a[1] + 1) # minor (default)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- #
|
|
66
|
+
# manifest parsing (best-effort, dependency-free)
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
def _p_package_json(text):
|
|
69
|
+
out = {}
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(text)
|
|
72
|
+
except Exception:
|
|
73
|
+
return out
|
|
74
|
+
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
75
|
+
for name, ver in (data.get(key) or {}).items():
|
|
76
|
+
out[name] = ver
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _p_requirements(text):
|
|
81
|
+
out = {}
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
line = line.split("#", 1)[0].strip()
|
|
84
|
+
m = re.match(r"^([A-Za-z0-9._-]+)\s*(?:==|~=|>=|<=|>|<)\s*([0-9][^\s;,]*)", line)
|
|
85
|
+
if m:
|
|
86
|
+
out[m.group(1)] = m.group(2)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _p_pyproject(text):
|
|
91
|
+
out = {}
|
|
92
|
+
# PEP 621 / poetry — best effort, no TOML lib
|
|
93
|
+
for m in re.finditer(r'"([A-Za-z0-9._-]+)\s*(?:==|>=|~=)\s*([0-9][^"\s,]*)"', text):
|
|
94
|
+
out[m.group(1)] = m.group(2)
|
|
95
|
+
for m in re.finditer(r'^\s*([A-Za-z0-9._-]+)\s*=\s*"[\^~]?([0-9][^"\s]*)"', text, re.M):
|
|
96
|
+
out.setdefault(m.group(1), m.group(2))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _p_gomod(text):
|
|
101
|
+
out = {}
|
|
102
|
+
for m in re.finditer(r'^\s*([^\s]+/[^\s]+)\s+v(\d+\.\d+\.\d+)', text, re.M):
|
|
103
|
+
out[m.group(1)] = m.group(2)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _p_cargo(text):
|
|
108
|
+
out = {}
|
|
109
|
+
for m in re.finditer(r'^\s*([A-Za-z0-9._-]+)\s*=\s*"([0-9][^"]*)"', text, re.M):
|
|
110
|
+
out[m.group(1)] = m.group(2)
|
|
111
|
+
for m in re.finditer(r'^\s*([A-Za-z0-9._-]+)\s*=\s*\{[^}]*version\s*=\s*"([0-9][^"]*)"', text, re.M):
|
|
112
|
+
out[m.group(1)] = m.group(2)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_PARSERS = {
|
|
117
|
+
"package.json": _p_package_json,
|
|
118
|
+
"requirements.txt": _p_requirements,
|
|
119
|
+
"pyproject.toml": _p_pyproject,
|
|
120
|
+
"go.mod": _p_gomod,
|
|
121
|
+
"Cargo.toml": _p_cargo,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_manifest(basename, manifests=None):
|
|
126
|
+
return basename in (manifests or DEFAULT_MANIFESTS)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def snapshot_deps(root, manifests=None):
|
|
130
|
+
"""Aggregate declared dependency versions across all manifests found in root."""
|
|
131
|
+
manifests = manifests or DEFAULT_MANIFESTS
|
|
132
|
+
deps = {}
|
|
133
|
+
for name in manifests:
|
|
134
|
+
parser = _PARSERS.get(name)
|
|
135
|
+
if not parser:
|
|
136
|
+
continue
|
|
137
|
+
path = os.path.join(root, name)
|
|
138
|
+
if not os.path.exists(path):
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
142
|
+
text = fh.read()
|
|
143
|
+
except OSError:
|
|
144
|
+
continue
|
|
145
|
+
for dep, ver in parser(text).items():
|
|
146
|
+
deps["%s:%s" % (name, dep)] = ver
|
|
147
|
+
return deps
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def diff_deps(baseline, current):
|
|
151
|
+
"""List of {name, from, to, kind, severity}; kind in added|removed|bumped."""
|
|
152
|
+
changes = []
|
|
153
|
+
for key in sorted(set(baseline) | set(current)):
|
|
154
|
+
old, new = baseline.get(key), current.get(key)
|
|
155
|
+
if old == new:
|
|
156
|
+
continue
|
|
157
|
+
if old is None:
|
|
158
|
+
changes.append({"name": key, "from": None, "to": new, "kind": "added", "severity": "minor"})
|
|
159
|
+
elif new is None:
|
|
160
|
+
changes.append({"name": key, "from": old, "to": None, "kind": "removed", "severity": "major"})
|
|
161
|
+
else:
|
|
162
|
+
sev = classify(old, new)
|
|
163
|
+
changes.append({"name": key, "from": old, "to": new, "kind": "bumped",
|
|
164
|
+
"severity": sev if sev != "none" else "patch"})
|
|
165
|
+
return changes
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def has_major(changes):
|
|
169
|
+
return any(c["severity"] == "major" for c in changes)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
# development versioning — artifact snapshots
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
SNAPSHOT_TARGETS = ["discovery.md", "plan.md", "task-graph.json", "approvals.json"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _sha1(path):
|
|
179
|
+
try:
|
|
180
|
+
with open(path, "rb") as fh:
|
|
181
|
+
return "sha1:" + hashlib.sha1(fh.read()).hexdigest()[:16]
|
|
182
|
+
except OSError:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def snapshot_artifacts(spec_dir):
|
|
187
|
+
hashes = {}
|
|
188
|
+
for rel in SNAPSHOT_TARGETS:
|
|
189
|
+
h = _sha1(os.path.join(spec_dir, rel))
|
|
190
|
+
if h:
|
|
191
|
+
hashes[rel] = h
|
|
192
|
+
cdir = os.path.join(spec_dir, "contracts")
|
|
193
|
+
if os.path.isdir(cdir):
|
|
194
|
+
for name in sorted(os.listdir(cdir)):
|
|
195
|
+
h = _sha1(os.path.join(cdir, name))
|
|
196
|
+
if h:
|
|
197
|
+
hashes["contracts/" + name] = h
|
|
198
|
+
for name in sorted(os.listdir(spec_dir)) if os.path.isdir(spec_dir) else []:
|
|
199
|
+
if re.match(r"review\..*\.json$", name):
|
|
200
|
+
h = _sha1(os.path.join(spec_dir, name))
|
|
201
|
+
if h:
|
|
202
|
+
hashes[name] = h
|
|
203
|
+
return hashes
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def record_snapshot(spec_dir, milestone, now=None):
|
|
207
|
+
"""Append a development-version entry to history.json; returns dev_version."""
|
|
208
|
+
hp = os.path.join(spec_dir, "history.json")
|
|
209
|
+
hist = lib.read_json(hp, default={}) or {}
|
|
210
|
+
entries = hist.setdefault("entries", [])
|
|
211
|
+
dev_version = len(entries) + 1
|
|
212
|
+
entries.append({
|
|
213
|
+
"dev_version": dev_version,
|
|
214
|
+
"milestone": milestone,
|
|
215
|
+
"at": now,
|
|
216
|
+
"artifacts": snapshot_artifacts(spec_dir),
|
|
217
|
+
})
|
|
218
|
+
hist["dev_version"] = dev_version
|
|
219
|
+
lib.write_json(hp, hist)
|
|
220
|
+
return dev_version
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------- #
|
|
224
|
+
# code versioning — VERSION + CHANGELOG
|
|
225
|
+
# --------------------------------------------------------------------------- #
|
|
226
|
+
def read_root_version(root):
|
|
227
|
+
p = os.path.join(root, "VERSION")
|
|
228
|
+
try:
|
|
229
|
+
with open(p, "r", encoding="utf-8") as fh:
|
|
230
|
+
return fh.read().strip() or "0.0.0"
|
|
231
|
+
except OSError:
|
|
232
|
+
return "0.0.0"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def plan_version_impact(spec_dir):
|
|
236
|
+
"""Level declared by the planner as `VERSION-IMPACT: major|minor|patch` in plan.md."""
|
|
237
|
+
try:
|
|
238
|
+
with open(os.path.join(spec_dir, "plan.md"), "r", encoding="utf-8", errors="ignore") as fh:
|
|
239
|
+
text = fh.read()
|
|
240
|
+
except OSError:
|
|
241
|
+
return None
|
|
242
|
+
m = re.search(r"VERSION-IMPACT:\s*(major|minor|patch)", text, re.I)
|
|
243
|
+
return m.group(1).lower() if m else None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""approve-plan.py — write specs/<feature>/approvals.json (the autonomy boundary).
|
|
3
|
+
|
|
4
|
+
This is the signature the human applies to the DRAFT PLAN PACKAGE (tasks,
|
|
5
|
+
footprints, contracts, computed task-graph, token budget). After it exists and
|
|
6
|
+
its hash matches plan.md, dispatch-gate G1 lets automation run — and any later
|
|
7
|
+
edit to plan.md breaks the hash and re-freezes the pipeline until re-approval.
|
|
8
|
+
|
|
9
|
+
python .claude/scripts/approve-plan.py <feature> [--budget N] [--approver NAME] [--force]
|
|
10
|
+
|
|
11
|
+
The plan_hash MUST be computed exactly as dispatch-gate.py computes it:
|
|
12
|
+
sha256(plan.md bytes) — keep these two in lock-step.
|
|
13
|
+
|
|
14
|
+
Approval also runs the requirements gate (req_lib): it REFUSES to sign a plan
|
|
15
|
+
built on ambiguous intent, vague requirements, a broken flow, or one that has
|
|
16
|
+
diverged from the acceptance criteria (uncovered ACs / scope creep). --force
|
|
17
|
+
overrides and records the override in approvals.json, so the bypass is logged,
|
|
18
|
+
never silent.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks"))
|
|
29
|
+
import harness_lib as lib # noqa: E402
|
|
30
|
+
import req_lib as req # noqa: E402
|
|
31
|
+
import version_lib as ver # noqa: E402
|
|
32
|
+
|
|
33
|
+
REPO = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv=None):
|
|
37
|
+
ap = argparse.ArgumentParser()
|
|
38
|
+
ap.add_argument("feature")
|
|
39
|
+
ap.add_argument("--budget", type=int, default=None,
|
|
40
|
+
help="approved token budget for this feature (burndown ceiling)")
|
|
41
|
+
ap.add_argument("--approver", default=os.environ.get("USER") or os.environ.get("USERNAME") or "human")
|
|
42
|
+
ap.add_argument("--now", default=None, help="override timestamp (tests)")
|
|
43
|
+
ap.add_argument("--force", action="store_true",
|
|
44
|
+
help="sign despite requirements-gate problems (logged as an override)")
|
|
45
|
+
args = ap.parse_args(argv)
|
|
46
|
+
|
|
47
|
+
spec = REPO / "specs" / args.feature
|
|
48
|
+
plan = spec / "plan.md"
|
|
49
|
+
if not plan.exists():
|
|
50
|
+
sys.stderr.write("approve-plan: %s not found — nothing to approve.\n" % plan)
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
cfg = lib.load_config(str(REPO)).get("requirements", {})
|
|
54
|
+
problems = req.check_all(str(spec), is_specialist=True, cfg=cfg)
|
|
55
|
+
if problems and not args.force:
|
|
56
|
+
sys.stderr.write(
|
|
57
|
+
"approve-plan: REFUSED — the plan is not ready to sign:\n - "
|
|
58
|
+
+ "\n - ".join(problems)
|
|
59
|
+
+ "\nResolve these with the user (re-interview / re-plan), or pass --force "
|
|
60
|
+
"to sign anyway (the override is logged in approvals.json).\n")
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
approvals = {
|
|
64
|
+
"plan_hash": hashlib.sha256(plan.read_bytes()).hexdigest(),
|
|
65
|
+
"budget": args.budget,
|
|
66
|
+
"approver": args.approver,
|
|
67
|
+
"timestamp": args.now or datetime.now(timezone.utc).isoformat(),
|
|
68
|
+
}
|
|
69
|
+
if problems and args.force:
|
|
70
|
+
approvals["forced_override"] = problems
|
|
71
|
+
(spec / "approvals.json").write_text(
|
|
72
|
+
json.dumps(approvals, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
# Version Controller: capture the pre-implementation dependency baseline and
|
|
75
|
+
# the first development-version snapshot. Both must happen at approval — the
|
|
76
|
+
# baseline is the pre-feature state, and dev history starts the moment the
|
|
77
|
+
# decomposition is signed.
|
|
78
|
+
try:
|
|
79
|
+
deps = ver.snapshot_deps(str(REPO), lib.load_config(str(REPO)).get("versioning", {}).get("manifests"))
|
|
80
|
+
lib.write_json(str(spec / "dependencies.baseline.json"), deps)
|
|
81
|
+
ver.record_snapshot(str(spec), "approved", now=approvals["timestamp"])
|
|
82
|
+
except Exception as e: # versioning must never block an otherwise-valid approval
|
|
83
|
+
sys.stderr.write("approve-plan: version baseline/snapshot skipped (%s)\n" % e)
|
|
84
|
+
|
|
85
|
+
sys.stderr.write("approved '%s' (plan_hash %s…, budget %s)\n"
|
|
86
|
+
% (args.feature, approvals["plan_hash"][:12], approvals["budget"]))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""changeset.py — print the current pending-diff fingerprint.
|
|
3
|
+
|
|
4
|
+
The reviewer runs this and records the output as `changeset` in its integration
|
|
5
|
+
review verdict. commit-gate.py computes the same value and only opens the gate
|
|
6
|
+
when they match — so a review authorizes exactly the diff it saw, nothing later.
|
|
7
|
+
|
|
8
|
+
python .claude/scripts/changeset.py [repo_root]
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks"))
|
|
14
|
+
import harness_lib as lib # noqa: E402
|
|
15
|
+
|
|
16
|
+
root = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.getcwd()
|
|
17
|
+
cs = lib.git_changeset(root)
|
|
18
|
+
print(cs if cs else "no-git")
|