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.
- statutor-0.2.0/PKG-INFO +5 -0
- statutor-0.2.0/README.md +78 -0
- statutor-0.2.0/core/statutor.egg-info/PKG-INFO +5 -0
- statutor-0.2.0/core/statutor.egg-info/SOURCES.txt +12 -0
- statutor-0.2.0/core/statutor.egg-info/dependency_links.txt +1 -0
- statutor-0.2.0/core/statutor.egg-info/entry_points.txt +3 -0
- statutor-0.2.0/core/statutor.egg-info/top_level.txt +2 -0
- statutor-0.2.0/core/statutor_core.py +423 -0
- statutor-0.2.0/core/statutor_doctor.py +184 -0
- statutor-0.2.0/pyproject.toml +17 -0
- statutor-0.2.0/setup.cfg +4 -0
- statutor-0.2.0/tests/test_doctor.py +523 -0
- statutor-0.2.0/tests/test_kernel.py +1289 -0
- statutor-0.2.0/tests/test_stop_hook.py +168 -0
statutor-0.2.0/PKG-INFO
ADDED
statutor-0.2.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|