lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
#!/usr/bin/env python3
"""Build deterministic Claude Code hook input fixtures for #30 verification.

Lands the *shape* of what Claude Code sends to each lifecycle hook,
based on Anthropic's published Claude Code hook protocol documentation
(NOT on `lifeloop::claude_manifest()`). Independence from the manifest
matters — the verification in `tests/live_evidence_claude.rs` compares
the fixtures to the manifest claims, and a generator that read from
the manifest would be circular.

These deterministic fixtures hold the line until an operator does a
real recapture using the rig at `scripts/regenerate-live-claude-evidence.sh`.
The rig's sanitizer produces output in the same per-event /
per-fixture-ordinal layout this generator writes to, so an operator
recapture overwrites these fixtures cleanly.

Produces one fixture per Claude lifecycle event under
`tests/fixtures/live/claude/<EventName>/0.json`. The fixtures are
pre-sanitized to the same placeholder vocabulary as
`scripts/sanitize-live-evidence.py`, so the on-disk shape matches
what the live-recapture path produces.

Usage:
    scripts/build-deterministic-claude-fixtures.py [--output-dir tests/fixtures/live]
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Source-of-truth references (external — not the lifeloop manifest)
# ---------------------------------------------------------------------------
#
# Each fixture below carries the field set documented in Anthropic's
# Claude Code hook protocol docs as of 2026-05. When the upstream
# protocol changes, an operator recapture will diverge from these
# fixtures, which is the cue to bump them.

SHARED_FIELDS = {
    "session_id": "<SESSION_ID>",
    "transcript_path": "<TRANSCRIPT_PATH>",
    "cwd": "<REPO_ROOT>",
    "permission_mode": "default",
}

EVENT_FIXTURES: dict[str, dict] = {
    "SessionStart": {
        **SHARED_FIELDS,
        "hook_event_name": "SessionStart",
        # Claude fires SessionStart with one of: startup | resume | clear | compact.
        # `startup` is the cleanest baseline for the manifest claim
        # `SessionStarting/SessionStarted: native via NativeHook`.
        "source": "startup",
    },
    "UserPromptSubmit": {
        **SHARED_FIELDS,
        "hook_event_name": "UserPromptSubmit",
        "prompt": "<USER_PROMPT_BODY>",
    },
    "PreCompact": {
        **SHARED_FIELDS,
        "hook_event_name": "PreCompact",
        # The manifest's context_pressure claim names PreCompact as
        # the on-the-wire signal that maps to ContextPressureObserved.
        # `auto` is the most common trigger; `manual` would also fire
        # on `/compact`. We pin `auto` so the fixture stays stable.
        "trigger": "auto",
        "custom_instructions": "<USER_PROMPT_BODY>",
    },
    "Stop": {
        **SHARED_FIELDS,
        "hook_event_name": "Stop",
        "stop_hook_active": True,
    },
    "SessionEnd": {
        **SHARED_FIELDS,
        "hook_event_name": "SessionEnd",
        # Claude fires SessionEnd with one of: clear | logout |
        # prompt_input_exit | other. We pin `prompt_input_exit` —
        # the operator typing /exit. The manifest's
        # context_pressure claim names SessionEnd as the
        # session-boundary hint, so the fixture's presence
        # grounds that claim.
        "reason": "prompt_input_exit",
    },
}


def _per_event_readme() -> str:
    return """# Claude Code live evidence

**Status:** Deterministic-from-protocol fixtures (pre-v1 degradation).

These fixtures were generated by
`scripts/build-deterministic-claude-fixtures.py` based on Anthropic's
published Claude Code hook protocol documentation. They are NOT
recordings of a live Claude session — but their shape is what a real
recording would produce after `scripts/sanitize-live-evidence.py`.

## How to upgrade to live evidence

1. Run the operator recapture procedure documented in
   `docs/playbooks/capture-live-adapter-evidence.md`.
2. The rig's sanitizer overwrites these files in place under
   `<event>/<n>.json`.
3. Re-run `make verify` — the live-evidence comparison test in
   `tests/live_evidence_claude.rs` re-runs against the captured
   fixtures and confirms or rejects each manifest claim.

## v1 freeze gate

Per #30's acceptance criteria: this issue closes with the
deterministic fixtures and the verification harness, **plus a
documented pre-v1 degradation**: at least one operator recapture run
must occur before v1 freeze to confirm the deterministic fixtures
match real Claude behavior on the v1 freeze candidate harness
versions.
"""


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "live",
        help="Root output directory (default: tests/fixtures/live).",
    )
    args = parser.parse_args(argv)
    claude_root: Path = args.output_dir / "claude"
    claude_root.mkdir(parents=True, exist_ok=True)

    # README that documents the pre-v1 degradation status.
    (claude_root / "README.md").write_text(_per_event_readme())

    for event, body in EVENT_FIXTURES.items():
        event_dir = claude_root / event
        event_dir.mkdir(parents=True, exist_ok=True)
        (event_dir / "0.json").write_text(json.dumps(body, indent=2, sort_keys=True) + "\n")

    print(
        f"build-deterministic-claude-fixtures: wrote {len(EVENT_FIXTURES)} fixtures "
        f"under {claude_root}",
        file=sys.stderr,
    )
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))