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.
- statutor-0.2.0.dist-info/METADATA +5 -0
- statutor-0.2.0.dist-info/RECORD +7 -0
- statutor-0.2.0.dist-info/WHEEL +5 -0
- statutor-0.2.0.dist-info/entry_points.txt +3 -0
- statutor-0.2.0.dist-info/top_level.txt +2 -0
- statutor_core.py +423 -0
- statutor_doctor.py +184 -0
|
@@ -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,,
|
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()
|