statutor 0.2.0__tar.gz

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,78 @@
1
+ # statutor
2
+
3
+ Typed project-ledger framework for agentic repos. A *statutor* is one who
4
+ enacts (agent noun of *statuere*) — which is the thesis: repo instruction
5
+ files are a state machine of typed registers, each with a mutation policy
6
+ and exactly one writer, enforced by hooks and git, not by prose.
7
+
8
+ | Plane | Files | Policy | Enforced by |
9
+ |---|---|---|---|
10
+ | Constitution | AGENTS.md (+ CLAUDE.md = `@AGENTS.md`) | hard cap 200 lines | hook + git floor |
11
+ | State | HANDOFF.md | overwrite-only, ≤ 40 lines, required sections | hook + git floor |
12
+ | State | TASKS.md | stable T-NNNN ids | doctor |
13
+ | Log | DECISIONS.md | append-only, insertions only, supersede-never-edit | hook + git floor |
14
+ | Plan | ROADMAP.md, plans/ → plans/archive/ (frozen) | archive immutable | hook + git floor |
15
+
16
+ Plus a **bash guard** on every harness: shell writes to governed files
17
+ (`>>`, `sed -i`, `tee`, ...) are denied — the editor tools are the audited
18
+ path. No hand-maintained CHANGELOG.md: git log + conventional commits.
19
+
20
+ ## Kernel / adapter architecture
21
+
22
+ core/statutor_core.py single-file kernel: validate() + embedded templates
23
+ modes: hook | check | staged | init (fail-open hooks)
24
+ core/statutor_doctor.py drift linter (stale stamps, budgets, unarchived plans)
25
+ hooks/stop_doctor.py Claude Code Stop hook: runs statutor-doctor after each
26
+ turn and surfaces its WARN/ERROR lines as
27
+ additionalContext — non-blocking, silent when the
28
+ ledger is clean or the cwd isn't a statutor ledger at all
29
+ pyproject.toml pipx install → `statutor`, `statutor-doctor` on PATH
30
+
31
+ | Adapter | Mechanism | Coverage |
32
+ |---|---|---|
33
+ | Claude Code (repo root is the plugin) | PreToolUse `Write\|Edit\|Bash` → `statutor hook`; Stop → `hooks/stop_doctor.py` | full in-loop + drift surfacing |
34
+ | OpenCode (`adapters/opencode/statutor.ts`) | `tool.execute.before` → `statutor check` | in-loop (write/edit/bash)¹ |
35
+ | Codex CLI (`adapters/codex/`) | PreToolUse (Claude-compatible protocol) → `statutor hook` | bash guard only² |
36
+ | git (`adapters/git/`, `.pre-commit-hooks.yaml`) | `statutor staged` on pre-commit / pre-receive | universal floor |
37
+ | Hermes / custom (`adapters/hermes/middleware.py`) | `from statutor_core import validate` | full in-loop |
38
+
39
+ ¹ in-loop for write/edit/bash; `apply_patch` (opencode substitutes it for
40
+ write/edit on GPT-5-class models) and server-namespaced MCP tool ids are
41
+ not matched by the allowlist — git floor covers them. Subagent tool calls
42
+ DO fire plugin hooks (verified opencode v1.18.21, 2026-08-21); the
43
+ opposite claim (sst/opencode#5894) was a misdiagnosis, stale-closed
44
+ 2026-04-15.
45
+ ² Codex hooks are on by default since rust-v0.124.0 (2026-04-23) — the old
46
+ `[features].codex_hooks` flag is a deprecated legacy alias, and hooks need
47
+ a one-time trust approval (`/hooks`). PreToolUse fires for apply_patch
48
+ too, but Codex sends edits as tool_name `apply_patch` + `{"command":
49
+ "<patch>"}`, which the kernel doesn't parse yet — so statutor's in-loop
50
+ coverage is the bash guard, and the git floor is mandatory there.
51
+
52
+ ## Install
53
+
54
+ pipx install statutor # or: pip install -e .
55
+ statutor init . # scaffold any repo, any harness
56
+
57
+ # Claude Code (this repo doubles as the plugin):
58
+ /plugin marketplace add <path-or-url>
59
+ /plugin install statutor@hoo-plugins --scope project
60
+ # then: /statutor-init /handoff /decide /statutor-doctor
61
+
62
+ # git floor for every repo:
63
+ # .pre-commit-config.yaml → repo: <this repo>, hooks: [{id: statutor}]
64
+
65
+ Per-repo policy overrides: `.statutor.yaml` (embedded defaults apply without it).
66
+
67
+ ## Provenance
68
+
69
+ Framework doctrine distilled from: the AGENTS.md open standard
70
+ <https://agents.md/>, Claude Code memory & hooks docs
71
+ <https://code.claude.com/docs/en/memory>, <https://code.claude.com/docs/en/hooks>,
72
+ Anthropic on long-running agent harnesses
73
+ <https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents>
74
+ and context engineering
75
+ <https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents>,
76
+ MADR <https://adr.github.io/madr/>, Keep a Changelog
77
+ <https://keepachangelog.com/>, Conventional Commits
78
+ <https://www.conventionalcommits.org/>, pre-commit <https://pre-commit.com/>.
@@ -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,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ core/statutor_core.py
4
+ core/statutor_doctor.py
5
+ core/statutor.egg-info/PKG-INFO
6
+ core/statutor.egg-info/SOURCES.txt
7
+ core/statutor.egg-info/dependency_links.txt
8
+ core/statutor.egg-info/entry_points.txt
9
+ core/statutor.egg-info/top_level.txt
10
+ tests/test_doctor.py
11
+ tests/test_kernel.py
12
+ tests/test_stop_hook.py
@@ -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
@@ -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()