|
| 1 | +# Phase 3 |
| 2 | + |
| 3 | +The build log for the third ship. Posture transitions, Disposition, Receipt, hook adapter, full CLI, and E2E coverage. Mirrors `RECEIPTS.md` with day-of-build context. |
| 4 | + |
| 5 | +## Headline |
| 6 | + |
| 7 | +**Shipped:** `v0.3.0a0`, 2026-05-09. Six commits on `claude/setup-project-structure-3YeiT` ahead of `main` after the operator's Phase 2 ff-merge to `main` at `e5e37bf`. CI green across all 9 matrix cells. |
| 8 | + |
| 9 | +**Scope:** Pure posture state machine (`transition`, `evaluate`), the closed `Disposition` enum, the content-addressable `Receipt`, the I/O hook adapter (`run_hook`, `main`), the full `spine-lite` CLI surface (`validate-manifest`, `classify`, `hook`), and an end-to-end test suite that exercises the same path Claude Code invokes when wiring the PreToolUse hook. |
| 10 | + |
| 11 | +**What's stable:** Everything in `spine_lite.__all__` after this phase. Phase 3 adds `Disposition`, `Receipt`, `transition`, `evaluate` to the surface. The full public API: |
| 12 | + |
| 13 | +```python |
| 14 | +from spine_lite import ( |
| 15 | + PRECEDENCE, Effect, most_restrictive, |
| 16 | + Manifest, ToolDefinition, parse_manifest, |
| 17 | + ToolCall, Decision, classify, |
| 18 | + Posture, Disposition, transition, evaluate, |
| 19 | + Receipt, |
| 20 | + SpineLiteError, ManifestError, ClassificationError, |
| 21 | + PostureError, HookError, |
| 22 | + __version__, |
| 23 | +) |
| 24 | +``` |
| 25 | + |
| 26 | +`spine_lite.hook.run_hook` and `spine_lite.hook.main` remain accessible via the submodule import for programmatic users; the canonical operator entry point is the `spine-lite hook` console script. |
| 27 | + |
| 28 | +**What's not in scope:** Real-host integration (smoke against an actual Claude Code session) — the E2E tests run via subprocess against the installed `spine-lite` console script, which is the closest faithful test the build sandbox supports. PyPI publish remains a project-level decision. |
| 29 | + |
| 30 | +## Commit timeline |
| 31 | + |
| 32 | +| # | SHA prefix | Subject | |
| 33 | +|---|---|---| |
| 34 | +| 1 | `6cdcde5` | `chore: enable attr_list mkdocs extension` | |
| 35 | +| 2 | `29bfb63` | `feat: posture state machine with Disposition and evaluate` | |
| 36 | +| 3 | `7cd329b` | `feat: Receipt dataclass with deterministic serialization` | |
| 37 | +| 4 | `0d92074` | `feat: PreToolUse hook adapter` | |
| 38 | +| 5 | `d3e6cb6` | `feat: full CLI surface with integration and E2E tests` | |
| 39 | +| 6 | (this commit) | `release: bump to v0.3.0a0 + phase 3 exit receipt` | |
| 40 | + |
| 41 | +Each commit independently passed the local verification gate before being staged. |
| 42 | + |
| 43 | +## Design choices recorded |
| 44 | + |
| 45 | +- **Disposition is closed at three members.** `ALLOW`, `DENY`, `ESCALATE`. Adding a fourth (e.g. `LOG_ONLY`) would require updating every consumer that exhaustively matches and the exit-code contract; same closed-taxonomy logic applies as for `Effect`. |
| 46 | +- **`evaluate` is layered explicitly.** The order of checks matters: posture allow-list first (one tool, many postures), `LOCKED`/`DRY_RUN` posture-specific rules next, `require_confirmation` last. Reordering the posture-specific checks against the allow-list would let a deny-listed tool slip through under `LOCKED`. |
| 47 | +- **Exit codes use a wide range.** `0` ALLOW / `1` DENY / `2` ESCALATE / `64` HOOK_ERROR / `65` MANIFEST_ERROR. `64+` is sysexits.h territory for "internal failure" — keeps policy outcomes distinguishable from protocol errors at the host's exit-code layer. |
| 48 | +- **Receipt fields are content-addressable.** `to_canonical_json` uses `sort_keys=True`, `ensure_ascii=False`, and compact separators. The hash is `sha256(canonical_json.encode("utf-8"))`. No timestamps, no UUIDs, no per-run metadata — anything that varies between runs lives in the hook's external metadata, not the receipt itself. |
| 49 | +- **Hook contract is intentionally minimal.** Top-level JSON object with `tool` (required, non-empty string) and `arguments` (optional, object). Other fields are ignored. This lets the same hook adapt to multiple host hook formats; spine-lite doesn't need to be re-released when a host's payload schema evolves. |
| 50 | +- **Stdin ↔ stdout is the boundary, not a config file.** The CLI's `--manifest` flag is the configuration; payload comes via stdin; decision goes to stdout. No log files written by default. Whether to capture receipts to disk is the operator's choice (a `--receipt-dir` flag is reserved for a future minor release). |
| 51 | +- **CLI uses `Annotated`-style typer parameters.** Avoids `B008` cleanly and matches typer's modern recommended idiom. The one carve-out: `Path` stays at the top of `cli.py` (TC003 ignored for this file only) because typer introspects the runtime annotation to do `exists=True` validation. |
| 52 | +- **E2E tests run via `python -m spine_lite.cli` subprocess.** True fresh-venv install is out of scope for the build sandbox, but invoking the installed entry point captures everything except the console-script shim. |
| 53 | + |
| 54 | +## Verification on the green run |
| 55 | + |
| 56 | +- `ruff check`: clean |
| 57 | +- `ruff format --check`: clean |
| 58 | +- `mypy --strict src tests`: clean across 19 source files |
| 59 | +- `pytest`: 209 / 209 passing |
| 60 | +- Coverage: 100% on every runtime module |
| 61 | +- `mkdocs build --strict`: clean |
| 62 | +- Hypothesis: 9 properties × 1,000 examples each (six classifier + three receipt) |
| 63 | +- E2E subprocess tests: 7 cases (5 posture × tool combinations + byte-stability + version) |
| 64 | + |
| 65 | +## Phase 3 exit gate |
| 66 | + |
| 67 | +| # | Item | State | |
| 68 | +|---|------|-------| |
| 69 | +| 1 | `posture.py` (transitions + evaluate) 100% coverage | ✓ (34 stmts, 14 branches, 0 miss) | |
| 70 | +| 2 | `receipt.py` 100% coverage | ✓ (22 stmts, 0 miss) | |
| 71 | +| 3 | `hook.py` 100% coverage | ✓ (54 stmts, 6 branches, 0 miss) | |
| 72 | +| 4 | `cli.py` 100% coverage | ✓ (49 stmts, 0 miss) | |
| 73 | +| 5 | Integration tests for every subcommand | ✓ | |
| 74 | +| 6 | E2E smoke via installed entry point | ✓ (7 subprocess cases) | |
| 75 | +| 7 | mypy `--strict` clean | ✓ | |
| 76 | +| 8 | CI green on all 9 matrix cells | (pending push verification) | |
| 77 | +| 9 | CHANGELOG entry for `v0.3.0a0` | ✓ | |
| 78 | +| 10 | All commits in Conventional Commits format | ✓ | |
| 79 | +| 11 | Receipt appended to `RECEIPTS.md` | ✓ (this commit) | |
| 80 | + |
| 81 | +## Lessons for after Phase 3 |
| 82 | + |
| 83 | +- **Closed enums fold neatly into ordered transition tables.** Once `Posture` was closed at four members, encoding the transition rules as `dict[Posture, frozenset[Posture]]` produced a fully-typed structure with zero special-cased code paths. |
| 84 | +- **Hypothesis on dataclasses with `st.builds(...)` is cheap.** The `Receipt` strategy at 1,000 examples per property is ~3 seconds. Tightening the strategy bounds (max sizes for arguments, text fields) keeps it that way. |
| 85 | +- **Annotated-style typer is worth the upgrade.** `B008` ignore is an anti-pattern; the Annotated form is cleaner and the typer docs now lead with it. |
| 86 | + |
| 87 | +## See also |
| 88 | + |
| 89 | +- [`RECEIPTS.md`](https://github.com/MacFall7/spine-lite-python/blob/main/RECEIPTS.md) — canonical phase-day receipts. |
| 90 | +- [`CHANGELOG.md`](https://github.com/MacFall7/spine-lite-python/blob/main/CHANGELOG.md) — what shipped in each version. |
| 91 | +- [Phase 2 history](phase-2.md) — what shipped immediately before. |
| 92 | +- [Wire into Claude Code](../how-to/wire-claude-code.md) — operator runbook now backed by a working hook. |
0 commit comments