statutor 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: statutor
3
+ Version: 0.2.0
4
+ Summary: Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies.
5
+ Requires-Python: >=3.10
@@ -0,0 +1,7 @@
1
+ statutor_core.py,sha256=RjqBU7pz5P5EfK14BCZGp6JaHlTgm7lUNvKqQGhqDNs,16071
2
+ statutor_doctor.py,sha256=uTzEFkNanRxbfz6HTIIub295ZVWB7gUN2f_mqWqTi6E,7362
3
+ statutor-0.2.0.dist-info/METADATA,sha256=dF6gtJqt3iawUre6DfI26HMlVYX1ve9sjQ8j6kT68PQ,197
4
+ statutor-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ statutor-0.2.0.dist-info/entry_points.txt,sha256=1A1e8zsnk6dtvt5x9Y2w2XJ9Ih9wUzbo9XnvFKtGQUw,87
6
+ statutor-0.2.0.dist-info/top_level.txt,sha256=GzS6YL34do9ACaZ_iVXJ7eYMC2_iUZtU9eWbcCAulas,30
7
+ statutor-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ statutor = statutor_core:main
3
+ statutor-doctor = statutor_doctor:main
@@ -0,0 +1,2 @@
1
+ statutor_core
2
+ statutor_doctor
statutor_core.py ADDED
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env python3
2
+ """statutor — typed project-ledger kernel (harness-agnostic).
3
+
4
+ Four planes (constitution / state / log / plan), one writer per file,
5
+ mutation policies enforced here rather than in prose.
6
+
7
+ Entry modes (all share the same validate() core):
8
+
9
+ statutor hook Claude Code / Codex CLI hook protocol:
10
+ stdin JSON in, permissionDecision JSON out.
11
+ (Codex's PreToolUse mirrors Claude's schema and
12
+ also fires for apply_patch, but sends edits as
13
+ tool_input {"command": "<patch text>"} — this
14
+ validate() only understands bash/write/edit, so
15
+ apply_patch falls through unhandled; the git
16
+ floor is mandatory there. See adapters/codex/.)
17
+ statutor check TOOL JSON [CWD]
18
+ Generic shim mode for OpenCode / Hermes / tests.
19
+ exit 0 = allow, exit 2 = deny (reason on stderr).
20
+ statutor staged [CWD] Git floor: validate staged changes (pre-commit).
21
+ exit 1 on violations.
22
+ statutor init [DIR] Scaffold governed files from embedded templates.
23
+
24
+ No third-party dependencies. PyYAML optional (.statutor.yaml overrides).
25
+ Hook mode fails open: a kernel bug must never break a session.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import fnmatch
31
+ import json
32
+ import os
33
+ import re
34
+ import subprocess
35
+ import sys
36
+
37
+ # --------------------------------------------------------------------------
38
+ # policy
39
+ # --------------------------------------------------------------------------
40
+
41
+ DEFAULT_POLICY: dict = {
42
+ "bash_guard": True,
43
+ "governed": [
44
+ {"pattern": "AGENTS.md", "policy": "constitution", "hard_max_lines": 200},
45
+ {
46
+ "pattern": "HANDOFF.md",
47
+ "policy": "overwrite_bounded",
48
+ "max_lines": 40,
49
+ "required_sections": [
50
+ "## Goal",
51
+ "## Last verified state",
52
+ "## Next action",
53
+ "## Gotchas",
54
+ "## Do not touch",
55
+ ],
56
+ },
57
+ {"pattern": "DECISIONS.md", "policy": "append_only"},
58
+ {"pattern": "TASKS.md", "policy": "state"},
59
+ {"pattern": "plans/archive/*", "policy": "frozen"},
60
+ ],
61
+ }
62
+
63
+ WRITEISH = ("(?<![0-9<>])>", ">>", "\\btee\\b", "\\bsed\\s+-i", "\\brm\\b",
64
+ "\\bmv\\b", "\\btruncate\\b", "\\bdd\\b", "\\bcp\\b")
65
+
66
+
67
+ def load_policy(cwd: str) -> dict:
68
+ path = os.path.join(cwd, ".statutor.yaml")
69
+ if os.path.isfile(path):
70
+ try:
71
+ import yaml # optional
72
+
73
+ data = yaml.safe_load(open(path, encoding="utf-8"))
74
+ if isinstance(data, dict) and "governed" in data:
75
+ data.setdefault("bash_guard", True)
76
+ return data
77
+ except Exception:
78
+ pass # fall through to defaults; `statutor doctor` reports parse issues
79
+ return DEFAULT_POLICY
80
+
81
+
82
+ def _match_rule(rel_path: str, policy: dict) -> dict | None:
83
+ rel_path = rel_path.replace(os.sep, "/")
84
+ base = os.path.basename(rel_path)
85
+ for rule in policy.get("governed", []):
86
+ pat = rule.get("pattern", "")
87
+ if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(base, pat):
88
+ return rule
89
+ return None
90
+
91
+
92
+ def _norm(payload: dict) -> dict:
93
+ """Normalize harness arg names (Claude snake_case, OpenCode camelCase)."""
94
+ out = dict(payload or {})
95
+ for a, b in (("filePath", "file_path"), ("oldString", "old_string"),
96
+ ("newString", "new_string")):
97
+ if a in out and b not in out:
98
+ out[b] = out[a]
99
+ return out
100
+
101
+
102
+ # --------------------------------------------------------------------------
103
+ # core validation (pure): returns denial reason or None
104
+ # --------------------------------------------------------------------------
105
+
106
+ def validate(tool: str, payload: dict, cwd: str, policy: dict | None = None) -> str | None:
107
+ policy = policy or load_policy(cwd)
108
+ tool = tool.lower()
109
+ payload = _norm(payload)
110
+
111
+ if tool == "bash":
112
+ return guard_bash(payload.get("command", ""), policy)
113
+ if tool not in ("write", "edit"):
114
+ return None
115
+
116
+ file_path = payload.get("file_path", "")
117
+ if not file_path:
118
+ return None
119
+ rel = os.path.relpath(os.path.abspath(file_path), os.path.abspath(cwd))
120
+ rule = _match_rule(rel, policy)
121
+ if rule is None:
122
+ return None
123
+ kind = rule.get("policy", "")
124
+
125
+ if kind == "frozen":
126
+ return f"{rel} is frozen (archived plan). Archived records are immutable."
127
+
128
+ if kind == "constitution" and tool == "write":
129
+ content = payload.get("content", "")
130
+ hard = int(rule.get("hard_max_lines", 200))
131
+ n = content.count("\n") + 1
132
+ if n > hard:
133
+ return (f"AGENTS.md would be {n} lines (hard cap {hard}). The constitution "
134
+ "carries only what the repo cannot say itself — move procedures "
135
+ "to skills/commands, delete derivable facts.")
136
+
137
+ if kind == "overwrite_bounded" and tool == "write":
138
+ content = payload.get("content", "")
139
+ cap = int(rule.get("max_lines", 40))
140
+ n = content.count("\n") + 1
141
+ if n > cap:
142
+ return (f"{rel} would be {n} lines (cap {cap}). HANDOFF is a shift-change "
143
+ "note, not a log: overwrite, compress, drop history.")
144
+ missing = [s for s in rule.get("required_sections", []) if s not in content]
145
+ if missing:
146
+ return (f"{rel} is missing required sections: {', '.join(missing)}. "
147
+ "A handoff without these fields strands the next session.")
148
+
149
+ if kind == "append_only":
150
+ if tool == "edit":
151
+ old = payload.get("old_string", "")
152
+ new = payload.get("new_string", "")
153
+ if old and old not in new:
154
+ return (f"{rel} is append-only. Edits must be pure insertions "
155
+ "(new_string must contain old_string verbatim). To change a "
156
+ "decision, append a superseding record — never edit the old one.")
157
+ elif tool == "write":
158
+ try:
159
+ existing = open(file_path, encoding="utf-8").read()
160
+ except FileNotFoundError:
161
+ existing = ""
162
+ if existing.strip() and existing.strip() not in payload.get("content", ""):
163
+ return (f"{rel} is append-only. A full rewrite must contain the "
164
+ "existing content verbatim; records are never modified or deleted.")
165
+ return None
166
+
167
+
168
+ def guard_bash(command: str, policy: dict) -> str | None:
169
+ """Deny shell commands that look like writes to governed files.
170
+
171
+ Closes the bypass where an agent avoids Write/Edit hooks via
172
+ `echo x >> DECISIONS.md` or `sed -i` — on every harness, and it is the
173
+ only PreToolUse coverage Codex currently offers. Strict by design
174
+ (a redirect on the same line as a governed name is denied even if the
175
+ target differs); disable per-repo with `bash_guard: false` in .statutor.yaml.
176
+ """
177
+ if not policy.get("bash_guard", True) or not command:
178
+ return None
179
+ names = [os.path.basename(r.get("pattern", "")) for r in policy.get("governed", [])
180
+ if r.get("policy") in ("append_only", "overwrite_bounded", "constitution")
181
+ and "*" not in r.get("pattern", "")]
182
+ hit = [n for n in names if n and n in command]
183
+ if hit and any(re.search(p, command) for p in WRITEISH):
184
+ return (f"shell write touching governed file(s) {hit} denied: direct shell "
185
+ "mutations bypass policy validation. Use the editor tool, or set "
186
+ "bash_guard: false in .statutor.yaml if this was a false positive.")
187
+ return None
188
+
189
+
190
+ # --------------------------------------------------------------------------
191
+ # entry: hook (Claude Code / Codex protocol) — must fail open
192
+ # --------------------------------------------------------------------------
193
+
194
+ def run_hook() -> int:
195
+ try:
196
+ event = json.load(sys.stdin)
197
+ tool = event.get("tool_name", "")
198
+ payload = event.get("tool_input", {}) or {}
199
+ cwd = event.get("cwd", os.getcwd())
200
+ reason = validate(tool, payload, cwd)
201
+ if reason:
202
+ print(json.dumps({"hookSpecificOutput": {
203
+ "hookEventName": "PreToolUse",
204
+ "permissionDecision": "deny",
205
+ "permissionDecisionReason": f"[statutor] {reason}",
206
+ }}))
207
+ except Exception:
208
+ pass # fail open
209
+ return 0
210
+
211
+
212
+ # --------------------------------------------------------------------------
213
+ # entry: check (generic shim for OpenCode / Hermes / tests)
214
+ # --------------------------------------------------------------------------
215
+
216
+ def run_check(argv: list[str]) -> int:
217
+ if len(argv) < 2:
218
+ print("usage: statutor check TOOL JSON [CWD]", file=sys.stderr)
219
+ return 64
220
+ tool, payload = argv[0], json.loads(argv[1])
221
+ cwd = argv[2] if len(argv) > 2 else os.getcwd()
222
+ reason = validate(tool, payload, cwd)
223
+ if reason:
224
+ print(f"[statutor] {reason}", file=sys.stderr)
225
+ return 2
226
+ return 0
227
+
228
+
229
+ # --------------------------------------------------------------------------
230
+ # entry: staged (git floor — harness-independent backstop)
231
+ # --------------------------------------------------------------------------
232
+
233
+ def _git(cwd: str, *args: str) -> str:
234
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True,
235
+ text=True, check=False).stdout
236
+
237
+
238
+ def run_staged(cwd: str) -> int:
239
+ policy = load_policy(cwd)
240
+ violations: list[str] = []
241
+
242
+ for line in _git(cwd, "diff", "--cached", "--name-status", "-M").splitlines():
243
+ parts = line.split("\t")
244
+ if len(parts) < 2:
245
+ continue
246
+ status, paths = parts[0], parts[1:]
247
+ old, new = (paths[0], paths[-1])
248
+ for p, arriving in ((old, False), (new, True)) if status.startswith("R") \
249
+ else ((new, status.startswith("A")),):
250
+ rule = _match_rule(p, policy)
251
+ if rule and rule.get("policy") == "frozen" and not arriving:
252
+ violations.append(f"{p}: frozen — archived records are immutable "
253
+ "(moving a plan INTO the archive is allowed).")
254
+
255
+ for path in _git(cwd, "diff", "--cached", "--name-only").splitlines():
256
+ rule = _match_rule(path, policy)
257
+ if not rule:
258
+ continue
259
+ kind = rule.get("policy", "")
260
+ if kind == "append_only":
261
+ diff = _git(cwd, "diff", "--cached", "-U0", "--", path)
262
+ dels = [l for l in diff.splitlines()
263
+ if l.startswith("-") and not l.startswith("---")]
264
+ if dels:
265
+ violations.append(
266
+ f"{path}: append-only, but staged diff deletes/modifies "
267
+ f"{len(dels)} line(s). Append superseding records instead.")
268
+ elif kind in ("overwrite_bounded", "constitution"):
269
+ blob = _git(cwd, "show", f":{path}")
270
+ n = blob.count("\n") + 1
271
+ cap = int(rule.get("max_lines", rule.get("hard_max_lines", 200)))
272
+ if n > cap:
273
+ violations.append(f"{path}: staged version is {n} lines (cap {cap}).")
274
+ missing = [s for s in rule.get("required_sections", []) if s not in blob]
275
+ if missing:
276
+ violations.append(f"{path}: missing sections {missing}.")
277
+
278
+ for v in violations:
279
+ print(f"STATUTOR {v}")
280
+ return 1 if violations else 0
281
+
282
+
283
+ # --------------------------------------------------------------------------
284
+ # entry: init (embedded templates — single source of truth)
285
+ # --------------------------------------------------------------------------
286
+
287
+ TEMPLATES: dict[str, str] = {
288
+ "AGENTS.md": """\
289
+ <!-- statutor: plane=constitution | policy=constitution | writer=human | budget: soft 120 / hard 200 lines -->
290
+ # AGENTS.md
291
+
292
+ > One-paragraph project statement and core constraints. Nothing derivable
293
+ > from the codebase belongs here — only pitfalls, rationale, and conventions
294
+ > that differ from tool defaults.
295
+
296
+ ## Commands
297
+ - Build: `<cmd>`
298
+ - Test: `<cmd>`
299
+ - Lint: `<cmd>`
300
+
301
+ ## Conventions that differ from defaults
302
+ - <...>
303
+
304
+ ## Pitfalls (hard-won, one line each)
305
+ - <add only after an agent actually made the mistake>
306
+
307
+ ## Boundaries
308
+ - Do not edit: `plans/archive/`, generated files
309
+ - Ledger discipline: HANDOFF.md (state), TASKS.md (queue), DECISIONS.md
310
+ (settled questions — read before re-opening any choice)
311
+ """,
312
+ "HANDOFF.md": """\
313
+ <!-- statutor: plane=state | policy=overwrite_bounded (max 40 lines) | writer=executor | OVERWRITE, NEVER APPEND -->
314
+ # HANDOFF
315
+
316
+ last_verified: 1970-01-01 by `<command that proved the state below>`
317
+
318
+ ## Goal
319
+ <the single objective of the current work stream>
320
+
321
+ ## Last verified state
322
+ <what is known-working right now, and how it was verified>
323
+
324
+ ## Next action
325
+ <the exact next step, specific enough to start cold>
326
+
327
+ ## Gotchas
328
+ <open traps discovered this session>
329
+
330
+ ## Do not touch
331
+ <files/areas mid-flight or deliberately frozen>
332
+ """,
333
+ "DECISIONS.md": """\
334
+ <!-- statutor: plane=log | policy=append_only (insertions only; supersede, never edit) | writer=orchestrator/human -->
335
+ # DECISIONS
336
+
337
+ ## D-0001 — Adopt the statutor ledger framework
338
+ **Status:** accepted
339
+ **Context:** Multi-agent sessions re-litigate settled questions and lose state across context windows.
340
+ **Decision:** Four-plane typed ledger, single writer per file, hook-enforced mutation policies.
341
+ **Consequences:** HANDOFF.md is overwrite-only and bounded; this file is append-only; CHANGELOG.md is generated from conventional commits, never hand-maintained.
342
+ """,
343
+ "TASKS.md": """\
344
+ <!-- statutor: plane=state | policy=state (doctor-checked) | writer=orchestrator | stable IDs, one line per task -->
345
+ # TASKS
346
+
347
+ - [ ] T-0001 <first task — imperative, verifiable>
348
+ """,
349
+ "ROADMAP.md": """\
350
+ <!-- statutor: plane=plan | writer=human | agents read ONLY the section below the marker -->
351
+ # ROADMAP
352
+
353
+ ## Current milestone <!-- agent-visible -->
354
+ <what "done" means for the active milestone>
355
+
356
+ ## Later (human context, agents ignore)
357
+ - <...>
358
+ """,
359
+ ".statutor.yaml": """\
360
+ # .statutor.yaml — statutor mutation policy. Embedded defaults apply if
361
+ # this file is absent or PyYAML is unavailable.
362
+ bash_guard: true
363
+ governed:
364
+ - pattern: AGENTS.md
365
+ policy: constitution
366
+ hard_max_lines: 200
367
+ - pattern: HANDOFF.md
368
+ policy: overwrite_bounded
369
+ max_lines: 40
370
+ required_sections:
371
+ - "## Goal"
372
+ - "## Last verified state"
373
+ - "## Next action"
374
+ - "## Gotchas"
375
+ - "## Do not touch"
376
+ - pattern: DECISIONS.md
377
+ policy: append_only
378
+ - pattern: TASKS.md
379
+ policy: state
380
+ - pattern: plans/archive/*
381
+ policy: frozen
382
+ """,
383
+ }
384
+
385
+
386
+ def run_init(target: str) -> int:
387
+ os.makedirs(os.path.join(target, "plans", "archive"), exist_ok=True)
388
+ os.makedirs(os.path.join(target, "notes"), exist_ok=True)
389
+ for name, body in TEMPLATES.items():
390
+ path = os.path.join(target, name)
391
+ if os.path.exists(path):
392
+ print(f"skip {name} (exists)")
393
+ continue
394
+ with open(path, "w", encoding="utf-8") as fh:
395
+ fh.write(body)
396
+ print(f"write {name}")
397
+ claude_md = os.path.join(target, "CLAUDE.md")
398
+ if not os.path.exists(claude_md):
399
+ with open(claude_md, "w", encoding="utf-8") as fh:
400
+ fh.write("@AGENTS.md\n")
401
+ print("write CLAUDE.md (@AGENTS.md import)")
402
+ return 0
403
+
404
+
405
+ # --------------------------------------------------------------------------
406
+
407
+ def main() -> None:
408
+ argv = sys.argv[1:]
409
+ mode = argv[0] if argv else "hook"
410
+ if mode in ("hook", "--claude-hook"):
411
+ sys.exit(run_hook())
412
+ if mode == "check":
413
+ sys.exit(run_check(argv[1:]))
414
+ if mode in ("staged", "--staged"):
415
+ sys.exit(run_staged(argv[1] if len(argv) > 1 else os.getcwd()))
416
+ if mode == "init":
417
+ sys.exit(run_init(argv[1] if len(argv) > 1 else os.getcwd()))
418
+ print(__doc__)
419
+ sys.exit(64)
420
+
421
+
422
+ if __name__ == "__main__":
423
+ main()
statutor_doctor.py ADDED
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """statutor doctor — drift linter for governed files.
3
+
4
+ Run from the repo root (or pass the root as argv[1]). Checks what the
5
+ PreToolUse hook cannot catch synchronously:
6
+
7
+ * .statutor.yaml present but not applied (PyYAML missing, or the file is
8
+ malformed / lacks a `governed` key — statutor_core.load_policy fell back
9
+ to embedded defaults silently)
10
+ * governed files missing entirely
11
+ * the constitution file over soft budget (hook only enforces the hard cap)
12
+ * the overwrite_bounded file's stale `last_verified:` stamp (default:
13
+ warn > 3 days)
14
+ * the overwrite_bounded file missing required sections while it exists on
15
+ disk (the hook/floor bounds writes; a file lacking sections means one
16
+ was bypassed — that's drift)
17
+ * consumed plans left in plans/ instead of plans/archive/
18
+ (heuristic: plan references a TASKS.md id whose checkbox is [x])
19
+ * DECISIONS.md records missing status fields
20
+
21
+ Budgets and filenames are read from the repo's policy (.statutor.yaml via
22
+ statutor_core.load_policy), falling back to the module constants below when a
23
+ key (or the whole rule) is absent:
24
+ * the missing-file check list comes from governed patterns that are plain
25
+ basenames (no "*", no "/")
26
+ * the constitution filename comes from the constitution rule's pattern
27
+ (same basename-only restriction); soft budget: optional `soft_max_lines`
28
+ * the overwrite_bounded filename comes from that rule's pattern (same
29
+ restriction); staleness threshold: optional `stale_after_days`;
30
+ required sections: optional `required_sections`
31
+
32
+ Exit code 1 on errors, 0 on clean/warnings-only.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import re
39
+ import sys
40
+ from datetime import date, datetime
41
+
42
+ import statutor_core
43
+
44
+ SOFT_AGENTS_LINES = 120
45
+ HANDOFF_STALE_DAYS = 3
46
+
47
+ errors: list[str] = []
48
+ warnings: list[str] = []
49
+
50
+
51
+ def _rule_filename(rule: dict | None, default: str) -> str:
52
+ """Basename to check for `rule`, derived from its `pattern` when that
53
+ pattern is a plain basename (no "*", no "/"); otherwise `default`.
54
+
55
+ A glob or path pattern doesn't identify one single file to open for a
56
+ line-count/staleness/sections check, so such rules fall back to the
57
+ conventional name rather than guessing which match to inspect.
58
+ """
59
+ if rule:
60
+ pat = rule.get("pattern", "")
61
+ if pat and "*" not in pat and "/" not in pat:
62
+ return pat
63
+ return default
64
+
65
+
66
+ def check(root: str) -> None:
67
+ errors.clear()
68
+ warnings.clear()
69
+
70
+ def p(name: str) -> str:
71
+ return os.path.join(root, name)
72
+
73
+ policy = statutor_core.load_policy(root)
74
+ governed = policy.get("governed", [])
75
+
76
+ if os.path.isfile(p(".statutor.yaml")) and policy is statutor_core.DEFAULT_POLICY:
77
+ # A file byte-identical to the scaffold template IS the embedded
78
+ # defaults — falling back loses nothing, so a fresh `statutor init`
79
+ # ledger on a PyYAML-less interpreter must not warn on every run.
80
+ try:
81
+ pristine = open(p(".statutor.yaml"), encoding="utf-8").read() \
82
+ == statutor_core.TEMPLATES[".statutor.yaml"]
83
+ except Exception:
84
+ pristine = False
85
+ if not pristine:
86
+ warnings.append(
87
+ ".statutor.yaml present but not applied (PyYAML missing or file "
88
+ "invalid) — embedded defaults in effect."
89
+ )
90
+
91
+ check_names = [
92
+ rule["pattern"] for rule in governed
93
+ if rule.get("pattern") and "*" not in rule["pattern"] and "/" not in rule["pattern"]
94
+ ]
95
+
96
+ constitution_rule = next((r for r in governed if r.get("policy") == "constitution"), None)
97
+ agents_filename = _rule_filename(constitution_rule, "AGENTS.md")
98
+ soft_agents_lines = int(constitution_rule.get("soft_max_lines", SOFT_AGENTS_LINES)) \
99
+ if constitution_rule else SOFT_AGENTS_LINES
100
+
101
+ overwrite_rule = next((r for r in governed if r.get("policy") == "overwrite_bounded"), None)
102
+ handoff_filename = _rule_filename(overwrite_rule, "HANDOFF.md")
103
+ handoff_stale_days = int(overwrite_rule.get("stale_after_days", HANDOFF_STALE_DAYS)) \
104
+ if overwrite_rule else HANDOFF_STALE_DAYS
105
+
106
+ for name in check_names:
107
+ if not os.path.isfile(p(name)):
108
+ errors.append(f"missing governed file: {name} (run /ledger-init)")
109
+
110
+ if os.path.isfile(p(agents_filename)):
111
+ n = sum(1 for _ in open(p(agents_filename), encoding="utf-8"))
112
+ if n > soft_agents_lines:
113
+ warnings.append(
114
+ f"{agents_filename} is {n} lines (soft budget {soft_agents_lines}): "
115
+ "trim derivable content, move procedures to skills."
116
+ )
117
+
118
+ if os.path.isfile(p(handoff_filename)):
119
+ text = open(p(handoff_filename), encoding="utf-8").read()
120
+ m = re.search(r"last_verified:\s*(\d{4}-\d{2}-\d{2})", text)
121
+ if not m:
122
+ errors.append(f"{handoff_filename} has no `last_verified: YYYY-MM-DD` stamp.")
123
+ else:
124
+ age = (date.today() - datetime.strptime(m.group(1), "%Y-%m-%d").date()).days
125
+ if age > handoff_stale_days:
126
+ warnings.append(
127
+ f"{handoff_filename} last verified {age} days ago — re-verify or rewrite."
128
+ )
129
+
130
+ required_sections = overwrite_rule.get("required_sections", []) if overwrite_rule else []
131
+ missing_sections = [s for s in required_sections if s not in text]
132
+ if missing_sections:
133
+ errors.append(
134
+ f"{handoff_filename} is missing required sections: "
135
+ f"{', '.join(missing_sections)} — a file on disk without these "
136
+ "bypassed the hook/floor (that's drift)."
137
+ )
138
+
139
+ done_ids: set[str] = set()
140
+ if os.path.isfile(p("TASKS.md")):
141
+ for line in open(p("TASKS.md"), encoding="utf-8"):
142
+ m = re.match(r"- \[x\]\s+(\S+)", line, re.IGNORECASE)
143
+ if m:
144
+ done_ids.add(m.group(1))
145
+
146
+ plans_dir = p("plans")
147
+ if os.path.isdir(plans_dir):
148
+ for fname in os.listdir(plans_dir):
149
+ fpath = os.path.join(plans_dir, fname)
150
+ if not fname.endswith(".md") or not os.path.isfile(fpath):
151
+ continue
152
+ body = open(fpath, encoding="utf-8").read()
153
+ hit = [t for t in done_ids if t in body]
154
+ if hit:
155
+ warnings.append(
156
+ f"plans/{fname} references completed task(s) {hit} — "
157
+ "move to plans/archive/ (consumed plans are stale intent)."
158
+ )
159
+
160
+ if os.path.isfile(p("DECISIONS.md")):
161
+ body = open(p("DECISIONS.md"), encoding="utf-8").read()
162
+ records = re.findall(r"^## D-\d+", body, re.MULTILINE)
163
+ statuses = re.findall(r"^\*\*Status:\*\*", body, re.MULTILINE)
164
+ if len(statuses) < len(records):
165
+ warnings.append(
166
+ f"DECISIONS.md: {len(records)} records but only {len(statuses)} "
167
+ "Status fields — every record needs one."
168
+ )
169
+
170
+
171
+ def main() -> None:
172
+ root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
173
+ check(root)
174
+ for w in warnings:
175
+ print(f"WARN {w}")
176
+ for e in errors:
177
+ print(f"ERROR {e}")
178
+ if not warnings and not errors:
179
+ print("OK ledger clean.")
180
+ sys.exit(1 if errors else 0)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()