custos-code 0.0.1__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.
Files changed (35) hide show
  1. custos_code-0.0.1/.gitignore +24 -0
  2. custos_code-0.0.1/LICENSE +21 -0
  3. custos_code-0.0.1/PKG-INFO +138 -0
  4. custos_code-0.0.1/README.md +112 -0
  5. custos_code-0.0.1/pyproject.toml +58 -0
  6. custos_code-0.0.1/src/custos_code/__init__.py +6 -0
  7. custos_code-0.0.1/src/custos_code/adapters/__init__.py +194 -0
  8. custos_code-0.0.1/src/custos_code/adapters/claude_code.py +266 -0
  9. custos_code-0.0.1/src/custos_code/adapters/codex.py +437 -0
  10. custos_code-0.0.1/src/custos_code/adapters/copilot.py +158 -0
  11. custos_code-0.0.1/src/custos_code/adapters/devin.py +172 -0
  12. custos_code-0.0.1/src/custos_code/adapters/machine.py +379 -0
  13. custos_code-0.0.1/src/custos_code/adapters/otel.py +210 -0
  14. custos_code-0.0.1/src/custos_code/adapters/state.py +164 -0
  15. custos_code-0.0.1/src/custos_code/claims.py +319 -0
  16. custos_code-0.0.1/src/custos_code/cli.py +789 -0
  17. custos_code-0.0.1/src/custos_code/compress.py +113 -0
  18. custos_code-0.0.1/src/custos_code/cost.py +216 -0
  19. custos_code-0.0.1/src/custos_code/demo_fixtures/__init__.py +1 -0
  20. custos_code-0.0.1/src/custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
  21. custos_code-0.0.1/src/custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
  22. custos_code-0.0.1/src/custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
  23. custos_code-0.0.1/src/custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
  24. custos_code-0.0.1/src/custos_code/feedback.py +93 -0
  25. custos_code-0.0.1/src/custos_code/hooks.py +648 -0
  26. custos_code-0.0.1/src/custos_code/judge.py +338 -0
  27. custos_code-0.0.1/src/custos_code/ledger.py +93 -0
  28. custos_code-0.0.1/src/custos_code/models.py +129 -0
  29. custos_code-0.0.1/src/custos_code/parsers.py +408 -0
  30. custos_code-0.0.1/src/custos_code/report.py +317 -0
  31. custos_code-0.0.1/src/custos_code/rerun.py +424 -0
  32. custos_code-0.0.1/src/custos_code/review.py +381 -0
  33. custos_code-0.0.1/src/custos_code/rules.py +464 -0
  34. custos_code-0.0.1/src/custos_code/scope.py +471 -0
  35. custos_code-0.0.1/src/custos_code/verdicts.py +296 -0
@@ -0,0 +1,24 @@
1
+ .venv/
2
+ .uv-cache/
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .hypothesis/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ __pycache__/
11
+ *.pyc
12
+ *.sqlite
13
+ *.duckdb
14
+ .env
15
+ .env.*
16
+ config.toml
17
+ ~/.receipts/
18
+ eval/study/sessions/
19
+ bench/runs/
20
+ bench/fixtures/*/.venv/
21
+ .claude/
22
+ CLAUDE.md
23
+ .DS_Store
24
+ docs/research/raw/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Zhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.5
2
+ Name: custos-code
3
+ Version: 0.0.1
4
+ Summary: Checks a coding agent's final report against the log of what it actually did.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: agents,claude-code,hooks,observability,verification
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: anthropic>=0.40
14
+ Requires-Dist: duckdb>=1.0
15
+ Requires-Dist: openai>=1.40
16
+ Requires-Dist: pydantic>=2.7
17
+ Requires-Dist: rich>=13.7
18
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
19
+ Requires-Dist: typer>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: hypothesis>=6; extra == 'dev'
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff>=0.5; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Custos Code
28
+
29
+ Checks a coding agent's final report against the log of what it actually did.
30
+
31
+ An agent finishes and says "implemented the feature, ran the tests, all passing." Custos Code reads the harness-written action log, splits the report into claims, and marks each one **confirmed**, **contradicted**, **unwitnessed**, **unrecorded**, or **qualified**, with the ledger lines that back the verdict. Contradictions go back to the agent before it is allowed to stop.
32
+
33
+ ```
34
+ custos-code session 4f2a… · 63 events
35
+ ✓ confirmed edited auth/middleware.py tier 1 · #14 Edit, git diff agrees
36
+ ✓ confirmed added tests/test_rate_limit.py tier 1 · #31 Write, file present
37
+ ✗ contradicted ran the suite, all 12 passing tier 2 · #41 `pytest | tail -5` exit 0, "collected 0 items"
38
+ ? unwitnessed ready to merge no CI, no git status after #41
39
+ stop blocked · 1 contradicted · evidence returned to agent
40
+ ```
41
+
42
+ ## Status
43
+
44
+ Pre-build. The design, research, plan, and evidence protocol are in `docs/`. Start with [AGENTS.md](AGENTS.md).
45
+
46
+ - [docs/DESIGN.md](docs/DESIGN.md) — problem, customers, verification ladder, feasibility, product sketches, benchmark, system design, prize strategy, adversarial review
47
+ - [docs/RESEARCH.md](docs/RESEARCH.md) — the evidence: prevalence with denominators, cost, current workarounds, tool landscape, 40 seed cases
48
+ - [docs/PLAN.md](docs/PLAN.md) — who owns what, phases, parallel tracks
49
+ - [docs/EVIDENCE_PLAN.md](docs/EVIDENCE_PLAN.md) — pre-registered study: accuracy, time saved, retention, usability
50
+ - [docs/OPEN_QUESTIONS.md](docs/OPEN_QUESTIONS.md) — every unresolved decision with an owner
51
+
52
+ ## See it work
53
+
54
+ ```bash
55
+ uv sync
56
+ export OPENAI_API_KEY=...
57
+ uv run custos-code demo # the whole loop on a known trap, live
58
+ uv run custos-code demo --scenario honest # the control: nothing blocks
59
+ uv run custos-code check --last # your own most recent session
60
+ ```
61
+
62
+ `demo` prints five things from the fixture's own tool log: what was asked, what the agent actually
63
+ did, what it said, the receipt, and the deterministic nudge that goes back. `--format html --out
64
+ card.html` writes a self-contained report card; `--format markdown` writes what the PR bot posts.
65
+
66
+ ## Prototype
67
+
68
+ `docs/prototype/index.html` is an interactive, non-functional mock of the editor experience: marks on the agent's message, the evidence panel, editor decorations, the auto-mode loop, and the PR receipt. It is a single self-contained file:
69
+
70
+ ```bash
71
+ open docs/prototype/index.html # macOS
72
+ # or: python3 -m http.server -d docs/prototype 8765 → http://localhost:8765
73
+ ```
74
+
75
+ ## Local setup
76
+
77
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) once. On macOS
78
+ with Homebrew: `brew install uv`. From the repository directory:
79
+
80
+ ```bash
81
+ uv python install
82
+ make sync
83
+ make check
84
+ uv run custos-code check --last
85
+ ```
86
+
87
+ `.python-version` selects Python 3.12, independently of your shell's pyenv or
88
+ Conda default. `make sync` installs the project and developer tools from the
89
+ committed `uv.lock`; it fails if the lockfile needs updating. After an intentional
90
+ dependency change, run `uv lock` and include the lockfile in the same PR.
91
+
92
+ `make build` produces a wheel and source distribution in `dist/`. CI runs the
93
+ same checks, installs both distributions in clean environments, and checks the
94
+ installed CLI outside the source checkout. CI uses locked installs following
95
+ the [uv integration guide](https://docs.astral.sh/uv/guides/integration/github/).
96
+
97
+ ## Bench container
98
+
99
+ With Docker installed and running, build from the repository root:
100
+
101
+ ```bash
102
+ docker build -t custos-code-bench .
103
+ docker run --rm --network none custos-code-bench
104
+ docker run --rm --network none custos-code-bench python -m pytest --version
105
+ ```
106
+
107
+ The image contains Python 3.12, uv 0.12.17, git, the installed Custos Code package,
108
+ developer dependencies, and scenario descriptions under `/app/bench/scenarios`.
109
+ It runs as a non-root user in writable `/workspace`; the default command shows
110
+ CLI help. The benchmark orchestration and fixture repos are not implemented
111
+ yet, so this is their execution environment, not a working benchmark command.
112
+ Agent CLIs and their credentials are not installed.
113
+
114
+ The Docker build context is an allowlist that excludes local session logs,
115
+ credentials, caches, and git history. For a local Python fixture, mount only
116
+ that fixture (including its git metadata when state checks need it):
117
+
118
+ ```bash
119
+ docker run --rm --network none \
120
+ --mount type=bind,src="$(pwd)/path/to/fixture",dst=/workspace,readonly \
121
+ custos-code-bench python -m pytest -p no:cacheprovider
122
+ ```
123
+
124
+ This read-only example suits tests that do not write into the fixture. Agent
125
+ bench runs will need a disposable writable checkout and explicit network and
126
+ credential configuration. Non-Python runners require additional toolchains.
127
+
128
+ ## Why
129
+
130
+ Across 20,574 real coding-agent sessions, 22.58% of 16,118 validated misalignment episodes were the agent misreporting its own work, and only 2.99% of resolved episodes were self-corrected. Every agent vendor attaches an action log; none checks the report against it. Sources and denominators: `docs/RESEARCH.md`.
131
+
132
+ ## Working in this repo
133
+
134
+ Read `AGENTS.md`. Flag anything undecided with `NEEDS-DECISION(owner):`. Local by default; secrets are redacted at ingest; fixtures are synthetic.
135
+
136
+ ## License
137
+
138
+ MIT (see `LICENSE`).
@@ -0,0 +1,112 @@
1
+ # Custos Code
2
+
3
+ Checks a coding agent's final report against the log of what it actually did.
4
+
5
+ An agent finishes and says "implemented the feature, ran the tests, all passing." Custos Code reads the harness-written action log, splits the report into claims, and marks each one **confirmed**, **contradicted**, **unwitnessed**, **unrecorded**, or **qualified**, with the ledger lines that back the verdict. Contradictions go back to the agent before it is allowed to stop.
6
+
7
+ ```
8
+ custos-code session 4f2a… · 63 events
9
+ ✓ confirmed edited auth/middleware.py tier 1 · #14 Edit, git diff agrees
10
+ ✓ confirmed added tests/test_rate_limit.py tier 1 · #31 Write, file present
11
+ ✗ contradicted ran the suite, all 12 passing tier 2 · #41 `pytest | tail -5` exit 0, "collected 0 items"
12
+ ? unwitnessed ready to merge no CI, no git status after #41
13
+ stop blocked · 1 contradicted · evidence returned to agent
14
+ ```
15
+
16
+ ## Status
17
+
18
+ Pre-build. The design, research, plan, and evidence protocol are in `docs/`. Start with [AGENTS.md](AGENTS.md).
19
+
20
+ - [docs/DESIGN.md](docs/DESIGN.md) — problem, customers, verification ladder, feasibility, product sketches, benchmark, system design, prize strategy, adversarial review
21
+ - [docs/RESEARCH.md](docs/RESEARCH.md) — the evidence: prevalence with denominators, cost, current workarounds, tool landscape, 40 seed cases
22
+ - [docs/PLAN.md](docs/PLAN.md) — who owns what, phases, parallel tracks
23
+ - [docs/EVIDENCE_PLAN.md](docs/EVIDENCE_PLAN.md) — pre-registered study: accuracy, time saved, retention, usability
24
+ - [docs/OPEN_QUESTIONS.md](docs/OPEN_QUESTIONS.md) — every unresolved decision with an owner
25
+
26
+ ## See it work
27
+
28
+ ```bash
29
+ uv sync
30
+ export OPENAI_API_KEY=...
31
+ uv run custos-code demo # the whole loop on a known trap, live
32
+ uv run custos-code demo --scenario honest # the control: nothing blocks
33
+ uv run custos-code check --last # your own most recent session
34
+ ```
35
+
36
+ `demo` prints five things from the fixture's own tool log: what was asked, what the agent actually
37
+ did, what it said, the receipt, and the deterministic nudge that goes back. `--format html --out
38
+ card.html` writes a self-contained report card; `--format markdown` writes what the PR bot posts.
39
+
40
+ ## Prototype
41
+
42
+ `docs/prototype/index.html` is an interactive, non-functional mock of the editor experience: marks on the agent's message, the evidence panel, editor decorations, the auto-mode loop, and the PR receipt. It is a single self-contained file:
43
+
44
+ ```bash
45
+ open docs/prototype/index.html # macOS
46
+ # or: python3 -m http.server -d docs/prototype 8765 → http://localhost:8765
47
+ ```
48
+
49
+ ## Local setup
50
+
51
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) once. On macOS
52
+ with Homebrew: `brew install uv`. From the repository directory:
53
+
54
+ ```bash
55
+ uv python install
56
+ make sync
57
+ make check
58
+ uv run custos-code check --last
59
+ ```
60
+
61
+ `.python-version` selects Python 3.12, independently of your shell's pyenv or
62
+ Conda default. `make sync` installs the project and developer tools from the
63
+ committed `uv.lock`; it fails if the lockfile needs updating. After an intentional
64
+ dependency change, run `uv lock` and include the lockfile in the same PR.
65
+
66
+ `make build` produces a wheel and source distribution in `dist/`. CI runs the
67
+ same checks, installs both distributions in clean environments, and checks the
68
+ installed CLI outside the source checkout. CI uses locked installs following
69
+ the [uv integration guide](https://docs.astral.sh/uv/guides/integration/github/).
70
+
71
+ ## Bench container
72
+
73
+ With Docker installed and running, build from the repository root:
74
+
75
+ ```bash
76
+ docker build -t custos-code-bench .
77
+ docker run --rm --network none custos-code-bench
78
+ docker run --rm --network none custos-code-bench python -m pytest --version
79
+ ```
80
+
81
+ The image contains Python 3.12, uv 0.12.17, git, the installed Custos Code package,
82
+ developer dependencies, and scenario descriptions under `/app/bench/scenarios`.
83
+ It runs as a non-root user in writable `/workspace`; the default command shows
84
+ CLI help. The benchmark orchestration and fixture repos are not implemented
85
+ yet, so this is their execution environment, not a working benchmark command.
86
+ Agent CLIs and their credentials are not installed.
87
+
88
+ The Docker build context is an allowlist that excludes local session logs,
89
+ credentials, caches, and git history. For a local Python fixture, mount only
90
+ that fixture (including its git metadata when state checks need it):
91
+
92
+ ```bash
93
+ docker run --rm --network none \
94
+ --mount type=bind,src="$(pwd)/path/to/fixture",dst=/workspace,readonly \
95
+ custos-code-bench python -m pytest -p no:cacheprovider
96
+ ```
97
+
98
+ This read-only example suits tests that do not write into the fixture. Agent
99
+ bench runs will need a disposable writable checkout and explicit network and
100
+ credential configuration. Non-Python runners require additional toolchains.
101
+
102
+ ## Why
103
+
104
+ Across 20,574 real coding-agent sessions, 22.58% of 16,118 validated misalignment episodes were the agent misreporting its own work, and only 2.99% of resolved episodes were self-corrected. Every agent vendor attaches an action log; none checks the report against it. Sources and denominators: `docs/RESEARCH.md`.
105
+
106
+ ## Working in this repo
107
+
108
+ Read `AGENTS.md`. Flag anything undecided with `NEEDS-DECISION(owner):`. Local by default; secrets are redacted at ingest; fixtures are synthetic.
109
+
110
+ ## License
111
+
112
+ MIT (see `LICENSE`).
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "custos-code"
3
+ version = "0.0.1"
4
+ description = "Checks a coding agent's final report against the log of what it actually did."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["agents", "verification", "claude-code", "hooks", "observability"]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "Programming Language :: Python :: 3.12",
12
+ "Topic :: Software Development :: Quality Assurance",
13
+ ]
14
+ requires-python = ">=3.12"
15
+ dependencies = [
16
+ "pydantic>=2.7",
17
+ "typer>=0.12",
18
+ "rich>=13.7",
19
+ "openai>=1.40",
20
+ "anthropic>=0.40",
21
+ "duckdb>=1.0",
22
+ "tomli>=2.0; python_version < '3.11'",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8", "ruff>=0.5", "mypy>=1.10", "hypothesis>=6"]
27
+
28
+ [project.scripts]
29
+ custos-code = "custos_code.cli:app"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/custos_code"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = [
40
+ "/src/custos_code",
41
+ "/README.md",
42
+ "/LICENSE",
43
+ "/pyproject.toml",
44
+ ]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ exclude = ["pilots"] # feasibility-week scratch scripts, not product code
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "B", "UP"]
51
+ ignore = ["E501"] # long lines in docstrings are fine; the formatter handles code
52
+
53
+ [tool.mypy]
54
+ strict = true
55
+ python_version = "3.12"
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ """Custos Code: check a coding agent's final report against the log of what it actually did.
2
+
3
+ Read AGENTS.md before changing anything. Invariants live there. Every module below has a
4
+ docstring saying what it owns and what it must never do.
5
+ """
6
+ __version__ = "0.0.1"
@@ -0,0 +1,194 @@
1
+ """Adapters turn a source (harness transcript, hook payload, vendor export) into LedgerEvents.
2
+
3
+ Contract: every adapter is a function `parse(path_or_payload) -> tuple[Session, list[LedgerEvent], str | None]`
4
+ returning the session, the chained events, and the final report text if present.
5
+ Adapters must set flags.truncated / flags.piped / flags.sidechain honestly; downstream tiers rely on them.
6
+ Golden tests live in tests/golden/<adapter>/ : real input in, expected JSONL out.
7
+
8
+ `detect` picks the adapter from the file itself so `custos-code check <path>` needs no --agent flag.
9
+
10
+ `request_and_plan` (docs/SCOPE.md §6.3, issue #58) is scope's other input besides the ledger it
11
+ already gets: what was asked, and what the agent said it would do before doing it. It lives here,
12
+ not in `scope.py`, so scope stays agent-agnostic -- it takes a request and a plan, never a
13
+ harness-specific shape.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import Sequence
20
+ from typing import Protocol
21
+
22
+ from ..models import EventKind, LedgerEvent, Session
23
+ from . import claude_code, codex, copilot, devin, machine, otel
24
+
25
+ Source = str
26
+
27
+
28
+ class Adapter(Protocol):
29
+ def parse(self, path: str) -> tuple[Session, list[LedgerEvent], str | None]: ...
30
+
31
+
32
+ ADAPTERS: dict[Source, Adapter] = {
33
+ "claude_code": claude_code,
34
+ "codex": codex,
35
+ "devin": devin,
36
+ "machine": machine,
37
+ "copilot": copilot,
38
+ "otel": otel,
39
+ }
40
+
41
+ _CODEX_TYPES = frozenset(
42
+ {
43
+ "session_meta",
44
+ "turn_context",
45
+ "response_item",
46
+ "event_msg",
47
+ "compacted",
48
+ "thread_rolled_back",
49
+ }
50
+ )
51
+
52
+
53
+ def _bundle_source(obj: dict[str, object]) -> Source | None:
54
+ """Class-R bundles and OTLP payloads are one JSON object; tell them apart by their keys."""
55
+ if "resourceSpans" in obj or "scopeSpans" in obj:
56
+ return "otel"
57
+ raw = obj.get("session")
58
+ session: dict[str, object] = raw if isinstance(raw, dict) else {}
59
+ keys = set(obj) | {f"session.{k}" for k in session}
60
+ # Copilot first: both bundles carry `pull_request` and a `session.session_id`, and only
61
+ # Copilot carries a workspace or an attached tool log. Devin's tell is structured_output.
62
+ if "pull_request" in keys and (keys & {"log", "session.workspace", "session.agent_log"}):
63
+ return "copilot"
64
+ if keys & {"session.structured_output", "structured_output"}:
65
+ return "devin"
66
+ if keys & {"session.session_id", "session_id", "pull_request", "pull_requests"}:
67
+ return "devin"
68
+ return None
69
+
70
+
71
+ def _line_source(rec: dict[str, object]) -> Source | None:
72
+ if "sessionId" in rec or (rec.get("type") in ("assistant", "user") and "message" in rec):
73
+ return "claude_code"
74
+ if str(rec.get("type")) in _CODEX_TYPES:
75
+ return "codex"
76
+ if rec.get("recorder") in machine.RECORDER_NAMES:
77
+ return "machine"
78
+ if "traceId" in rec and "spanId" in rec:
79
+ return "otel"
80
+ return None
81
+
82
+
83
+ def detect(path: str) -> Source:
84
+ """Name the adapter for a file, by its shape. Raises ValueError when nothing matches."""
85
+ with open(path, encoding="utf-8", errors="ignore") as fh:
86
+ text = fh.read()
87
+ if not text.strip():
88
+ raise ValueError(f"{path} is empty")
89
+ try:
90
+ whole = json.loads(text)
91
+ except json.JSONDecodeError:
92
+ whole = None
93
+ if isinstance(whole, dict):
94
+ name = _bundle_source(whole)
95
+ if name:
96
+ return name
97
+ for line in text.splitlines():
98
+ if not line.strip():
99
+ continue
100
+ try:
101
+ rec = json.loads(line)
102
+ except json.JSONDecodeError:
103
+ continue
104
+ if isinstance(rec, dict):
105
+ name = _line_source(rec)
106
+ if name:
107
+ return name
108
+ raise ValueError(f"cannot tell which agent wrote {path}; pass --agent")
109
+
110
+
111
+ def parse(path: str, source: Source | None = None) -> tuple[Session, list[LedgerEvent], str | None]:
112
+ """Parse a transcript with the adapter named by `source`, or the one `detect` picks."""
113
+ name = source or detect(path)
114
+ adapter = ADAPTERS.get(name)
115
+ if adapter is None:
116
+ raise ValueError(f"unknown agent {name!r}; one of {', '.join(sorted(ADAPTERS))}")
117
+ return adapter.parse(path)
118
+
119
+
120
+ def _first_user_text(ledger: Sequence[LedgerEvent]) -> str:
121
+ """The earliest non-sidechain USER event's text -- the literal ask, verbatim.
122
+
123
+ Claude Code, Codex, and Devin (when it has a chat transcript) all write these already; nothing
124
+ new has to be recorded (SCOPE.md §6.3's whole premise).
125
+ """
126
+ for event in ledger:
127
+ if event.kind is EventKind.USER and not event.flags.sidechain and event.output:
128
+ return event.output
129
+ return ""
130
+
131
+
132
+ def _todo_plan(ledger: Sequence[LedgerEvent]) -> list[str]:
133
+ """Claude Code's `TodoWrite`: the most recent call's items, oldest first.
134
+
135
+ The latest call wins, not the first -- a plan is allowed to change, and the self-authored
136
+ contract SCOPE.md §3 describes is whatever the agent most recently committed to, not its first
137
+ draft.
138
+ """
139
+ latest: LedgerEvent | None = None
140
+ for event in ledger:
141
+ if event.kind is EventKind.CALL and event.tool == "TodoWrite" and not event.flags.sidechain:
142
+ latest = event
143
+ if latest is None:
144
+ return []
145
+ todos = (latest.input or {}).get("todos")
146
+ if not isinstance(todos, list):
147
+ return []
148
+ out = []
149
+ for item in todos:
150
+ if not isinstance(item, dict):
151
+ continue
152
+ text = item.get("content") or item.get("activeForm") or item.get("task")
153
+ if isinstance(text, str) and text.strip():
154
+ out.append(text.strip())
155
+ return out
156
+
157
+
158
+ def _stated_plan_before_first_call(ledger: Sequence[LedgerEvent]) -> list[str]:
159
+ """Fallback plan: the last thing the agent said before it touched a tool.
160
+
161
+ A message written before any evidence exists is a commitment it cannot later revise -- the
162
+ same "log it has no write path to" property SCOPE.md §3 grounds the plan in. Applies to any
163
+ adapter whose ledger has TEXT/CALL events in the shared shape (Claude Code, Codex today).
164
+ """
165
+ first_call_seq = next(
166
+ (e.seq for e in ledger if e.kind is EventKind.CALL and not e.flags.sidechain), None
167
+ )
168
+ last_text = ""
169
+ for event in ledger:
170
+ if first_call_seq is not None and event.seq >= first_call_seq:
171
+ break
172
+ if event.kind is EventKind.TEXT and not event.flags.sidechain and event.output:
173
+ last_text = event.output
174
+ return [last_text] if last_text.strip() else []
175
+
176
+
177
+ def request_and_plan(
178
+ session: Session, ledger: Sequence[LedgerEvent], report: str | None = None
179
+ ) -> tuple[str, list[str]]:
180
+ """The spec and the agent's self-authored plan, per docs/SCOPE.md §6.3.
181
+
182
+ Deliberately reads the ledger, not the harness: Claude Code and Codex both already write a
183
+ USER event for the request, so no per-agent branch is needed to find it. Class-R bundles
184
+ (Copilot, Devin without a chat transcript) have no live back-and-forth to draw either from --
185
+ there, the PR body (`report`, already the same text `custos-code check` extracts claims from)
186
+ doubles as the spec, exactly as SCOPE.md §6.3 says, and there is no separate plan to find.
187
+
188
+ A `TodoWrite` call is the strongest plan signal where one exists; otherwise the last thing said
189
+ before the first tool call stands in for it (still empty for a class-R bundle, which has no
190
+ "before the first call" boundary to speak of).
191
+ """
192
+ request = _first_user_text(ledger) or (report or "")
193
+ plan = _todo_plan(ledger) or _stated_plan_before_first_call(ledger)
194
+ return request, plan