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 +21 -0
- statutor-0.3.0/PKG-INFO +107 -0
- {statutor-0.2.0 → statutor-0.3.0}/README.md +13 -9
- statutor-0.3.0/core/statutor.egg-info/PKG-INFO +107 -0
- {statutor-0.2.0 → statutor-0.3.0}/core/statutor.egg-info/SOURCES.txt +3 -0
- {statutor-0.2.0 → statutor-0.3.0}/core/statutor_core.py +168 -9
- {statutor-0.2.0 → statutor-0.3.0}/core/statutor_doctor.py +15 -5
- statutor-0.3.0/pyproject.toml +40 -0
- statutor-0.3.0/tests/test_conformance_rust.py +117 -0
- {statutor-0.2.0 → statutor-0.3.0}/tests/test_doctor.py +60 -0
- {statutor-0.2.0 → statutor-0.3.0}/tests/test_kernel.py +186 -12
- statutor-0.3.0/tests/test_pre_commit.py +116 -0
- {statutor-0.2.0 → statutor-0.3.0}/tests/test_stop_hook.py +66 -0
- statutor-0.2.0/PKG-INFO +0 -5
- statutor-0.2.0/core/statutor.egg-info/PKG-INFO +0 -5
- statutor-0.2.0/pyproject.toml +0 -17
- {statutor-0.2.0 → statutor-0.3.0}/core/statutor.egg-info/dependency_links.txt +0 -0
- {statutor-0.2.0 → statutor-0.3.0}/core/statutor.egg-info/entry_points.txt +0 -0
- {statutor-0.2.0 → statutor-0.3.0}/core/statutor.egg-info/top_level.txt +0 -0
- {statutor-0.2.0 → statutor-0.3.0}/setup.cfg +0 -0
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.
|
statutor-0.3.0/PKG-INFO
ADDED
|
@@ -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
|
|
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
|
-
|
|
|
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;
|
|
40
|
-
|
|
41
|
-
|
|
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`).
|
|
48
|
-
|
|
49
|
-
"<
|
|
50
|
-
|
|
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,
|
|
13
|
-
tool_input {"command": "<
|
|
14
|
-
validate()
|
|
15
|
-
|
|
16
|
-
|
|
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 /
|
|
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 /
|
|
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
|
-
|
|
235
|
-
|
|
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(
|
|
141
|
-
for line in open(p(
|
|
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(
|
|
161
|
-
body = open(p(
|
|
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"
|
|
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
|
|
1152
|
-
"""statutor_core._git() spawns `git
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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 ==
|
|
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
statutor-0.2.0/pyproject.toml
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|