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