statutor 0.2.0__tar.gz → 0.3.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.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hoo (github.com/hoohugokim)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: statutor
3
+ Version: 0.3.0
4
+ Summary: Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies.
5
+ Author: Hoo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/hoohugokim/statutor
8
+ Project-URL: Repository, https://github.com/hoohugokim/statutor
9
+ Project-URL: Issues, https://github.com/hoohugokim/statutor/issues
10
+ Keywords: agents,claude,opencode,codex,ledger,governance,hooks,pre-commit,adr,decision-records
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # statutor
27
+
28
+ Typed project-ledger framework for agentic repos. A *statutor* is one who
29
+ enacts (agent noun of *statuere*) — which is the thesis: repo instruction
30
+ files are a state machine of typed registers, each with a mutation policy
31
+ and exactly one writer, enforced by hooks and git, not by prose.
32
+
33
+ | Plane | Files | Policy | Enforced by |
34
+ |---|---|---|---|
35
+ | Constitution | AGENTS.md (+ CLAUDE.md = `@AGENTS.md`) | hard cap 200 lines | hook + git floor |
36
+ | State | HANDOFF.md | overwrite-only, ≤ 40 lines, required sections | hook + git floor |
37
+ | State | TASKS.md | stable T-NNNN ids | doctor |
38
+ | Log | DECISIONS.md | append-only, insertions only, supersede-never-edit | hook + git floor |
39
+ | Plan | ROADMAP.md, plans/ → plans/archive/ (frozen) | archive immutable | hook + git floor |
40
+
41
+ Plus a **bash guard** on every harness: shell writes to governed files
42
+ (`>>`, `sed -i`, `tee`, ...) are denied — the editor tools are the audited
43
+ path. No hand-maintained CHANGELOG.md: git log + conventional commits.
44
+
45
+ ## Kernel / adapter architecture
46
+
47
+ core/statutor_core.py single-file kernel: validate() + embedded templates
48
+ modes: hook | check | staged | init (fail-open hooks)
49
+ core/statutor_doctor.py drift linter (stale stamps, budgets, unarchived plans)
50
+ hooks/stop_doctor.py Claude Code Stop hook: runs statutor-doctor after each
51
+ turn and surfaces its WARN/ERROR lines as
52
+ additionalContext — non-blocking, silent when the
53
+ ledger is clean or the cwd isn't a statutor ledger at all
54
+ pyproject.toml pipx install → `statutor`, `statutor-doctor` on PATH
55
+
56
+ | Adapter | Mechanism | Coverage |
57
+ |---|---|---|
58
+ | Claude Code (repo root is the plugin) | PreToolUse `Write\|Edit\|Bash` → `statutor hook`; Stop → `hooks/stop_doctor.py` | full in-loop + drift surfacing |
59
+ | OpenCode (`adapters/opencode/statutor.ts`) | `tool.execute.before` → `statutor check` | in-loop (write/edit/bash)¹ |
60
+ | Codex CLI (`adapters/codex/`) | PreToolUse (Claude-compatible protocol) → `statutor hook` | bash guard + apply_patch² |
61
+ | git (`adapters/git/`, `.pre-commit-hooks.yaml`) | `statutor staged` on pre-commit / pre-receive | universal floor |
62
+ | git servers (`crates/statutor/`) | static `statutor-staged` binary for pre-receive, conformance-gated ≡ Python (D-0014) | universal floor, no runtime |
63
+ | Custom harnesses (`statutor check`, or import `validate`) | embed in your own tool dispatch | full in-loop |
64
+
65
+ ¹ in-loop for write/edit/bash/apply_patch; the kernel parses apply_patch
66
+ envelopes (T-0011), with two partial-diff blind spots: required sections
67
+ on an Update File and server-namespaced MCP tool ids — the git floor
68
+ covers both. Subagent tool calls
69
+ DO fire plugin hooks (verified opencode v1.18.21, 2026-08-21); the
70
+ opposite claim (sst/opencode#5894) was a misdiagnosis, stale-closed
71
+ 2026-04-15.
72
+ ² Codex hooks are on by default since rust-v0.124.0 (2026-04-23) — the old
73
+ `[features].codex_hooks` flag is a deprecated legacy alias, and hooks need
74
+ a one-time trust approval (`/hooks`). Codex sends edits as tool_name
75
+ `apply_patch` + `{"command":
76
+ "<envelope>"}`, which `guard_apply_patch()` parses (frozen/delete/
77
+ append-only/cap checks; see adapters/codex/). Residual gaps: MCP tools
78
+ and Update-File section checks — so the git floor remains
79
+ mandatory there.
80
+
81
+ ## Install
82
+
83
+ pipx install statutor # or: pip install -e .
84
+ statutor init . # scaffold any repo, any harness
85
+
86
+ # Claude Code (this repo doubles as the plugin):
87
+ /plugin marketplace add <path-or-url>
88
+ /plugin install statutor@hoo-plugins --scope project
89
+ # then: /statutor-init /handoff /decide /statutor-doctor
90
+
91
+ # git floor for every repo:
92
+ # .pre-commit-config.yaml → repo: <this repo>, hooks: [{id: statutor}]
93
+
94
+ Per-repo policy overrides: `.statutor.yaml` (embedded defaults apply without it).
95
+
96
+ ## Provenance
97
+
98
+ Framework doctrine distilled from: the AGENTS.md open standard
99
+ <https://agents.md/>, Claude Code memory & hooks docs
100
+ <https://code.claude.com/docs/en/memory>, <https://code.claude.com/docs/en/hooks>,
101
+ Anthropic on long-running agent harnesses
102
+ <https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents>
103
+ and context engineering
104
+ <https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents>,
105
+ MADR <https://adr.github.io/madr/>, Keep a Changelog
106
+ <https://keepachangelog.com/>, Conventional Commits
107
+ <https://www.conventionalcommits.org/>, pre-commit <https://pre-commit.com/>.
@@ -32,22 +32,26 @@ path. No hand-maintained CHANGELOG.md: git log + conventional commits.
32
32
  |---|---|---|
33
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
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² |
35
+ | Codex CLI (`adapters/codex/`) | PreToolUse (Claude-compatible protocol) → `statutor hook` | bash guard + apply_patch² |
36
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 |
37
+ | git servers (`crates/statutor/`) | static `statutor-staged` binary for pre-receive, conformance-gated Python (D-0014) | universal floor, no runtime |
38
+ | Custom harnesses (`statutor check`, or import `validate`) | embed in your own tool dispatch | full in-loop |
38
39
 
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
40
+ ¹ in-loop for write/edit/bash/apply_patch; the kernel parses apply_patch
41
+ envelopes (T-0011), with two partial-diff blind spots: required sections
42
+ on an Update File and server-namespaced MCP tool ids the git floor
43
+ covers both. Subagent tool calls
42
44
  DO fire plugin hooks (verified opencode v1.18.21, 2026-08-21); the
43
45
  opposite claim (sst/opencode#5894) was a misdiagnosis, stale-closed
44
46
  2026-04-15.
45
47
  ² Codex hooks are on by default since rust-v0.124.0 (2026-04-23) — the old
46
48
  `[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.
49
+ a one-time trust approval (`/hooks`). Codex sends edits as tool_name
50
+ `apply_patch` + `{"command":
51
+ "<envelope>"}`, which `guard_apply_patch()` parses (frozen/delete/
52
+ append-only/cap checks; see adapters/codex/). Residual gaps: MCP tools
53
+ and Update-File section checks — so the git floor remains
54
+ mandatory there.
51
55
 
52
56
  ## Install
53
57
 
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: statutor
3
+ Version: 0.3.0
4
+ Summary: Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies.
5
+ Author: Hoo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/hoohugokim/statutor
8
+ Project-URL: Repository, https://github.com/hoohugokim/statutor
9
+ Project-URL: Issues, https://github.com/hoohugokim/statutor/issues
10
+ Keywords: agents,claude,opencode,codex,ledger,governance,hooks,pre-commit,adr,decision-records
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # statutor
27
+
28
+ Typed project-ledger framework for agentic repos. A *statutor* is one who
29
+ enacts (agent noun of *statuere*) — which is the thesis: repo instruction
30
+ files are a state machine of typed registers, each with a mutation policy
31
+ and exactly one writer, enforced by hooks and git, not by prose.
32
+
33
+ | Plane | Files | Policy | Enforced by |
34
+ |---|---|---|---|
35
+ | Constitution | AGENTS.md (+ CLAUDE.md = `@AGENTS.md`) | hard cap 200 lines | hook + git floor |
36
+ | State | HANDOFF.md | overwrite-only, ≤ 40 lines, required sections | hook + git floor |
37
+ | State | TASKS.md | stable T-NNNN ids | doctor |
38
+ | Log | DECISIONS.md | append-only, insertions only, supersede-never-edit | hook + git floor |
39
+ | Plan | ROADMAP.md, plans/ → plans/archive/ (frozen) | archive immutable | hook + git floor |
40
+
41
+ Plus a **bash guard** on every harness: shell writes to governed files
42
+ (`>>`, `sed -i`, `tee`, ...) are denied — the editor tools are the audited
43
+ path. No hand-maintained CHANGELOG.md: git log + conventional commits.
44
+
45
+ ## Kernel / adapter architecture
46
+
47
+ core/statutor_core.py single-file kernel: validate() + embedded templates
48
+ modes: hook | check | staged | init (fail-open hooks)
49
+ core/statutor_doctor.py drift linter (stale stamps, budgets, unarchived plans)
50
+ hooks/stop_doctor.py Claude Code Stop hook: runs statutor-doctor after each
51
+ turn and surfaces its WARN/ERROR lines as
52
+ additionalContext — non-blocking, silent when the
53
+ ledger is clean or the cwd isn't a statutor ledger at all
54
+ pyproject.toml pipx install → `statutor`, `statutor-doctor` on PATH
55
+
56
+ | Adapter | Mechanism | Coverage |
57
+ |---|---|---|
58
+ | Claude Code (repo root is the plugin) | PreToolUse `Write\|Edit\|Bash` → `statutor hook`; Stop → `hooks/stop_doctor.py` | full in-loop + drift surfacing |
59
+ | OpenCode (`adapters/opencode/statutor.ts`) | `tool.execute.before` → `statutor check` | in-loop (write/edit/bash)¹ |
60
+ | Codex CLI (`adapters/codex/`) | PreToolUse (Claude-compatible protocol) → `statutor hook` | bash guard + apply_patch² |
61
+ | git (`adapters/git/`, `.pre-commit-hooks.yaml`) | `statutor staged` on pre-commit / pre-receive | universal floor |
62
+ | git servers (`crates/statutor/`) | static `statutor-staged` binary for pre-receive, conformance-gated ≡ Python (D-0014) | universal floor, no runtime |
63
+ | Custom harnesses (`statutor check`, or import `validate`) | embed in your own tool dispatch | full in-loop |
64
+
65
+ ¹ in-loop for write/edit/bash/apply_patch; the kernel parses apply_patch
66
+ envelopes (T-0011), with two partial-diff blind spots: required sections
67
+ on an Update File and server-namespaced MCP tool ids — the git floor
68
+ covers both. Subagent tool calls
69
+ DO fire plugin hooks (verified opencode v1.18.21, 2026-08-21); the
70
+ opposite claim (sst/opencode#5894) was a misdiagnosis, stale-closed
71
+ 2026-04-15.
72
+ ² Codex hooks are on by default since rust-v0.124.0 (2026-04-23) — the old
73
+ `[features].codex_hooks` flag is a deprecated legacy alias, and hooks need
74
+ a one-time trust approval (`/hooks`). Codex sends edits as tool_name
75
+ `apply_patch` + `{"command":
76
+ "<envelope>"}`, which `guard_apply_patch()` parses (frozen/delete/
77
+ append-only/cap checks; see adapters/codex/). Residual gaps: MCP tools
78
+ and Update-File section checks — so the git floor remains
79
+ mandatory there.
80
+
81
+ ## Install
82
+
83
+ pipx install statutor # or: pip install -e .
84
+ statutor init . # scaffold any repo, any harness
85
+
86
+ # Claude Code (this repo doubles as the plugin):
87
+ /plugin marketplace add <path-or-url>
88
+ /plugin install statutor@hoo-plugins --scope project
89
+ # then: /statutor-init /handoff /decide /statutor-doctor
90
+
91
+ # git floor for every repo:
92
+ # .pre-commit-config.yaml → repo: <this repo>, hooks: [{id: statutor}]
93
+
94
+ Per-repo policy overrides: `.statutor.yaml` (embedded defaults apply without it).
95
+
96
+ ## Provenance
97
+
98
+ Framework doctrine distilled from: the AGENTS.md open standard
99
+ <https://agents.md/>, Claude Code memory & hooks docs
100
+ <https://code.claude.com/docs/en/memory>, <https://code.claude.com/docs/en/hooks>,
101
+ Anthropic on long-running agent harnesses
102
+ <https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents>
103
+ and context engineering
104
+ <https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents>,
105
+ MADR <https://adr.github.io/madr/>, Keep a Changelog
106
+ <https://keepachangelog.com/>, Conventional Commits
107
+ <https://www.conventionalcommits.org/>, pre-commit <https://pre-commit.com/>.
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  core/statutor_core.py
@@ -7,6 +8,8 @@ core/statutor.egg-info/SOURCES.txt
7
8
  core/statutor.egg-info/dependency_links.txt
8
9
  core/statutor.egg-info/entry_points.txt
9
10
  core/statutor.egg-info/top_level.txt
11
+ tests/test_conformance_rust.py
10
12
  tests/test_doctor.py
11
13
  tests/test_kernel.py
14
+ tests/test_pre_commit.py
12
15
  tests/test_stop_hook.py
@@ -9,13 +9,14 @@ Entry modes (all share the same validate() core):
9
9
  statutor hook Claude Code / Codex CLI hook protocol:
10
10
  stdin JSON in, permissionDecision JSON out.
11
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/.)
12
+ also fires for apply_patch, sending edits as
13
+ tool_input {"command": "<apply_patch envelope>"}.
14
+ validate() parses that envelope — see
15
+ guard_apply_patch(); unknown payload shapes fall
16
+ through unhandled, and the git floor stays
17
+ mandatory as the backstop. See adapters/codex/.)
17
18
  statutor check TOOL JSON [CWD]
18
- Generic shim mode for OpenCode / Hermes / tests.
19
+ Generic shim mode for OpenCode / custom harnesses / tests.
19
20
  exit 0 = allow, exit 2 = deny (reason on stderr).
20
21
  statutor staged [CWD] Git floor: validate staged changes (pre-commit).
21
22
  exit 1 on violations.
@@ -110,6 +111,8 @@ def validate(tool: str, payload: dict, cwd: str, policy: dict | None = None) ->
110
111
 
111
112
  if tool == "bash":
112
113
  return guard_bash(payload.get("command", ""), policy)
114
+ if tool == "apply_patch":
115
+ return guard_apply_patch(payload, cwd, policy)
113
116
  if tool not in ("write", "edit"):
114
117
  return None
115
118
 
@@ -187,6 +190,159 @@ def guard_bash(command: str, policy: dict) -> str | None:
187
190
  return None
188
191
 
189
192
 
193
+ # --------------------------------------------------------------------------
194
+ # apply_patch envelope (Codex / opencode GPT-5-class edit path)
195
+ # --------------------------------------------------------------------------
196
+
197
+ _AP_HEADER_RE = re.compile(r"^\*\*\* (Update File|Add File|Delete File|Move to):\s*(.+?)\s*$")
198
+ _AP_OPS = {"update file": "update", "add file": "add", "delete file": "delete"}
199
+
200
+
201
+ def _patch_targets(text: str) -> list[dict]:
202
+ """Split an apply_patch envelope into per-file ops, in document order.
203
+
204
+ Each target: {op, path, move_to, plus, minus, content} where plus/minus
205
+ are the raw hunk lines and content is an Add File's decoded body.
206
+ Context (" "), hunk anchors (@@), and anything before *** Begin Patch
207
+ are ignored — this is a policy scan, not a patch applier.
208
+ """
209
+ targets: list[dict] = []
210
+ cur: dict | None = None
211
+ inside = False
212
+ for line in text.splitlines():
213
+ stripped = line.strip()
214
+ if stripped == "*** Begin Patch":
215
+ inside = True
216
+ continue
217
+ if stripped == "*** End Patch":
218
+ break
219
+ if not inside:
220
+ continue
221
+ m = _AP_HEADER_RE.match(line)
222
+ if m:
223
+ kind, path = m.group(1).lower(), m.group(2)
224
+ if kind == "move to":
225
+ if cur is not None:
226
+ cur["move_to"] = path
227
+ continue
228
+ cur = {"op": _AP_OPS[kind], "path": path, "move_to": None,
229
+ "plus": [], "minus": [], "content": []}
230
+ targets.append(cur)
231
+ continue
232
+ if cur is None:
233
+ continue
234
+ if line.startswith("+"):
235
+ cur["plus"].append(line)
236
+ cur["content"].append(line[1:])
237
+ elif line.startswith("-"):
238
+ cur["minus"].append(line)
239
+ return targets
240
+
241
+
242
+ def _resolve_patch_path(path: str, cwd: str) -> str:
243
+ """Policy-relative form of a patch path (same resolution write/edit get:
244
+ absolute-ize against cwd, then relativize back)."""
245
+ return os.path.relpath(os.path.abspath(os.path.join(cwd, path)), os.path.abspath(cwd))
246
+
247
+
248
+ def _size_reason(rel: str, kind: str, rule: dict, content: str) -> str | None:
249
+ """Cap/sections denial for a fully-known body (write tool and Add File)."""
250
+ n = content.count("\n") + 1
251
+ if kind == "constitution":
252
+ hard = int(rule.get("hard_max_lines", 200))
253
+ if n > hard:
254
+ return (f"{rel} would be {n} lines (hard cap {hard}). The constitution "
255
+ "carries only what the repo cannot say itself.")
256
+ elif kind == "overwrite_bounded":
257
+ cap = int(rule.get("max_lines", 40))
258
+ if n > cap:
259
+ return (f"{rel} would be {n} lines (cap {cap}). HANDOFF is a shift-change "
260
+ "note, not a log: overwrite, compress, drop history.")
261
+ missing = [s for s in rule.get("required_sections", []) if s not in content]
262
+ if missing:
263
+ return (f"{rel} is missing required sections: {', '.join(missing)}. "
264
+ "A handoff without these fields strands the next session.")
265
+ return None
266
+
267
+
268
+ def guard_apply_patch(payload: dict, cwd: str, policy: dict) -> str | None:
269
+ """Policy-check an apply_patch envelope.
270
+
271
+ Codex PreToolUse delivers edits as tool_name apply_patch with
272
+ tool_input {"command": "<envelope>"}; opencode substitutes apply_patch
273
+ for write/edit on GPT-5-class models. Semantics mirror the other layers:
274
+
275
+ * any touch of a frozen path is denied (arrival INTO plans/archive/
276
+ stays allowed, matching the staged rename rule);
277
+ * Delete File on a governed constitution/overwrite_bounded/append_only
278
+ path is denied wholesale — records are superseded, never removed
279
+ (state-policy files stay deletable, matching the bash guard's gap);
280
+ * Add File on a sized policy runs the full cap + required-sections
281
+ check (the body is fully known);
282
+ * Update File on append_only denies any deleting/modifying hunk line;
283
+ on sized policies it estimates the resulting line count from the
284
+ on-disk file plus adds-minus-dels (required sections cannot be
285
+ verified from a partial diff — the git floor covers that).
286
+
287
+ Unknown payload shapes fall through silently (None): parsing here must
288
+ never be load-bearing for enforcement the git floor also provides.
289
+ """
290
+ text = payload.get("command", payload.get("patch", ""))
291
+ if not isinstance(text, str) or "*** Begin Patch" not in text:
292
+ return None
293
+
294
+ for t in _patch_targets(text):
295
+ rel = _resolve_patch_path(t["path"], cwd)
296
+ rule = _match_rule(rel, policy)
297
+ kind = rule.get("policy", "") if rule else ""
298
+
299
+ if t["op"] == "delete":
300
+ if kind == "frozen":
301
+ return f"{rel} is frozen (archived plan). Archived records are immutable."
302
+ if kind in ("constitution", "overwrite_bounded", "append_only"):
303
+ return (f"{rel} is governed ({kind}): apply_patch cannot delete it. "
304
+ "Records are superseded, never removed.")
305
+ continue
306
+
307
+ move_rel = _resolve_patch_path(t["move_to"], cwd) if t["move_to"] else None
308
+ if move_rel is not None and kind == "frozen":
309
+ return (f"{rel} is frozen (archived plan). Moving a record OUT of "
310
+ "the archive is denied.")
311
+
312
+ if t["op"] == "add":
313
+ if kind == "frozen":
314
+ return f"{rel} is frozen (archived plan). Archived records are immutable."
315
+ if rule is None:
316
+ continue
317
+ reason = _size_reason(rel, kind, rule, "\n".join(t["content"]))
318
+ if reason:
319
+ return reason
320
+ continue
321
+
322
+ # update
323
+ if kind == "frozen":
324
+ return f"{rel} is frozen (archived plan). Archived records are immutable."
325
+ if rule is None:
326
+ continue
327
+ dels, adds = len(t["minus"]), len(t["plus"])
328
+ if kind == "append_only":
329
+ if dels:
330
+ return (f"{rel} is append-only, but the patch deletes/modifies "
331
+ f"{dels} line(s). Append superseding records instead.")
332
+ continue
333
+ if kind in ("constitution", "overwrite_bounded") and (adds or dels):
334
+ cap_key = "hard_max_lines" if kind == "constitution" else "max_lines"
335
+ cap = int(rule.get(cap_key, 200))
336
+ try:
337
+ cur_n = open(os.path.join(cwd, rel), encoding="utf-8").read().count("\n") + 1
338
+ except OSError:
339
+ cur_n = None # unreadable/unmapped path: let the floor judge
340
+ if cur_n is not None and cur_n + adds - dels > cap:
341
+ est = cur_n + adds - dels
342
+ return (f"{rel} would grow to ~{est} lines (cap {cap}).")
343
+ return None
344
+
345
+
190
346
  # --------------------------------------------------------------------------
191
347
  # entry: hook (Claude Code / Codex protocol) — must fail open
192
348
  # --------------------------------------------------------------------------
@@ -210,7 +366,7 @@ def run_hook() -> int:
210
366
 
211
367
 
212
368
  # --------------------------------------------------------------------------
213
- # entry: check (generic shim for OpenCode / Hermes / tests)
369
+ # entry: check (generic shim for OpenCode / custom harnesses / tests)
214
370
  # --------------------------------------------------------------------------
215
371
 
216
372
  def run_check(argv: list[str]) -> int:
@@ -231,8 +387,11 @@ def run_check(argv: list[str]) -> int:
231
387
  # --------------------------------------------------------------------------
232
388
 
233
389
  def _git(cwd: str, *args: str) -> str:
234
- return subprocess.run(["git", *args], cwd=cwd, capture_output=True,
235
- text=True, check=False).stdout
390
+ # -c color.ui=false pins machine-readable output: a user gitconfig with
391
+ # color.ui=always would otherwise ANSI-colorize diff lines, and the
392
+ # append-only scan (startswith("-")) would silently stop seeing deletions.
393
+ return subprocess.run(["git", "-c", "color.ui=false", *args], cwd=cwd,
394
+ capture_output=True, text=True, check=False).stdout
236
395
 
237
396
 
238
397
  def run_staged(cwd: str) -> int:
@@ -28,6 +28,10 @@ key (or the whole rule) is absent:
28
28
  * the overwrite_bounded filename comes from that rule's pattern (same
29
29
  restriction); staleness threshold: optional `stale_after_days`;
30
30
  required sections: optional `required_sections`
31
+ * the state-plane (TASKS) filename and the append-only (DECISIONS)
32
+ filename come from their rules' patterns (same restriction); the plans/
33
+ directory itself stays conventional — `statutor init` scaffolds it and
34
+ no governed basename can express a directory
31
35
 
32
36
  Exit code 1 on errors, 0 on clean/warnings-only.
33
37
  """
@@ -103,6 +107,12 @@ def check(root: str) -> None:
103
107
  handoff_stale_days = int(overwrite_rule.get("stale_after_days", HANDOFF_STALE_DAYS)) \
104
108
  if overwrite_rule else HANDOFF_STALE_DAYS
105
109
 
110
+ state_rule = next((r for r in governed if r.get("policy") == "state"), None)
111
+ tasks_filename = _rule_filename(state_rule, "TASKS.md")
112
+
113
+ append_only_rule = next((r for r in governed if r.get("policy") == "append_only"), None)
114
+ decisions_filename = _rule_filename(append_only_rule, "DECISIONS.md")
115
+
106
116
  for name in check_names:
107
117
  if not os.path.isfile(p(name)):
108
118
  errors.append(f"missing governed file: {name} (run /ledger-init)")
@@ -137,8 +147,8 @@ def check(root: str) -> None:
137
147
  )
138
148
 
139
149
  done_ids: set[str] = set()
140
- if os.path.isfile(p("TASKS.md")):
141
- for line in open(p("TASKS.md"), encoding="utf-8"):
150
+ if os.path.isfile(p(tasks_filename)):
151
+ for line in open(p(tasks_filename), encoding="utf-8"):
142
152
  m = re.match(r"- \[x\]\s+(\S+)", line, re.IGNORECASE)
143
153
  if m:
144
154
  done_ids.add(m.group(1))
@@ -157,13 +167,13 @@ def check(root: str) -> None:
157
167
  "move to plans/archive/ (consumed plans are stale intent)."
158
168
  )
159
169
 
160
- if os.path.isfile(p("DECISIONS.md")):
161
- body = open(p("DECISIONS.md"), encoding="utf-8").read()
170
+ if os.path.isfile(p(decisions_filename)):
171
+ body = open(p(decisions_filename), encoding="utf-8").read()
162
172
  records = re.findall(r"^## D-\d+", body, re.MULTILINE)
163
173
  statuses = re.findall(r"^\*\*Status:\*\*", body, re.MULTILINE)
164
174
  if len(statuses) < len(records):
165
175
  warnings.append(
166
- f"DECISIONS.md: {len(records)} records but only {len(statuses)} "
176
+ f"{decisions_filename}: {len(records)} records but only {len(statuses)} "
167
177
  "Status fields — every record needs one."
168
178
  )
169
179
 
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "statutor"
7
+ version = "0.3.0"
8
+ description = "Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Hoo" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["agents", "claude", "opencode", "codex", "ledger",
15
+ "governance", "hooks", "pre-commit", "adr", "decision-records"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Software Development :: Quality Assurance",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/hoohugokim/statutor"
31
+ Repository = "https://github.com/hoohugokim/statutor"
32
+ Issues = "https://github.com/hoohugokim/statutor/issues"
33
+
34
+ [project.scripts]
35
+ statutor = "statutor_core:main"
36
+ statutor-doctor = "statutor_doctor:main"
37
+
38
+ [tool.setuptools]
39
+ package-dir = { "" = "core" }
40
+ py-modules = ["statutor_core", "statutor_doctor"]
@@ -0,0 +1,117 @@
1
+ """Rust↔Python staged-floor conformance (T-0021, licensed by D-0014).
2
+
3
+ For every scenario in conformance_scenarios: build the repo state, then run
4
+ BOTH kernels and assert byte-identical verdicts — exit code AND stdout.
5
+ Python is normative; any divergence fails CI, which is the only reason the
6
+ Rust duplicate (crates/statutor) may exist.
7
+
8
+ Binary resolution: $STATUTOR_STAGED_BIN if set, else `cargo build --release`
9
+ in crates/statutor. Skips when neither exists, mirroring the suite's other
10
+ optional-dependency skips.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ import conformance_scenarios as cs
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parents[1]
27
+ KERNEL = REPO_ROOT / "core" / "statutor_core.py"
28
+ CRATE = REPO_ROOT / "crates" / "statutor"
29
+
30
+ NO_GIT = cs.no_git()
31
+ git_required = pytest.mark.skipif(NO_GIT, reason="git not available")
32
+
33
+ # Scenarios whose whole point is a .statutor.yaml the PYTHON side must parse:
34
+ # without PyYAML the Python kernel silently falls back to embedded defaults
35
+ # while Rust applies the file — an environmental false divergence, not a
36
+ # behavioral one. They run in CI's pyyaml leg.
37
+ PYAML_AVAILABLE = importlib.util.find_spec("yaml") is not None
38
+ NEEDS_PYAML = {"s25_statutor_yaml_governed_empty", "s29_statutor_yaml_custom_names"}
39
+
40
+
41
+ def _cargo_available() -> bool:
42
+ return shutil.which("cargo") is not None
43
+
44
+
45
+ @pytest.fixture(scope="session")
46
+ def staged_bin() -> Path:
47
+ env_bin = os.environ.get("STATUTOR_STAGED_BIN")
48
+ if env_bin:
49
+ p = Path(env_bin)
50
+ if not p.exists():
51
+ pytest.fail(f"STATUTOR_STAGED_BIN points at missing binary: {env_bin}")
52
+ return p
53
+ if not _cargo_available():
54
+ pytest.skip("no STATUTOR_STAGED_BIN and cargo unavailable")
55
+ build = subprocess.run(
56
+ ["cargo", "build", "-q", "--release", "--manifest-path",
57
+ str(CRATE / "Cargo.toml")],
58
+ capture_output=True, text=True)
59
+ if build.returncode != 0:
60
+ pytest.fail(f"cargo build failed:\n{build.stderr}")
61
+ bin_path = CRATE / "target" / "release" / "statutor-staged"
62
+ assert bin_path.exists(), "cargo reported success but binary is absent"
63
+ return bin_path
64
+
65
+
66
+ def _run_kernel(repo: Path) -> subprocess.CompletedProcess:
67
+ return subprocess.run([sys.executable, str(KERNEL), "staged", str(repo)],
68
+ env=cs.GIT_ENV, capture_output=True, text=True)
69
+
70
+
71
+ def _run_rust(bin_path: Path, repo: Path) -> subprocess.CompletedProcess:
72
+ return subprocess.run([str(bin_path), str(repo)],
73
+ env=cs.GIT_ENV, capture_output=True, text=True)
74
+
75
+
76
+ def _scenario_ids() -> list[str]:
77
+ return sorted(cs.SCENARIOS)
78
+
79
+
80
+ @git_required
81
+ @pytest.mark.parametrize("name", _scenario_ids())
82
+ def test_rust_matches_python(tmp_path: Path, name: str, staged_bin: Path) -> None:
83
+ if name in NEEDS_PYAML and not PYAML_AVAILABLE:
84
+ pytest.skip("scenario needs a PyYAML-capable python kernel")
85
+ repo = tmp_path / name
86
+ repo.mkdir()
87
+ cs.SCENARIOS[name](repo)
88
+
89
+ py = _run_kernel(repo)
90
+ rs = _run_rust(staged_bin, repo)
91
+
92
+ assert py.returncode == rs.returncode, (
93
+ f"{name}: exit codes diverge (python={py.returncode} rust={rs.returncode})\n"
94
+ f"python stdout:\n{py.stdout}\nrust stdout:\n{rs.stdout}")
95
+ assert py.stdout == rs.stdout, (
96
+ f"{name}: stdout diverges\npython:\n{py.stdout!r}\nrust:\n{rs.stdout!r}")
97
+
98
+
99
+ @git_required
100
+ def test_colored_gitconfig_diverges_nothing(tmp_path: Path, staged_bin: Path,
101
+ monkeypatch: pytest.MonkeyPatch) -> None:
102
+ """The post-fix kernels pin color.ui=false themselves, so a hostile user
103
+ gitconfig must not change EITHER implementation's verdict."""
104
+ repo = tmp_path / "colored"
105
+ repo.mkdir()
106
+ cs.s03_delete_last_line(repo)
107
+ colorful = tmp_path / "colorful.gitconfig"
108
+ colorful.write_text("[color]\n\tui = always\n", encoding="utf-8")
109
+ env = {**cs.GIT_ENV, "GIT_CONFIG_GLOBAL": str(colorful)}
110
+
111
+ py = subprocess.run([sys.executable, str(KERNEL), "staged", str(repo)],
112
+ env=env, capture_output=True, text=True)
113
+ rs = subprocess.run([str(staged_bin), str(repo)],
114
+ env=env, capture_output=True, text=True)
115
+
116
+ assert py.returncode == rs.returncode == 1
117
+ assert py.stdout == rs.stdout != ""
@@ -521,3 +521,63 @@ def test_all_required_sections_present_no_error(tmp_path, monkeypatch, capsys):
521
521
  out, code = run_doctor(monkeypatch, capsys, tmp_path)
522
522
  assert code == 0
523
523
  assert "missing required sections" not in out
524
+
525
+
526
+ # --------------------------------------------------------------------------
527
+ # T-0013: remaining hardcoded names derived from policy
528
+ # --------------------------------------------------------------------------
529
+
530
+ def test_custom_state_filename_drives_done_ids_and_plan_heuristic_stub_yaml(tmp_path, monkeypatch, capsys):
531
+ """The consumed-plan heuristic must read done ids from whichever file the
532
+ state rule governs (here BACKLOG.md), not the literal TASKS.md: with
533
+ BACKLOG.md marking T-0001 done and TASKS.md leaving it open, the plan
534
+ referencing T-0001 must still be flagged."""
535
+ policy = {
536
+ "bash_guard": True,
537
+ "governed": [
538
+ {"pattern": "AGENTS.md", "policy": "constitution", "hard_max_lines": 200},
539
+ {"pattern": "HANDOFF.md", "policy": "overwrite_bounded", "max_lines": 40,
540
+ "required_sections": ["## Goal", "## Last verified state", "## Next action",
541
+ "## Gotchas", "## Do not touch"]},
542
+ {"pattern": "DECISIONS.md", "policy": "append_only"},
543
+ {"pattern": "BACKLOG.md", "policy": "state"},
544
+ ],
545
+ }
546
+ _write_ledger(tmp_path, overrides={"TASKS.md": "- [ ] T-0001 open here\n"})
547
+ (tmp_path / "BACKLOG.md").write_text("- [x] T-0001 done there\n", encoding="utf-8")
548
+ plans_dir = tmp_path / "plans"
549
+ plans_dir.mkdir(exist_ok=True)
550
+ (plans_dir / "old-plan.md").write_text("Implements T-0001.\n", encoding="utf-8")
551
+ (tmp_path / ".statutor.yaml").write_text("placeholder: true\n", encoding="utf-8")
552
+ _stub_yaml(monkeypatch, policy)
553
+ out, code = run_doctor(monkeypatch, capsys, tmp_path)
554
+ assert code == 0
555
+ assert "plans/old-plan.md references completed task(s) ['T-0001']" in out
556
+
557
+
558
+ def test_custom_append_only_filename_status_check_stub_yaml(tmp_path, monkeypatch, capsys):
559
+ """The D-record Status check must apply to whichever file the append_only
560
+ rule governs (here CHOICES.md): a statusless CHOICES record warns by
561
+ name, while a statusless DECISIONS.md record goes unflagged because no
562
+ rule points at that filename anymore."""
563
+ policy = {
564
+ "bash_guard": True,
565
+ "governed": [
566
+ {"pattern": "AGENTS.md", "policy": "constitution", "hard_max_lines": 200},
567
+ {"pattern": "HANDOFF.md", "policy": "overwrite_bounded", "max_lines": 40,
568
+ "required_sections": ["## Goal", "## Last verified state", "## Next action",
569
+ "## Gotchas", "## Do not touch"]},
570
+ {"pattern": "CHOICES.md", "policy": "append_only"},
571
+ {"pattern": "TASKS.md", "policy": "state"},
572
+ ],
573
+ }
574
+ _write_ledger(tmp_path, overrides={
575
+ "DECISIONS.md": "# DECISIONS\n\n## D-0001\nno status field\n"})
576
+ (tmp_path / "CHOICES.md").write_text(
577
+ "# CHOICES\n\n## D-0001 — Example\n**Context:** x\n**Decision:** y\n", encoding="utf-8")
578
+ (tmp_path / ".statutor.yaml").write_text("placeholder: true\n", encoding="utf-8")
579
+ _stub_yaml(monkeypatch, policy)
580
+ out, code = run_doctor(monkeypatch, capsys, tmp_path)
581
+ assert code == 0
582
+ assert "CHOICES.md: 1 records but only 0 Status fields" in out
583
+ assert "DECISIONS.md" not in out
@@ -700,6 +700,182 @@ def test_bash_guard_scans_whole_multiline_command():
700
700
  assert statutor_core.guard_bash(command, POLICY) is not None
701
701
 
702
702
 
703
+ # --------------------------------------------------------------------------
704
+ # guard_apply_patch (AP-01..AP-18) — Codex / opencode GPT-5-class edit path
705
+ # --------------------------------------------------------------------------
706
+
707
+ def envelope(*sections: str) -> dict:
708
+ return {"command": "*** Begin Patch\n" + "\n".join(sections) + "\n*** End Patch"}
709
+
710
+
711
+ def ap(tmp_path, *sections: str) -> str | None:
712
+ return statutor_core.validate("apply_patch", envelope(*sections), str(tmp_path), POLICY)
713
+
714
+
715
+ @pytest.mark.parametrize("command", [
716
+ "sed -i s/a/b/ DECISIONS.md", # a bash command, not an envelope
717
+ "*** Update File: DECISIONS.md\n-x\n+y", # header without Begin Patch
718
+ ])
719
+ def test_apply_patch_unknown_payload_falls_through(tmp_path, command):
720
+ """Non-envelope payloads fall through silently — the git floor covers
721
+ whatever shape arrives; parsing here must never guess."""
722
+ assert statutor_core.validate("apply_patch", {"command": command}, str(tmp_path), POLICY) is None
723
+
724
+
725
+ def test_apply_patch_missing_command_key_falls_through(tmp_path):
726
+ assert statutor_core.validate("apply_patch", {}, str(tmp_path), POLICY) is None
727
+
728
+
729
+ def test_apply_patch_non_string_command_falls_through(tmp_path):
730
+ assert statutor_core.validate("apply_patch", {"command": 42}, str(tmp_path), POLICY) is None
731
+
732
+
733
+ def test_apply_patch_non_governed_update_passes(tmp_path):
734
+ assert ap(tmp_path, "*** Update File: src/app.py\n@@\n old\n+new") is None
735
+
736
+
737
+ def test_apply_patch_append_only_pure_addition_ok(tmp_path):
738
+ (tmp_path / "DECISIONS.md").write_text("## D-0001\nfirst\n", encoding="utf-8")
739
+ assert ap(tmp_path, "*** Update File: DECISIONS.md\n@@\n first\n+second") is None
740
+
741
+
742
+ def test_apply_patch_append_only_deletion_denied(tmp_path):
743
+ result = ap(tmp_path, "*** Update File: DECISIONS.md\n@@\n-first\n+first changed")
744
+ assert result is not None
745
+ assert "append-only" in result
746
+ assert "deletes/modifies 1 line(s)" in result
747
+
748
+
749
+ def test_apply_patch_append_only_add_file_ok(tmp_path):
750
+ assert ap(tmp_path, "*** Add File: docs/DRAFT.md\n+draft notes") is None
751
+
752
+
753
+ @pytest.mark.parametrize("path,policy_name", [
754
+ ("DECISIONS.md", "append_only"),
755
+ ("HANDOFF.md", "overwrite_bounded"),
756
+ ("AGENTS.md", "constitution"),
757
+ ])
758
+ def test_apply_patch_delete_governed_denied(tmp_path, path, policy_name):
759
+ result = ap(tmp_path, f"*** Delete File: {path}")
760
+ assert result is not None
761
+ assert f"governed ({policy_name})" in result
762
+ assert "superseded, never removed" in result
763
+
764
+
765
+ def test_apply_patch_delete_state_file_passes_quirk(tmp_path):
766
+ """State-plane files stay deletable everywhere (bash guard and staged
767
+ floor share this gap by design); apply_patch matches them, not stricter."""
768
+ assert ap(tmp_path, "*** Delete File: TASKS.md") is None
769
+
770
+
771
+ @pytest.mark.parametrize("section", [
772
+ "*** Add File: plans/archive/new.md\n+x",
773
+ "*** Update File: plans/archive/a1.md\n@@\n-old\n+new",
774
+ "*** Delete File: plans/archive/a1.md",
775
+ ])
776
+ def test_apply_patch_frozen_touches_denied(tmp_path, section):
777
+ (tmp_path / "plans" / "archive").mkdir(parents=True)
778
+ result = ap(tmp_path, section)
779
+ assert result is not None
780
+ assert "is frozen (archived plan)" in result
781
+
782
+
783
+ def test_apply_patch_move_out_of_archive_denied(tmp_path):
784
+ (tmp_path / "plans" / "archive").mkdir(parents=True)
785
+ result = ap(tmp_path,
786
+ "*** Update File: plans/archive/a1.md",
787
+ "*** Move to: plans/a1.md")
788
+ assert result is not None
789
+ assert "frozen" in result
790
+ assert "OUT of the archive" in result
791
+
792
+
793
+ def test_apply_patch_move_into_archive_allowed(tmp_path):
794
+ (tmp_path / "plans").mkdir()
795
+ assert ap(tmp_path,
796
+ "*** Update File: plans/p1.md",
797
+ "*** Move to: plans/archive/p1.md") is None
798
+
799
+
800
+ def test_apply_patch_add_handoff_over_cap_denied(tmp_path):
801
+ body = "\n".join(f"+l{i}" for i in range(41))
802
+ result = ap(tmp_path, f"*** Add File: HANDOFF.md\n{body}")
803
+ assert result is not None
804
+ assert "would be 41 lines (cap 40)" in result
805
+
806
+
807
+ def test_apply_patch_add_handoff_missing_sections_denied(tmp_path):
808
+ result = ap(tmp_path, "*** Add File: HANDOFF.md\n+# HANDOFF\n+unfilled")
809
+ assert result is not None
810
+ assert "missing required sections" in result
811
+ assert "## Goal" in result
812
+
813
+
814
+ def test_apply_patch_add_compliant_handoff_ok(tmp_path):
815
+ body = handoff()
816
+ plus = "\n".join("+" + l for l in body.splitlines())
817
+ assert ap(tmp_path, f"*** Add File: HANDOFF.md\n{plus}") is None
818
+
819
+
820
+ def test_apply_patch_add_agents_over_hard_cap_denied(tmp_path):
821
+ body = "\n".join(f"+x{i}" for i in range(201))
822
+ result = ap(tmp_path, f"*** Add File: AGENTS.md\n{body}")
823
+ assert result is not None
824
+ assert "would be 201 lines (hard cap 200)" in result
825
+
826
+
827
+ def test_apply_patch_update_estimated_growth_over_cap_denied(tmp_path):
828
+ (tmp_path / "AGENTS.md").write_text(lines(195), encoding="utf-8")
829
+ plus = "\n".join(f"+more{i}" for i in range(10))
830
+ result = ap(tmp_path, f"*** Update File: AGENTS.md\n@@\n{plus}")
831
+ assert result is not None
832
+ assert "would grow to ~205 lines (cap 200)" in result
833
+
834
+
835
+ def test_apply_patch_update_estimated_growth_within_cap_ok(tmp_path):
836
+ (tmp_path / "AGENTS.md").write_text(lines(195), encoding="utf-8")
837
+ assert ap(tmp_path, "*** Update File: AGENTS.md\n@@\n+one\n+two") is None
838
+
839
+
840
+ def test_apply_patch_update_unreadable_target_skips_estimate(tmp_path):
841
+ """Cap estimation needs the on-disk file; when it can't be read the
842
+ estimate is skipped (the floor judges the final staged blob)."""
843
+ plus = "\n".join("+x" for _ in range(300))
844
+ assert ap(tmp_path, f"*** Update File: AGENTS.md\n@@\n{plus}") is None
845
+
846
+
847
+ def test_apply_patch_multiple_targets_first_denial_wins(tmp_path):
848
+ (tmp_path / "plans" / "archive").mkdir(parents=True)
849
+ result = ap(tmp_path,
850
+ "*** Update File: DECISIONS.md\n@@\n first\n+ok",
851
+ "*** Delete File: plans/archive/a1.md")
852
+ assert result is not None
853
+ assert "frozen" in result
854
+
855
+
856
+ def test_apply_patch_content_lines_starting_with_plus_star_parsed_as_content():
857
+ targets = statutor_core._patch_targets(
858
+ "*** Begin Patch\n*** Add File: x.md\n+*** not a header\n*** End Patch")
859
+ assert len(targets) == 1
860
+ assert targets[0]["content"] == ["*** not a header"]
861
+
862
+
863
+ def test_check_mode_applies_apply_patch_policy(tmp_path):
864
+ payload = json.dumps(envelope("*** Delete File: DECISIONS.md"))
865
+ result = run_kernel(["check", "apply_patch", payload, str(tmp_path)])
866
+ assert result.returncode == 2
867
+ assert "[statutor]" in result.stderr
868
+
869
+
870
+ def test_hook_mode_deny_json_for_apply_patch(tmp_path):
871
+ event = {"tool_name": "apply_patch", "tool_input": envelope("*** Delete File: DECISIONS.md"),
872
+ "cwd": str(tmp_path)}
873
+ result = run_kernel(["hook"], input_str=json.dumps(event))
874
+ assert result.returncode == 0
875
+ data = _hook_deny_json(result)
876
+ assert "governed (append_only)" in data["hookSpecificOutput"]["permissionDecisionReason"]
877
+
878
+
703
879
  # --------------------------------------------------------------------------
704
880
  # hook mode (K-79..K-87) — must fail open
705
881
  # --------------------------------------------------------------------------
@@ -1148,16 +1324,13 @@ def test_staged_repo_local_statutor_yaml_governed_empty(tmp_path, capsys):
1148
1324
 
1149
1325
 
1150
1326
  @git_required
1151
- def test_staged_color_ui_always_breaks_append_only_detection_quirk(ledger_repo, capsys, monkeypatch, tmp_path):
1152
- """statutor_core._git() spawns `git` with no explicit env=, so it inherits
1153
- whatever GIT_CONFIG_GLOBAL/GIT_CONFIG_NOSYSTEM this process happens to
1154
- have. Simulating a developer's real ~/.gitconfig with `color.ui =
1155
- always`: `git diff --cached -U0` starts colorizing, so its deletion
1156
- lines begin with an ANSI escape instead of "-", and run_staged's
1157
- `l.startswith("-")` filter (statutor_core.py) never sees them the
1158
- append-only floor is silently defeated. This PINS that current kernel
1159
- gap (not fixed here — see DECISIONS.md / HANDOFF.md); fixing it needs
1160
- statutor_core._git() to pass `-c color.ui=false` or `--no-color`."""
1327
+ def test_staged_color_ui_always_config_still_detects_deletions(ledger_repo, capsys, monkeypatch, tmp_path):
1328
+ """statutor_core._git() spawns `git -c color.ui=false ...`, so a real
1329
+ ~/.gitconfig with `color.ui = always` cannot ANSI-colorize the
1330
+ `git diff --cached -U0` output: deletion lines keep their "-" prefix and
1331
+ the append-only floor keeps catching them (regression: the pre-fix
1332
+ kernel silently passed a real deletion under this config; fixed alongside
1333
+ the Rust port so both kernels ship corrected behavior, D-0014)."""
1161
1334
  colorful_gitconfig = tmp_path / "colorful.gitconfig"
1162
1335
  colorful_gitconfig.write_text("[color]\n\tui = always\n", encoding="utf-8")
1163
1336
  path = ledger_repo / "DECISIONS.md"
@@ -1166,8 +1339,9 @@ def test_staged_color_ui_always_breaks_append_only_detection_quirk(ledger_repo,
1166
1339
  git(ledger_repo, "add", "DECISIONS.md")
1167
1340
  monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(colorful_gitconfig))
1168
1341
  code = statutor_core.run_staged(str(ledger_repo))
1169
- capsys.readouterr()
1170
- assert code == 0 # BUG: a real deletion goes undetected under this config
1342
+ out = capsys.readouterr().out
1343
+ assert code == 1
1344
+ assert "append-only, but staged diff deletes/modifies 1 line(s)." in out
1171
1345
 
1172
1346
 
1173
1347
  @git_required
@@ -0,0 +1,116 @@
1
+ """Tests for adapters/git/pre-commit — the copyable git-floor hook script (T-0012).
2
+
3
+ The script's contract:
4
+ * `statutor` on PATH → exec `statutor staged <toplevel>` verbatim
5
+ (exit codes propagate: 0 clean, 1 violation).
6
+ * `statutor` missing → FAIL CLOSED (exit 1, instructions on stderr).
7
+ The previous fallback (`python3 .statutor/statutor_core.py staged`) pointed
8
+ at a kernel copy that nothing ever created; vendoring would fork the
9
+ single-kernel source of truth, so the branch was dropped.
10
+
11
+ No conftest.py: this module bootstraps its own sys.path like its siblings.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ REPO_ROOT = Path(__file__).resolve().parents[1]
25
+ SCRIPT = REPO_ROOT / "adapters" / "git" / "pre-commit"
26
+
27
+ NO_GIT = shutil.which("git") is None
28
+ git_required = pytest.mark.skipif(NO_GIT, reason="git not available")
29
+
30
+ GIT_ENV = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1",
31
+ "GIT_CONFIG_GLOBAL": os.devnull, "GIT_TERMINAL_PROMPT": "0"}
32
+
33
+ SHELL = shutil.which("sh") or "/bin/sh"
34
+
35
+
36
+ def git(cwd, *args) -> None:
37
+ subprocess.run(["git", "-c", "user.email=statutor@test", "-c", "user.name=statutor test",
38
+ *args], cwd=str(cwd), env=GIT_ENV, capture_output=True, text=True, check=True)
39
+
40
+
41
+ def run_script(repo: Path, statutor_on_path: bool = True) -> subprocess.CompletedProcess:
42
+ """Run the hook against a hermetic PATH containing only git plus,
43
+ optionally, a `statutor` shim that execs the in-repo kernel — so the
44
+ pass-through branch is exercised even on machines without a global
45
+ `pipx install statutor`, and the fail-closed branch sees a PATH where
46
+ the CLI is genuinely absent."""
47
+ fake_bin = repo / ".fakebin"
48
+ fake_bin.mkdir(exist_ok=True)
49
+ git_exe = shutil.which("git")
50
+ if git_exe and not (fake_bin / "git").exists():
51
+ os.symlink(git_exe, fake_bin / "git")
52
+ if statutor_on_path and not (fake_bin / "statutor").exists():
53
+ shim = fake_bin / "statutor"
54
+ shim.write_text(
55
+ "#!/bin/sh\n"
56
+ f'exec "{sys.executable}" "{REPO_ROOT / "core" / "statutor_core.py"}" "$@"\n',
57
+ encoding="utf-8")
58
+ shim.chmod(0o755)
59
+ return subprocess.run([SHELL, str(SCRIPT)], cwd=str(repo),
60
+ env={**GIT_ENV, "PATH": str(fake_bin)},
61
+ capture_output=True, text=True)
62
+
63
+
64
+ @pytest.fixture
65
+ def ledger_repo(tmp_path: Path) -> Path:
66
+ assert NO_GIT is False
67
+ git(tmp_path, "init", "-q", "-b", "main")
68
+ (tmp_path / "DECISIONS.md").write_text("# DECISIONS\n\n## D-0001\nfirst\n", encoding="utf-8")
69
+ (tmp_path / "HANDOFF.md").write_text(
70
+ "# HANDOFF\nlast_verified: 2026-08-24 by `pytest`\n\n## Goal\ng\n\n"
71
+ "## Last verified state\ns\n\n## Next action\nn\n\n## Gotchas\ngo\n\n"
72
+ "## Do not touch\nd\n", encoding="utf-8")
73
+ (tmp_path / "AGENTS.md").write_text("# AGENTS\nshort\n", encoding="utf-8")
74
+ (tmp_path / "TASKS.md").write_text("- [ ] T-0001 one\n", encoding="utf-8")
75
+ (tmp_path / "plans" / "archive").mkdir(parents=True)
76
+ (tmp_path / "plans" / "archive" / "a1.md").write_text("archived\n", encoding="utf-8")
77
+ git(tmp_path, "add", "-A")
78
+ git(tmp_path, "commit", "-q", "-m", "init ledger")
79
+ return tmp_path
80
+
81
+
82
+ @git_required
83
+ def test_clean_index_passes(ledger_repo):
84
+ result = run_script(ledger_repo)
85
+ assert result.returncode == 0, result.stderr
86
+ assert result.stdout == ""
87
+
88
+
89
+ @git_required
90
+ def test_append_only_violation_denied(ledger_repo):
91
+ path = ledger_repo / "DECISIONS.md"
92
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
93
+ path.write_text("".join(lines[:-1]), encoding="utf-8")
94
+ git(ledger_repo, "add", "DECISIONS.md")
95
+ result = run_script(ledger_repo)
96
+ assert result.returncode == 1
97
+ assert "append-only" in result.stdout
98
+
99
+
100
+ @git_required
101
+ def test_frozen_departure_denied(ledger_repo):
102
+ git(ledger_repo, "rm", "-q", "plans/archive/a1.md")
103
+ result = run_script(ledger_repo)
104
+ assert result.returncode == 1
105
+ assert "frozen" in result.stdout
106
+
107
+
108
+ @git_required
109
+ def test_missing_cli_fails_closed(ledger_repo):
110
+ """No 'statutor' on PATH must exit 1 with install guidance — never a
111
+ silent pass (that would defeat the universal floor) and never a
112
+ traceback from a half-vendored kernel path."""
113
+ result = run_script(ledger_repo, statutor_on_path=False)
114
+ assert result.returncode == 1
115
+ assert "not on PATH" in result.stderr
116
+ assert "pipx install statutor" in result.stderr
@@ -8,11 +8,14 @@ pipes a JSON event on stdin), not by importing it as a module.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import importlib.util
11
12
  import json
12
13
  import subprocess
13
14
  import sys
14
15
  from pathlib import Path
15
16
 
17
+ import pytest
18
+
16
19
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "core"))
17
20
 
18
21
  import statutor_core
@@ -116,6 +119,69 @@ def test_non_sentinel_stale_handoff_surfaces(tmp_path):
116
119
  assert "HANDOFF.md last verified" in ctx
117
120
 
118
121
 
122
+ # --------------------------------------------------------------------------
123
+ # policy-derived state filename (sentinel suppression follows renames)
124
+ # --------------------------------------------------------------------------
125
+
126
+ _CUSTOM_STATE_YAML = """\
127
+ bash_guard: true
128
+ governed:
129
+ - pattern: AGENTS.md
130
+ policy: constitution
131
+ hard_max_lines: 200
132
+ - pattern: STATUS.md
133
+ policy: overwrite_bounded
134
+ max_lines: 40
135
+ required_sections:
136
+ - "## Goal"
137
+ - "## Last verified state"
138
+ - "## Next action"
139
+ - "## Gotchas"
140
+ - "## Do not touch"
141
+ - pattern: DECISIONS.md
142
+ policy: append_only
143
+ - pattern: TASKS.md
144
+ policy: state
145
+ - pattern: plans/archive/*
146
+ policy: frozen
147
+ """
148
+
149
+
150
+ def _custom_ledger(tmp_path: Path, stamp: str) -> None:
151
+ statutor_core.run_init(str(tmp_path))
152
+ status = (tmp_path / "HANDOFF.md").read_text(encoding="utf-8")
153
+ (tmp_path / "HANDOFF.md").unlink()
154
+ (tmp_path / "STATUS.md").write_text(status.replace("1970-01-01", stamp), encoding="utf-8")
155
+ (tmp_path / ".statutor.yaml").write_text(_CUSTOM_STATE_YAML, encoding="utf-8")
156
+
157
+
158
+ @pytest.mark.skipif(importlib.util.find_spec("yaml") is None,
159
+ reason="custom-policy subprocess needs real PyYAML")
160
+ def test_sentinel_suppression_follows_policy_renamed_state_file(tmp_path):
161
+ """A ledger whose overwrite_bounded file is named STATUS.md by policy
162
+ gets the same fresh-scaffold courtesy as HANDOFF.md: one lone sentinel
163
+ WARN must be suppressed, not continued into a spurious stop."""
164
+ _custom_ledger(tmp_path, "1970-01-01")
165
+ event = {"cwd": str(tmp_path), "stop_hook_active": False}
166
+ result = run_stop_hook(json.dumps(event))
167
+ assert result.returncode == 0
168
+ assert result.stdout == ""
169
+
170
+
171
+ @pytest.mark.skipif(importlib.util.find_spec("yaml") is None,
172
+ reason="custom-policy subprocess needs real PyYAML")
173
+ def test_real_drift_surfaces_on_policy_renamed_state_file(tmp_path):
174
+ """Suppression must not go over-broad: a renamed state file with a real
175
+ (non-sentinel) stale stamp is still reported, under its actual name."""
176
+ _custom_ledger(tmp_path, "2000-01-01")
177
+ event = {"cwd": str(tmp_path), "stop_hook_active": False}
178
+ result = run_stop_hook(json.dumps(event))
179
+ assert result.returncode == 0
180
+ data = json.loads(result.stdout)
181
+ ctx = data["hookSpecificOutput"]["additionalContext"]
182
+ assert "STATUS.md last verified" in ctx
183
+
184
+
119
185
  # --------------------------------------------------------------------------
120
186
  # JSON shape when it does fire
121
187
  # --------------------------------------------------------------------------
statutor-0.2.0/PKG-INFO DELETED
@@ -1,5 +0,0 @@
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
@@ -1,5 +0,0 @@
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
@@ -1,17 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=68"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "statutor"
7
- version = "0.2.0"
8
- description = "Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies."
9
- requires-python = ">=3.10"
10
-
11
- [project.scripts]
12
- statutor = "statutor_core:main"
13
- statutor-doctor = "statutor_doctor:main"
14
-
15
- [tool.setuptools]
16
- package-dir = { "" = "core" }
17
- py-modules = ["statutor_core", "statutor_doctor"]
File without changes