statutor 0.5.0__tar.gz → 0.6.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.5.0/core/statutor.egg-info → statutor-0.6.0}/PKG-INFO +32 -3
- {statutor-0.5.0 → statutor-0.6.0}/README.md +31 -2
- {statutor-0.5.0 → statutor-0.6.0/core/statutor.egg-info}/PKG-INFO +32 -3
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_core.py +206 -18
- {statutor-0.5.0 → statutor-0.6.0}/pyproject.toml +1 -1
- {statutor-0.5.0 → statutor-0.6.0}/LICENSE +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/MANIFEST.in +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor.egg-info/SOURCES.txt +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor.egg-info/dependency_links.txt +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor.egg-info/entry_points.txt +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor.egg-info/top_level.txt +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_doctor.py +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_global.py +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_global_cli.py +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_global_status.py +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_skills.py +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/core/statutor_worker.py +0 -0
- {statutor-0.5.0 → statutor-0.6.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statutor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies.
|
|
5
5
|
Author: Hoo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -46,6 +46,14 @@ message data is not scanned as shell code; its opener and all actual command
|
|
|
46
46
|
lines remain guarded. No hand-maintained CHANGELOG.md: git log + conventional
|
|
47
47
|
commits.
|
|
48
48
|
|
|
49
|
+
The policies are ordered by reversibility and the guardrails follow that
|
|
50
|
+
order: a HANDOFF overwrite is reversible, a TASKS checkbox or detail edit is
|
|
51
|
+
reversible with history, a DECISIONS append is irreversible by policy, and an
|
|
52
|
+
archived plan is frozen (only `git mv` INTO `plans/archive/` is allowed). Text
|
|
53
|
+
an agent reads or quotes — a note, a heredoc, a tool result — informs its
|
|
54
|
+
proposal but never authorizes a mutation: only the declared policy under the
|
|
55
|
+
committed HEAD snapshot gates a write, in-loop and at the git floor alike.
|
|
56
|
+
|
|
49
57
|
State task identities are durable: an existing ID cannot disappear or be
|
|
50
58
|
renamed, while its checkbox, detail, and position may change. New IDs advance
|
|
51
59
|
beyond the committed maximum. v0.4 intentionally has no pruning operation;
|
|
@@ -99,12 +107,16 @@ custom-run (`begin` → `record --event attempt` → attributed rewrite →
|
|
|
99
107
|
`complete --session`); `--event mutation` under an automatic harness is
|
|
100
108
|
denied. `worker capabilities` reports proven surfaces and gaps per host
|
|
101
109
|
(verified against Claude Code 2.1.258, Codex CLI 0.152.1, OpenCode 1.18.20
|
|
102
|
-
baselines); `worker compare <ref>` explains sibling handoffs read-only.
|
|
110
|
+
baselines); `worker compare <ref>` explains sibling handoffs read-only. The
|
|
111
|
+
machine-local registry is a bounded ring (50 sessions per project, 20 leases
|
|
112
|
+
per worktree), not an audit trail; durable provenance is the attributed
|
|
113
|
+
HANDOFF lineage in Git history, which lives as long as the repository does.
|
|
103
114
|
|
|
104
115
|
## Install
|
|
105
116
|
|
|
106
117
|
pipx install statutor # or: pip install -e .
|
|
107
118
|
statutor init . # scaffold any repo, any harness
|
|
119
|
+
statutor init . --type none # governed files only; env: STATUTOR_INIT_TYPE
|
|
108
120
|
|
|
109
121
|
# Claude Code (this repo doubles as the plugin):
|
|
110
122
|
/plugin marketplace add https://github.com/hoohugokim/statutor
|
|
@@ -114,9 +126,26 @@ baselines); `worker compare <ref>` explains sibling handoffs read-only.
|
|
|
114
126
|
# .pre-commit-config.yaml
|
|
115
127
|
repos:
|
|
116
128
|
- repo: https://github.com/hoohugokim/statutor
|
|
117
|
-
rev: v0.
|
|
129
|
+
rev: v0.6.0
|
|
118
130
|
hooks: [{id: statutor}]
|
|
119
131
|
|
|
132
|
+
`init` never mandates a layout (D-0024). It selects a profile — `--type NAME`,
|
|
133
|
+
else `STATUTOR_INIT_TYPE`, else the first marker found in the order below, else
|
|
134
|
+
`min` — and adds only empty conventional directories on top of the governed
|
|
135
|
+
files, `plans/archive/`, `notes/`, and the `CLAUDE.md` bridge. Present paths
|
|
136
|
+
are skipped, never overwritten, adopted, or followed; no profile writes
|
|
137
|
+
policy, constitution content, or ecosystem files, and the `min` fallback
|
|
138
|
+
prints and creates exactly what the pre-v0.6 `init` did.
|
|
139
|
+
|
|
140
|
+
| Profile | Adds | Selected by |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `python` | `tests/` | `pyproject.toml`, `setup.py`, `setup.cfg` |
|
|
143
|
+
| `rust` | `tests/` | `Cargo.toml` |
|
|
144
|
+
| `node` | `test/` | `package.json` |
|
|
145
|
+
| `docs` | `docs/` | `mkdocs.yml`, `_quarto.yml`, an existing `docs/` |
|
|
146
|
+
| `min` | nothing further | fallback when no marker matches, or explicitly |
|
|
147
|
+
| `none` | nothing, not even `plans/archive/` or `notes/` | `--type none` or `STATUTOR_INIT_TYPE=none` only |
|
|
148
|
+
|
|
120
149
|
Per-repo policy lives in `.statutor.yaml`. In-loop checks use the committed
|
|
121
150
|
HEAD snapshot; the git floor judges the transaction under both HEAD and the
|
|
122
151
|
candidate index snapshot, so an unstaged or co-staged weakening cannot disable
|
|
@@ -21,6 +21,14 @@ message data is not scanned as shell code; its opener and all actual command
|
|
|
21
21
|
lines remain guarded. No hand-maintained CHANGELOG.md: git log + conventional
|
|
22
22
|
commits.
|
|
23
23
|
|
|
24
|
+
The policies are ordered by reversibility and the guardrails follow that
|
|
25
|
+
order: a HANDOFF overwrite is reversible, a TASKS checkbox or detail edit is
|
|
26
|
+
reversible with history, a DECISIONS append is irreversible by policy, and an
|
|
27
|
+
archived plan is frozen (only `git mv` INTO `plans/archive/` is allowed). Text
|
|
28
|
+
an agent reads or quotes — a note, a heredoc, a tool result — informs its
|
|
29
|
+
proposal but never authorizes a mutation: only the declared policy under the
|
|
30
|
+
committed HEAD snapshot gates a write, in-loop and at the git floor alike.
|
|
31
|
+
|
|
24
32
|
State task identities are durable: an existing ID cannot disappear or be
|
|
25
33
|
renamed, while its checkbox, detail, and position may change. New IDs advance
|
|
26
34
|
beyond the committed maximum. v0.4 intentionally has no pruning operation;
|
|
@@ -74,12 +82,16 @@ custom-run (`begin` → `record --event attempt` → attributed rewrite →
|
|
|
74
82
|
`complete --session`); `--event mutation` under an automatic harness is
|
|
75
83
|
denied. `worker capabilities` reports proven surfaces and gaps per host
|
|
76
84
|
(verified against Claude Code 2.1.258, Codex CLI 0.152.1, OpenCode 1.18.20
|
|
77
|
-
baselines); `worker compare <ref>` explains sibling handoffs read-only.
|
|
85
|
+
baselines); `worker compare <ref>` explains sibling handoffs read-only. The
|
|
86
|
+
machine-local registry is a bounded ring (50 sessions per project, 20 leases
|
|
87
|
+
per worktree), not an audit trail; durable provenance is the attributed
|
|
88
|
+
HANDOFF lineage in Git history, which lives as long as the repository does.
|
|
78
89
|
|
|
79
90
|
## Install
|
|
80
91
|
|
|
81
92
|
pipx install statutor # or: pip install -e .
|
|
82
93
|
statutor init . # scaffold any repo, any harness
|
|
94
|
+
statutor init . --type none # governed files only; env: STATUTOR_INIT_TYPE
|
|
83
95
|
|
|
84
96
|
# Claude Code (this repo doubles as the plugin):
|
|
85
97
|
/plugin marketplace add https://github.com/hoohugokim/statutor
|
|
@@ -89,9 +101,26 @@ baselines); `worker compare <ref>` explains sibling handoffs read-only.
|
|
|
89
101
|
# .pre-commit-config.yaml
|
|
90
102
|
repos:
|
|
91
103
|
- repo: https://github.com/hoohugokim/statutor
|
|
92
|
-
rev: v0.
|
|
104
|
+
rev: v0.6.0
|
|
93
105
|
hooks: [{id: statutor}]
|
|
94
106
|
|
|
107
|
+
`init` never mandates a layout (D-0024). It selects a profile — `--type NAME`,
|
|
108
|
+
else `STATUTOR_INIT_TYPE`, else the first marker found in the order below, else
|
|
109
|
+
`min` — and adds only empty conventional directories on top of the governed
|
|
110
|
+
files, `plans/archive/`, `notes/`, and the `CLAUDE.md` bridge. Present paths
|
|
111
|
+
are skipped, never overwritten, adopted, or followed; no profile writes
|
|
112
|
+
policy, constitution content, or ecosystem files, and the `min` fallback
|
|
113
|
+
prints and creates exactly what the pre-v0.6 `init` did.
|
|
114
|
+
|
|
115
|
+
| Profile | Adds | Selected by |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `python` | `tests/` | `pyproject.toml`, `setup.py`, `setup.cfg` |
|
|
118
|
+
| `rust` | `tests/` | `Cargo.toml` |
|
|
119
|
+
| `node` | `test/` | `package.json` |
|
|
120
|
+
| `docs` | `docs/` | `mkdocs.yml`, `_quarto.yml`, an existing `docs/` |
|
|
121
|
+
| `min` | nothing further | fallback when no marker matches, or explicitly |
|
|
122
|
+
| `none` | nothing, not even `plans/archive/` or `notes/` | `--type none` or `STATUTOR_INIT_TYPE=none` only |
|
|
123
|
+
|
|
95
124
|
Per-repo policy lives in `.statutor.yaml`. In-loop checks use the committed
|
|
96
125
|
HEAD snapshot; the git floor judges the transaction under both HEAD and the
|
|
97
126
|
candidate index snapshot, so an unstaged or co-staged weakening cannot disable
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statutor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies.
|
|
5
5
|
Author: Hoo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -46,6 +46,14 @@ message data is not scanned as shell code; its opener and all actual command
|
|
|
46
46
|
lines remain guarded. No hand-maintained CHANGELOG.md: git log + conventional
|
|
47
47
|
commits.
|
|
48
48
|
|
|
49
|
+
The policies are ordered by reversibility and the guardrails follow that
|
|
50
|
+
order: a HANDOFF overwrite is reversible, a TASKS checkbox or detail edit is
|
|
51
|
+
reversible with history, a DECISIONS append is irreversible by policy, and an
|
|
52
|
+
archived plan is frozen (only `git mv` INTO `plans/archive/` is allowed). Text
|
|
53
|
+
an agent reads or quotes — a note, a heredoc, a tool result — informs its
|
|
54
|
+
proposal but never authorizes a mutation: only the declared policy under the
|
|
55
|
+
committed HEAD snapshot gates a write, in-loop and at the git floor alike.
|
|
56
|
+
|
|
49
57
|
State task identities are durable: an existing ID cannot disappear or be
|
|
50
58
|
renamed, while its checkbox, detail, and position may change. New IDs advance
|
|
51
59
|
beyond the committed maximum. v0.4 intentionally has no pruning operation;
|
|
@@ -99,12 +107,16 @@ custom-run (`begin` → `record --event attempt` → attributed rewrite →
|
|
|
99
107
|
`complete --session`); `--event mutation` under an automatic harness is
|
|
100
108
|
denied. `worker capabilities` reports proven surfaces and gaps per host
|
|
101
109
|
(verified against Claude Code 2.1.258, Codex CLI 0.152.1, OpenCode 1.18.20
|
|
102
|
-
baselines); `worker compare <ref>` explains sibling handoffs read-only.
|
|
110
|
+
baselines); `worker compare <ref>` explains sibling handoffs read-only. The
|
|
111
|
+
machine-local registry is a bounded ring (50 sessions per project, 20 leases
|
|
112
|
+
per worktree), not an audit trail; durable provenance is the attributed
|
|
113
|
+
HANDOFF lineage in Git history, which lives as long as the repository does.
|
|
103
114
|
|
|
104
115
|
## Install
|
|
105
116
|
|
|
106
117
|
pipx install statutor # or: pip install -e .
|
|
107
118
|
statutor init . # scaffold any repo, any harness
|
|
119
|
+
statutor init . --type none # governed files only; env: STATUTOR_INIT_TYPE
|
|
108
120
|
|
|
109
121
|
# Claude Code (this repo doubles as the plugin):
|
|
110
122
|
/plugin marketplace add https://github.com/hoohugokim/statutor
|
|
@@ -114,9 +126,26 @@ baselines); `worker compare <ref>` explains sibling handoffs read-only.
|
|
|
114
126
|
# .pre-commit-config.yaml
|
|
115
127
|
repos:
|
|
116
128
|
- repo: https://github.com/hoohugokim/statutor
|
|
117
|
-
rev: v0.
|
|
129
|
+
rev: v0.6.0
|
|
118
130
|
hooks: [{id: statutor}]
|
|
119
131
|
|
|
132
|
+
`init` never mandates a layout (D-0024). It selects a profile — `--type NAME`,
|
|
133
|
+
else `STATUTOR_INIT_TYPE`, else the first marker found in the order below, else
|
|
134
|
+
`min` — and adds only empty conventional directories on top of the governed
|
|
135
|
+
files, `plans/archive/`, `notes/`, and the `CLAUDE.md` bridge. Present paths
|
|
136
|
+
are skipped, never overwritten, adopted, or followed; no profile writes
|
|
137
|
+
policy, constitution content, or ecosystem files, and the `min` fallback
|
|
138
|
+
prints and creates exactly what the pre-v0.6 `init` did.
|
|
139
|
+
|
|
140
|
+
| Profile | Adds | Selected by |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `python` | `tests/` | `pyproject.toml`, `setup.py`, `setup.cfg` |
|
|
143
|
+
| `rust` | `tests/` | `Cargo.toml` |
|
|
144
|
+
| `node` | `test/` | `package.json` |
|
|
145
|
+
| `docs` | `docs/` | `mkdocs.yml`, `_quarto.yml`, an existing `docs/` |
|
|
146
|
+
| `min` | nothing further | fallback when no marker matches, or explicitly |
|
|
147
|
+
| `none` | nothing, not even `plans/archive/` or `notes/` | `--type none` or `STATUTOR_INIT_TYPE=none` only |
|
|
148
|
+
|
|
120
149
|
Per-repo policy lives in `.statutor.yaml`. In-loop checks use the committed
|
|
121
150
|
HEAD snapshot; the git floor judges the transaction under both HEAD and the
|
|
122
151
|
candidate index snapshot, so an unstaged or co-staged weakening cannot disable
|
|
@@ -25,7 +25,10 @@ Entry modes (all share the same validate() core):
|
|
|
25
25
|
ledger, or stay silent when none exists.
|
|
26
26
|
statutor staged [CWD] Git floor: validate staged changes (pre-commit).
|
|
27
27
|
exit 1 on violations.
|
|
28
|
-
statutor init [DIR]
|
|
28
|
+
statutor init [DIR] [--type NAME]
|
|
29
|
+
Scaffold governed files from embedded templates.
|
|
30
|
+
Profiles (D-0024) add conventional directories only:
|
|
31
|
+
--type > STATUTOR_INIT_TYPE > marker detection > min.
|
|
29
32
|
statutor global ... Opt-in global instruction and skill projection manager.
|
|
30
33
|
statutor machine ... Random machine identity (mode 0600, rotation).
|
|
31
34
|
statutor worker ... Machine-local worker provenance (begin/record/show/active/complete/compare).
|
|
@@ -787,6 +790,13 @@ def _automatic_reason(tool: str, payload: dict, cwd: str) -> str | None:
|
|
|
787
790
|
return None
|
|
788
791
|
|
|
789
792
|
def run_hook() -> int:
|
|
793
|
+
if sys.stdin.isatty():
|
|
794
|
+
# A TTY means a human ran the hook with no piped event (harnesses
|
|
795
|
+
# always pipe JSON). Fail fast with usage instead of blocking on
|
|
796
|
+
# stdin forever; fail-open applies to hook *events*, not misuse.
|
|
797
|
+
print("statutor hook expects piped hook JSON on stdin; "
|
|
798
|
+
"run `statutor --help` for modes.", file=sys.stderr)
|
|
799
|
+
return 64
|
|
790
800
|
try:
|
|
791
801
|
event = json.load(sys.stdin)
|
|
792
802
|
tool = event.get("tool_name", "")
|
|
@@ -1389,30 +1399,208 @@ governed:
|
|
|
1389
1399
|
}
|
|
1390
1400
|
|
|
1391
1401
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1402
|
+
# Init profiles (D-0024): Statutor never mandates a project layout. A profile
|
|
1403
|
+
# adds empty *conventional* directories on top of the governed files — never
|
|
1404
|
+
# policy, constitution content, or ecosystem files — and every create is
|
|
1405
|
+
# additive and idempotent (present paths are skipped, never overwritten,
|
|
1406
|
+
# adopted, or followed). Definitions live here beside TEMPLATES for the same
|
|
1407
|
+
# reason the templates do (D-0003): a profiles/ directory would fork the
|
|
1408
|
+
# source of truth.
|
|
1409
|
+
#
|
|
1410
|
+
# Precedence, highest first: `--type NAME` > `STATUTOR_INIT_TYPE` > the first
|
|
1411
|
+
# marker hit in INIT_DETECT_ORDER > `min`. A marker written `name/` must be a
|
|
1412
|
+
# directory; any other marker must be a regular file. The `min` fallback prints
|
|
1413
|
+
# and creates exactly what pre-v0.6 `init` did. `none` adds no directory at
|
|
1414
|
+
# all, not even the ledger skeleton.
|
|
1415
|
+
|
|
1416
|
+
INIT_SKELETON_DIRS: tuple[str, ...] = ("plans/archive", "notes")
|
|
1417
|
+
INIT_PROFILES: dict[str, dict[str, tuple[str, ...]]] = {
|
|
1418
|
+
"none": {"dirs": (), "markers": ()},
|
|
1419
|
+
"min": {"dirs": (), "markers": ()},
|
|
1420
|
+
"python": {"dirs": ("tests",),
|
|
1421
|
+
"markers": ("pyproject.toml", "setup.py", "setup.cfg")},
|
|
1422
|
+
"rust": {"dirs": ("tests",), "markers": ("Cargo.toml",)},
|
|
1423
|
+
"node": {"dirs": ("test",), "markers": ("package.json",)},
|
|
1424
|
+
"docs": {"dirs": ("docs",), "markers": ("mkdocs.yml", "_quarto.yml", "docs/")},
|
|
1425
|
+
}
|
|
1426
|
+
INIT_DETECT_ORDER: tuple[str, ...] = ("python", "rust", "node", "docs")
|
|
1427
|
+
INIT_TYPE_ENV = "STATUTOR_INIT_TYPE"
|
|
1428
|
+
INIT_USAGE = (f"usage: statutor init [DIR] [--type {'|'.join(INIT_PROFILES)}]"
|
|
1429
|
+
f" (env: {INIT_TYPE_ENV}=NAME)")
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _init_profile_name(raw: str, source: str) -> str:
|
|
1433
|
+
name = raw.strip().lower()
|
|
1434
|
+
if name not in INIT_PROFILES:
|
|
1435
|
+
raise ValueError(
|
|
1436
|
+
f"unknown init profile {raw!r} from {source}; "
|
|
1437
|
+
f"choose one of {', '.join(INIT_PROFILES)}")
|
|
1438
|
+
return name
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _marker_present(target: str, marker: str) -> bool:
|
|
1442
|
+
"""`name/` markers need a directory, others a regular file (links followed)."""
|
|
1443
|
+
path = os.path.join(target, marker.rstrip("/"))
|
|
1444
|
+
return os.path.isdir(path) if marker.endswith("/") else os.path.isfile(path)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def resolve_init_profile(
|
|
1448
|
+
target: str, explicit: str | None = None, env: dict | None = None,
|
|
1449
|
+
) -> tuple[str, str]:
|
|
1450
|
+
"""Return `(profile, source)` for `target` without touching the disk.
|
|
1451
|
+
|
|
1452
|
+
`source` is `--type`, the environment variable name, `detected <marker>`,
|
|
1453
|
+
or `fallback`. An empty environment value counts as unset (dotfiles often
|
|
1454
|
+
export empty strings); an explicit empty `--type` is an error. `env` of
|
|
1455
|
+
None consults nothing: only the CLI reads the process environment, so
|
|
1456
|
+
in-process callers stay deterministic.
|
|
1457
|
+
"""
|
|
1458
|
+
if explicit is not None:
|
|
1459
|
+
return _init_profile_name(explicit, "--type"), "--type"
|
|
1460
|
+
raw = (env or {}).get(INIT_TYPE_ENV, "")
|
|
1461
|
+
if isinstance(raw, str) and raw.strip():
|
|
1462
|
+
return _init_profile_name(raw, INIT_TYPE_ENV), INIT_TYPE_ENV
|
|
1463
|
+
for name in INIT_DETECT_ORDER:
|
|
1464
|
+
for marker in INIT_PROFILES[name]["markers"]:
|
|
1465
|
+
if _marker_present(target, marker):
|
|
1466
|
+
return name, f"detected {marker}"
|
|
1467
|
+
return "min", "fallback"
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _ensure_dir(root: str, rel: str) -> bool:
|
|
1471
|
+
"""Create `root/rel` one component at a time; True when the leaf was created.
|
|
1472
|
+
|
|
1473
|
+
Any present component that is a symlink or not a directory blocks the
|
|
1474
|
+
whole path (returned as a skip): init never follows a link out of the
|
|
1475
|
+
target and never adopts a file. A concurrent creator is a skip too.
|
|
1476
|
+
Permission failures still propagate.
|
|
1477
|
+
"""
|
|
1478
|
+
current = root
|
|
1479
|
+
created = False
|
|
1480
|
+
for part in rel.split("/"):
|
|
1481
|
+
current = os.path.join(current, part)
|
|
1482
|
+
if os.path.lexists(current):
|
|
1483
|
+
if os.path.islink(current) or not os.path.isdir(current):
|
|
1484
|
+
return False
|
|
1485
|
+
created = False
|
|
1399
1486
|
continue
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1487
|
+
try:
|
|
1488
|
+
os.mkdir(current)
|
|
1489
|
+
created = True
|
|
1490
|
+
except FileExistsError:
|
|
1491
|
+
created = False
|
|
1492
|
+
return created
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _create_file(path: str, body: str) -> bool:
|
|
1496
|
+
"""Exclusively create `path`; False when any entry (even a dangling link) exists."""
|
|
1497
|
+
try:
|
|
1498
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
|
|
1499
|
+
except FileExistsError:
|
|
1500
|
+
return False
|
|
1501
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
1502
|
+
fh.write(body)
|
|
1503
|
+
return True
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def _present_kind(path: str) -> str:
|
|
1507
|
+
if os.path.isdir(path) and not os.path.islink(path):
|
|
1508
|
+
return "exists"
|
|
1509
|
+
return "present, left alone"
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def run_init(target: str, profile: str | None = None, env: dict | None = None) -> int:
|
|
1513
|
+
try:
|
|
1514
|
+
name, source = resolve_init_profile(target, profile, env)
|
|
1515
|
+
except ValueError as exc:
|
|
1516
|
+
print(f"statutor init: {exc}", file=sys.stderr)
|
|
1517
|
+
print(INIT_USAGE, file=sys.stderr)
|
|
1518
|
+
return 64
|
|
1519
|
+
if source != "fallback":
|
|
1520
|
+
print(f"profile {name} ({source})")
|
|
1521
|
+
if os.path.lexists(target) and not os.path.isdir(target):
|
|
1522
|
+
print(f"statutor init: target {target!r} exists and is not a directory",
|
|
1523
|
+
file=sys.stderr)
|
|
1524
|
+
return 1
|
|
1525
|
+
try:
|
|
1526
|
+
os.makedirs(target, exist_ok=True)
|
|
1527
|
+
if name != "none":
|
|
1528
|
+
for rel in INIT_SKELETON_DIRS:
|
|
1529
|
+
_ensure_dir(target, rel) # silent: pre-v0.6 behavior
|
|
1530
|
+
for filename, body in TEMPLATES.items():
|
|
1531
|
+
if _create_file(os.path.join(target, filename), body):
|
|
1532
|
+
print(f"write {filename}")
|
|
1533
|
+
else:
|
|
1534
|
+
print(f"skip {filename} (exists)")
|
|
1535
|
+
if _create_file(os.path.join(target, "CLAUDE.md"), "@AGENTS.md\n"):
|
|
1536
|
+
print("write CLAUDE.md (@AGENTS.md import)")
|
|
1537
|
+
for rel in INIT_PROFILES[name]["dirs"]:
|
|
1538
|
+
if _ensure_dir(target, rel):
|
|
1539
|
+
print(f"mkdir {rel}/")
|
|
1540
|
+
else:
|
|
1541
|
+
print(f"skip {rel}/ ({_present_kind(os.path.join(target, rel))})")
|
|
1542
|
+
except OSError as exc:
|
|
1543
|
+
print(f"statutor init: {exc}", file=sys.stderr)
|
|
1544
|
+
return 1
|
|
1408
1545
|
return 0
|
|
1409
1546
|
|
|
1410
1547
|
|
|
1548
|
+
def run_init_cli(argv: list[str], env: dict | None = None) -> int:
|
|
1549
|
+
"""Parse `statutor init [DIR] [--type NAME] [-- DIR]`; the CLI alone reads the env."""
|
|
1550
|
+
target: str | None = None
|
|
1551
|
+
profile: str | None = None
|
|
1552
|
+
positional_only = False
|
|
1553
|
+
i = 0
|
|
1554
|
+
|
|
1555
|
+
def usage_error(message: str) -> int:
|
|
1556
|
+
print(f"statutor init: {message}", file=sys.stderr)
|
|
1557
|
+
print(INIT_USAGE, file=sys.stderr)
|
|
1558
|
+
return 64
|
|
1559
|
+
|
|
1560
|
+
while i < len(argv):
|
|
1561
|
+
arg = argv[i]
|
|
1562
|
+
if not positional_only:
|
|
1563
|
+
if arg in ("-h", "--help"):
|
|
1564
|
+
print(INIT_USAGE)
|
|
1565
|
+
return 0
|
|
1566
|
+
if arg == "--":
|
|
1567
|
+
positional_only = True
|
|
1568
|
+
i += 1
|
|
1569
|
+
continue
|
|
1570
|
+
if arg == "--type" or arg.startswith("--type="):
|
|
1571
|
+
if profile is not None:
|
|
1572
|
+
return usage_error("--type given more than once")
|
|
1573
|
+
if arg == "--type":
|
|
1574
|
+
if i + 1 >= len(argv):
|
|
1575
|
+
return usage_error("missing value for --type")
|
|
1576
|
+
profile = argv[i + 1]
|
|
1577
|
+
i += 2
|
|
1578
|
+
else:
|
|
1579
|
+
profile = arg[len("--type="):]
|
|
1580
|
+
i += 1
|
|
1581
|
+
continue
|
|
1582
|
+
if arg.startswith("-"):
|
|
1583
|
+
return usage_error(f"unexpected argument: {arg}")
|
|
1584
|
+
if not arg:
|
|
1585
|
+
return usage_error("DIR must not be empty")
|
|
1586
|
+
if target is not None:
|
|
1587
|
+
return usage_error(f"unexpected argument: {arg}")
|
|
1588
|
+
target = arg
|
|
1589
|
+
i += 1
|
|
1590
|
+
environment = dict(os.environ) if env is None else env
|
|
1591
|
+
return run_init(target if target is not None else os.getcwd(), profile, environment)
|
|
1592
|
+
|
|
1593
|
+
|
|
1411
1594
|
# --------------------------------------------------------------------------
|
|
1412
1595
|
|
|
1413
1596
|
def main() -> None:
|
|
1414
1597
|
argv = sys.argv[1:]
|
|
1415
|
-
|
|
1598
|
+
if not argv or argv[0] in ("--help", "-h"):
|
|
1599
|
+
# Bare invocation must never fall through to hook mode (which blocks
|
|
1600
|
+
# on stdin); print usage instead. --help exits clean, bare exits 64.
|
|
1601
|
+
print(__doc__)
|
|
1602
|
+
sys.exit(0 if argv else 64)
|
|
1603
|
+
mode = argv[0]
|
|
1416
1604
|
if mode in ("hook", "--claude-hook"):
|
|
1417
1605
|
sys.exit(run_hook())
|
|
1418
1606
|
if mode == "check":
|
|
@@ -1422,7 +1610,7 @@ def main() -> None:
|
|
|
1422
1610
|
if mode == "trust" and len(argv) > 1 and argv[1] == "approve":
|
|
1423
1611
|
sys.exit(run_trust_approve(argv[2:]))
|
|
1424
1612
|
if mode == "init":
|
|
1425
|
-
sys.exit(
|
|
1613
|
+
sys.exit(run_init_cli(argv[1:]))
|
|
1426
1614
|
if mode == "global":
|
|
1427
1615
|
import statutor_global_cli
|
|
1428
1616
|
sys.exit(statutor_global_cli.main(argv[1:]))
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "statutor"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Typed project-ledger framework for agentic repos: four planes, single-writer files, enforced mutation policies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|