debrief-mcp 0.1.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.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.3
2
+ Name: debrief-mcp
3
+ Version: 0.1.0
4
+ Summary: Evidence MCP for agents: what ggufbench measured, where a buildwright run broke, role proposals as text — never writes
5
+ Author: Jordi Marsal
6
+ Author-email: Jordi Marsal <jordi.marsal@gmail.com>
7
+ Requires-Dist: mcp>=2.3,<3
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # debrief
12
+
13
+ The evidence MCP: answers agent questions from receipts — what
14
+ [ggufbench](https://github.com/jordimarsal/ggufbench) measured, and where the
15
+ last [buildwright](https://github.com/jordimarsal/buildwright) run broke — and
16
+ proposes role changes **as text**. It never writes.
17
+
18
+ > Status: scaffolding. The harness is installed and the parent feature is
19
+ > `pending` — the spec (`harness/specs/debrief/`) is not written yet, so the
20
+ > tool list below is the plan, not the product.
21
+
22
+ ## The idea
23
+
24
+ - `ggufbench` owns the **catalog**: what a model measured, under which task
25
+ signature, and why rows were excluded.
26
+ - `buildwright` owns the **episode**: one run, its gates, its failures.
27
+ - `debrief` is the reader. One query surface, one shared finding type, and a
28
+ proposal that cites both. Two ports that answer different questions:
29
+
30
+ | Port | Question | Reads | Tools (planned) |
31
+ | --- | --- | --- | --- |
32
+ | `bench` | What did we measure? | ggufbench artifacts | `bench.which_model` · `bench.compare` · `bench.why_excluded` |
33
+ | `run` | Where did this run break? | a buildwright run log | `run.inspect_run` · `run.failures` · `run.tool_calls` |
34
+ | `routing.propose` | What would you change? | findings from both | prints a `BW_ROLE_*` diff as text |
35
+
36
+ ## Product rules (the gates that matter in code)
37
+
38
+ 1. **debrief never writes.** Every tool is read-only or returns a proposal as
39
+ text. No tool touches `.env`, `BW_ROLE_*`, `models.yaml`, or any other file.
40
+ 2. **Proposals target roles, not the registry.** A proposed change is a
41
+ `BW_ROLE_<ROLE>` diff. `models.yaml` is the registry of what exists — not
42
+ debrief's to edit.
43
+ 3. **bench and run answer from their own source.** `run` may cite `bench`
44
+ findings; `bench` never reads runs.
45
+ 4. **Rows never cross a task signature.** Incomparable rows are refused with a
46
+ reason — never averaged.
47
+
48
+ ## The gates (process)
49
+
50
+ The repo runs the [harness](https://github.com/jordimarsal/harness) SDD
51
+ workflow in **hybrid** mode: one in-session agent plays the roles, the human
52
+ gates stay mandatory. Architecture is **hexagonal** (`docs/architecture.md`);
53
+ features are cut into **vertical slices** (`docs/slicing-protocol.md`) —
54
+ S1 `bench_query` → S2 `run_inspect` → S3 `role_propose`, each demoable
55
+ end-to-end.
56
+
57
+ | Gate | What it checks | Command / artefact |
58
+ | --- | --- | --- |
59
+ | **Spec Gate** (human) | EARS trio in `harness/specs/<name>/`, slice map approved before any slice starts | review + explicit approval |
60
+ | **Batch gates** (every 2–4 tasks) | tests + lint + types, evidence saved | `uv run pytest tests`, `ruff check`, `mypy --strict` — full output to `harness/logs/<feature>/batch-<n>.log` |
61
+ | **Security audit** (`audit_level: standard`) | path traversal, input validation, secrets — run at review time | `bash harness/tools/audit-security.sh` |
62
+ | **Gauntlet G1–G4** (before Completion) | correctness, adversarial input, maintainability, evidence | `bash harness/tools/gauntlet.sh --feature <name>` + report in `harness/progress/gauntlet_<name>.md` |
63
+ | **Completion Gate** (human) | traceability `R<n>` ↔ test, plus a **recorded end-to-end demo** per slice | `python3 harness/tools/check-traceability.py --feature <name>` → `harness/specs/<name>/review.md` |
64
+ | **Session verify** | harness files, docs, tests, state coherence | `harness/init.sh` |
65
+
66
+ Two gates are non-negotiable and human: the **Spec Gate** and the **Completion
67
+ Gate**. The gauntlet filters what reaches them; it never replaces them.
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ uv sync
73
+ uv run pytest tests
74
+ ```
75
+
76
+ Requires Python ≥ 3.14 ([uv](https://docs.astral.sh/uv/)).
77
+
78
+ ## Repository map
79
+
80
+ See [`AGENTS.md`](AGENTS.md) — it is the navigation map for agents (and humans).
81
+ Architecture: [`docs/architecture.md`](docs/architecture.md). Decisions worth
82
+ remembering: [`harness/decisions/`](harness/decisions/).
83
+
84
+ [![built with harness](https://raw.githubusercontent.com/jordimarsal/harness/main/assets/badge.svg)](https://github.com/jordimarsal/harness)
@@ -0,0 +1,74 @@
1
+ # debrief
2
+
3
+ The evidence MCP: answers agent questions from receipts — what
4
+ [ggufbench](https://github.com/jordimarsal/ggufbench) measured, and where the
5
+ last [buildwright](https://github.com/jordimarsal/buildwright) run broke — and
6
+ proposes role changes **as text**. It never writes.
7
+
8
+ > Status: scaffolding. The harness is installed and the parent feature is
9
+ > `pending` — the spec (`harness/specs/debrief/`) is not written yet, so the
10
+ > tool list below is the plan, not the product.
11
+
12
+ ## The idea
13
+
14
+ - `ggufbench` owns the **catalog**: what a model measured, under which task
15
+ signature, and why rows were excluded.
16
+ - `buildwright` owns the **episode**: one run, its gates, its failures.
17
+ - `debrief` is the reader. One query surface, one shared finding type, and a
18
+ proposal that cites both. Two ports that answer different questions:
19
+
20
+ | Port | Question | Reads | Tools (planned) |
21
+ | --- | --- | --- | --- |
22
+ | `bench` | What did we measure? | ggufbench artifacts | `bench.which_model` · `bench.compare` · `bench.why_excluded` |
23
+ | `run` | Where did this run break? | a buildwright run log | `run.inspect_run` · `run.failures` · `run.tool_calls` |
24
+ | `routing.propose` | What would you change? | findings from both | prints a `BW_ROLE_*` diff as text |
25
+
26
+ ## Product rules (the gates that matter in code)
27
+
28
+ 1. **debrief never writes.** Every tool is read-only or returns a proposal as
29
+ text. No tool touches `.env`, `BW_ROLE_*`, `models.yaml`, or any other file.
30
+ 2. **Proposals target roles, not the registry.** A proposed change is a
31
+ `BW_ROLE_<ROLE>` diff. `models.yaml` is the registry of what exists — not
32
+ debrief's to edit.
33
+ 3. **bench and run answer from their own source.** `run` may cite `bench`
34
+ findings; `bench` never reads runs.
35
+ 4. **Rows never cross a task signature.** Incomparable rows are refused with a
36
+ reason — never averaged.
37
+
38
+ ## The gates (process)
39
+
40
+ The repo runs the [harness](https://github.com/jordimarsal/harness) SDD
41
+ workflow in **hybrid** mode: one in-session agent plays the roles, the human
42
+ gates stay mandatory. Architecture is **hexagonal** (`docs/architecture.md`);
43
+ features are cut into **vertical slices** (`docs/slicing-protocol.md`) —
44
+ S1 `bench_query` → S2 `run_inspect` → S3 `role_propose`, each demoable
45
+ end-to-end.
46
+
47
+ | Gate | What it checks | Command / artefact |
48
+ | --- | --- | --- |
49
+ | **Spec Gate** (human) | EARS trio in `harness/specs/<name>/`, slice map approved before any slice starts | review + explicit approval |
50
+ | **Batch gates** (every 2–4 tasks) | tests + lint + types, evidence saved | `uv run pytest tests`, `ruff check`, `mypy --strict` — full output to `harness/logs/<feature>/batch-<n>.log` |
51
+ | **Security audit** (`audit_level: standard`) | path traversal, input validation, secrets — run at review time | `bash harness/tools/audit-security.sh` |
52
+ | **Gauntlet G1–G4** (before Completion) | correctness, adversarial input, maintainability, evidence | `bash harness/tools/gauntlet.sh --feature <name>` + report in `harness/progress/gauntlet_<name>.md` |
53
+ | **Completion Gate** (human) | traceability `R<n>` ↔ test, plus a **recorded end-to-end demo** per slice | `python3 harness/tools/check-traceability.py --feature <name>` → `harness/specs/<name>/review.md` |
54
+ | **Session verify** | harness files, docs, tests, state coherence | `harness/init.sh` |
55
+
56
+ Two gates are non-negotiable and human: the **Spec Gate** and the **Completion
57
+ Gate**. The gauntlet filters what reaches them; it never replaces them.
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ uv sync
63
+ uv run pytest tests
64
+ ```
65
+
66
+ Requires Python ≥ 3.14 ([uv](https://docs.astral.sh/uv/)).
67
+
68
+ ## Repository map
69
+
70
+ See [`AGENTS.md`](AGENTS.md) — it is the navigation map for agents (and humans).
71
+ Architecture: [`docs/architecture.md`](docs/architecture.md). Decisions worth
72
+ remembering: [`harness/decisions/`](harness/decisions/).
73
+
74
+ [![built with harness](https://raw.githubusercontent.com/jordimarsal/harness/main/assets/badge.svg)](https://github.com/jordimarsal/harness)
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "debrief-mcp"
3
+ version = "0.1.0"
4
+ description = "Evidence MCP for agents: what ggufbench measured, where a buildwright run broke, role proposals as text — never writes"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = ["mcp>=2.3,<3"]
8
+
9
+ [[project.authors]]
10
+ name = "Jordi Marsal"
11
+ email = "jordi.marsal@gmail.com"
12
+
13
+ [project.scripts]
14
+ debrief = "debrief:main"
15
+ debrief-mcp = "debrief:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12.19,<0.13.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "mypy>=1.15",
24
+ "pytest>=9.2.0",
25
+ "ruff>=0.14",
26
+ ]
27
+
28
+ [tool.ruff]
29
+ line-length = 100
30
+ target-version = "py314"
31
+ extend-exclude = ["harness/tools"]
32
+
33
+ [tool.ruff.lint]
34
+ select = [
35
+ "E",
36
+ "F",
37
+ "W",
38
+ "I",
39
+ "UP",
40
+ "B",
41
+ ]
42
+
43
+ [tool.mypy]
44
+ strict = true
45
+ files = [
46
+ "src",
47
+ "tests",
48
+ ]
49
+
50
+ [tool.uv.build-backend]
51
+ module-name = "debrief"
52
+ module-root = "src"
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "debrief-mcp"
3
+ version = "0.1.0"
4
+ description = "Evidence MCP for agents: what ggufbench measured, where a buildwright run broke, role proposals as text — never writes"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jordi Marsal", email = "jordi.marsal@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "mcp>=2.3,<3",
12
+ ]
13
+
14
+ [project.scripts]
15
+ debrief = "debrief:main"
16
+ debrief-mcp = "debrief:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.12.19,<0.13.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "mypy>=1.15",
25
+ "pytest>=9.2.0",
26
+ "ruff>=0.14",
27
+ ]
28
+
29
+ [tool.ruff]
30
+ line-length = 100
31
+ target-version = "py314"
32
+ # harness/tools are harness-owned scripts executed by the system python3 (<3.14),
33
+ # so py314-only syntax ruff formats towards must never be applied to them.
34
+ extend-exclude = ["harness/tools"]
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "W", "I", "UP", "B"]
38
+
39
+ [tool.mypy]
40
+ strict = true
41
+ files = ["src", "tests"]
42
+
43
+ [tool.uv.build-backend]
44
+ # Distribution is `debrief-mcp` (PyPI name), but the importable package stays
45
+ # `debrief` — module name does not have to match the distribution name.
46
+ module-name = "debrief"
47
+ module-root = "src"
@@ -0,0 +1,16 @@
1
+ """debrief — answers agent questions from receipts; never writes (ADR-0001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = ["main"]
8
+
9
+
10
+ def __getattr__(name: str) -> Any:
11
+ # Lazy on purpose: `import debrief` (and so debrief.domain) must not pull in the mcp SDK.
12
+ if name == "main":
13
+ from debrief.mcp_server import main
14
+
15
+ return main
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
File without changes
@@ -0,0 +1,213 @@
1
+ """buildwright run log adapter: read-only mapping of one run log to Findings (R4).
2
+
3
+ Format: ``key: value`` header lines (``run:``, ``engine_build:``, ``task:``,
4
+ ``roles:``, ``result:``) followed by one ``step:`` line per logged step. The
5
+ adapter is the only place that knows this format (ADR-0002); a run log never
6
+ yields a task signature — it is one episode, not a measurement.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ import stat
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from debrief.domain import FAIL, OUTCOMES, PASS, Finding
18
+ from debrief.errors import RUN_SOURCE_MISSING, SOURCE_UNPARSEABLE, DebriefError
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # A named tuple, not an inline `except (A, B):`: ruff format (target py314) rewrites
23
+ # the inline spelling towards the PEP 758 bare-comma form, which this repo's review
24
+ # rejected; the named tuple keeps one spelling that is valid on every Python 3.
25
+ _MISSING_PATH_ERRORS = (FileNotFoundError, NotADirectoryError)
26
+
27
+ _STEP_RE = re.compile(
28
+ r"^step:\s*(?P<ordinal>\d+)\s+"
29
+ r"role=(?P<role>\S+)\s+"
30
+ r"model=(?P<model>\S+)\s+"
31
+ r"outcome=(?P<outcome>\S+)\s+"
32
+ r'reason="(?P<reason>[^"]*)"\s+'
33
+ r"evidence=(?P<evidence>\S+)$"
34
+ )
35
+
36
+ _RESULT_RE = re.compile(r"^(?P<outcome>\S+)(?:\s+failed_steps=(?P<failed_steps>\S*))?$")
37
+
38
+
39
+ class _Unparseable(Exception):
40
+ """A run log did not match the buildwright log format."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _Step:
45
+ """One logged step before it becomes a Finding; the ordinal exists only
46
+ here because the ``result:`` header names failed steps by it, and a
47
+ Finding never carries run-internal bookkeeping (ADR-0002)."""
48
+
49
+ ordinal: str
50
+ role: str
51
+ model: str
52
+ outcome: str
53
+ reason: str
54
+ evidence: str
55
+
56
+
57
+ class BuildwrightLog:
58
+ """RunLog over one buildwright run log; opens the file for reading only (R2, R4)."""
59
+
60
+ def __init__(self, log_file: Path) -> None:
61
+ self._log_file = log_file
62
+
63
+ def findings(self) -> list[Finding] | DebriefError:
64
+ # stat()-based probe, mirroring the bench adapter: pathlib's is_file()
65
+ # answers False for a log whose parent directory is unreadable, which
66
+ # would mislabel an existing-but-unreadable log as missing. An evidence
67
+ # tool never silently drops a source it was asked to read.
68
+ try:
69
+ st = self._log_file.stat()
70
+ except _MISSING_PATH_ERRORS:
71
+ return DebriefError(
72
+ code=RUN_SOURCE_MISSING,
73
+ message="no buildwright run log at the requested path",
74
+ detail=str(self._log_file),
75
+ )
76
+ except PermissionError as err:
77
+ return DebriefError(
78
+ code=SOURCE_UNPARSEABLE,
79
+ message=f"{self._log_file} exists but cannot be probed",
80
+ detail=str(err),
81
+ )
82
+ if not stat.S_ISREG(st.st_mode):
83
+ return DebriefError(
84
+ code=RUN_SOURCE_MISSING,
85
+ message="no buildwright run log at the requested path",
86
+ detail=str(self._log_file),
87
+ )
88
+ try:
89
+ lines = self._log_file.read_text(encoding="utf-8").splitlines()
90
+ except (OSError, ValueError) as err:
91
+ # ValueError covers undecodable bytes (UnicodeDecodeError), not just I/O errors.
92
+ return DebriefError(
93
+ code=SOURCE_UNPARSEABLE,
94
+ message=f"{self._log_file} could not be read",
95
+ detail=str(err),
96
+ )
97
+ try:
98
+ findings = _map_log(lines)
99
+ except _Unparseable as err:
100
+ return DebriefError(
101
+ code=SOURCE_UNPARSEABLE,
102
+ message=f"{self._log_file} is not a buildwright run log",
103
+ detail=str(err),
104
+ )
105
+ log.info("read %d step(s) from %s", len(findings), self._log_file.name)
106
+ return findings
107
+
108
+
109
+ def _map_log(lines: list[str]) -> list[Finding]:
110
+ task = _header(lines, "task")
111
+ steps = [_parse_step(line) for line in lines if line.startswith("step:")]
112
+ if not steps:
113
+ # A headers-only log (truncated or step-stripped) would otherwise answer
114
+ # [] as a structured success and flip routing.propose to the all-passed
115
+ # claim even when the log's own result: header declares a failure — the
116
+ # same fabrication class the outcome-enum check guards. An evidence tool
117
+ # never silently accepts an episode with no logged steps.
118
+ raise _Unparseable("run log has a task header but no step: lines")
119
+ _check_result_header(_optional_header(lines, "result"), steps)
120
+ return [_finding(step, task) for step in steps]
121
+
122
+
123
+ def _header(lines: list[str], key: str) -> str:
124
+ prefix = f"{key}:"
125
+ for line in lines:
126
+ if line.startswith(prefix):
127
+ value = line[len(prefix) :].strip()
128
+ if value:
129
+ return value
130
+ raise _Unparseable(f"no {key}: header line")
131
+
132
+
133
+ def _optional_header(lines: list[str], key: str) -> str | None:
134
+ prefix = f"{key}:"
135
+ for line in lines:
136
+ if line.startswith(prefix):
137
+ value = line[len(prefix) :].strip()
138
+ return value or None
139
+ return None
140
+
141
+
142
+ def _parse_step(line: str) -> _Step:
143
+ match = _STEP_RE.match(line)
144
+ if match is None:
145
+ raise _Unparseable(f"malformed step line: {line!r}")
146
+ outcome = match.group("outcome")
147
+ if outcome not in OUTCOMES:
148
+ # An unvalidated token would flip _propose to "every logged step passed"
149
+ # for a failed run, so the enum is checked at this boundary (conventions:
150
+ # validate at the boundary, trust types inside).
151
+ raise _Unparseable(f"outcome is not one of {sorted(OUTCOMES)}: {outcome!r}")
152
+ return _Step(
153
+ ordinal=match.group("ordinal"),
154
+ role=match.group("role"),
155
+ model=match.group("model"),
156
+ outcome=outcome,
157
+ reason=match.group("reason"),
158
+ evidence=match.group("evidence"),
159
+ )
160
+
161
+
162
+ def _finding(step: _Step, task: str) -> Finding:
163
+ return Finding(
164
+ model=step.model,
165
+ task=task,
166
+ outcome=step.outcome,
167
+ reason=step.reason,
168
+ evidence=step.evidence,
169
+ role=step.role,
170
+ )
171
+
172
+
173
+ def _check_result_header(result_line: str | None, steps: list[_Step]) -> None:
174
+ """A log's ``result:`` header is evidence too: when present it must not
175
+ contradict the steps it summarizes. Left unchecked, ``result: pass`` over a
176
+ logged failure (or a failed_steps list naming steps that passed) would let
177
+ run.inspect answer a success the log itself calls a lie, and
178
+ routing.propose the all-passed claim for a failed episode. The header stays
179
+ optional — producers older than the failed_steps convention omit it — and
180
+ the check runs only once every step line has parsed."""
181
+ if result_line is None:
182
+ return
183
+ match = _RESULT_RE.fullmatch(result_line)
184
+ if match is None:
185
+ raise _Unparseable(f"malformed result: header: {result_line!r}")
186
+ outcome = match.group("outcome")
187
+ if outcome not in OUTCOMES:
188
+ raise _Unparseable(f"result header outcome is not one of {sorted(OUTCOMES)}: {outcome!r}")
189
+ failed = [step for step in steps if step.outcome == FAIL]
190
+ if outcome == PASS:
191
+ if failed:
192
+ raise _Unparseable(
193
+ f"result header declares pass but logged step {failed[0].ordinal} failed"
194
+ )
195
+ return
196
+ declared = _declared_failed_steps(match.group("failed_steps"))
197
+ if not failed:
198
+ raise _Unparseable("result header declares a failure no logged step records")
199
+ logged = {step.ordinal.lstrip("0") or "0" for step in failed}
200
+ if declared != logged:
201
+ raise _Unparseable(
202
+ f"result header failed_steps={match.group('failed_steps')!r} does not match"
203
+ f" the logged failing step(s) {sorted(logged)}"
204
+ )
205
+
206
+
207
+ def _declared_failed_steps(token: str | None) -> set[str]:
208
+ if token is None:
209
+ raise _Unparseable("result header declares a failure without a failed_steps list")
210
+ declared = {part.strip().lstrip("0") or "0" for part in token.split(",") if part.strip()}
211
+ if not declared:
212
+ raise _Unparseable(f"result header failed_steps is empty: {token!r}")
213
+ return declared
@@ -0,0 +1,250 @@
1
+ """ggufbench artifacts adapter: read-only mapping of artifact rows to Findings (R3).
2
+
3
+ Layout: one ``<run-id>/results.json`` per ggufbench run under the artifact root.
4
+ The adapter is the only place that knows this format (ADR-0002).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import math
12
+ import re
13
+ import stat
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from debrief.domain import OUTCOMES, Finding, TaskSignature
18
+ from debrief.errors import BENCH_SOURCE_MISSING, SOURCE_UNPARSEABLE, DebriefError
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ RESULTS_FILE = "results.json"
23
+
24
+ # A named tuple, not an inline `except (A, B):`: ruff format (target py314) rewrites
25
+ # the inline spelling towards the PEP 758 bare-comma form, which this repo's review
26
+ # rejected; the named tuple keeps one spelling that is valid on every Python 3.
27
+ _MISSING_PATH_ERRORS = (FileNotFoundError, NotADirectoryError)
28
+
29
+ # Artifact string fields reach the routing.propose text verbatim, so a newline
30
+ # in a poisoned row could forge arbitrary proposal diff lines (ADR-0004
31
+ # fabrication class). Identifier-like fields must be single-line whitespace-free
32
+ # tokens; free-text fields single-line control-char-free — both validated here,
33
+ # at the boundary, per conventions.
34
+ _TOKEN_RE = re.compile(r"\S+")
35
+
36
+
37
+ class _Unparseable(Exception):
38
+ """An artifact did not match the ggufbench results schema."""
39
+
40
+
41
+ class GgufbenchStore:
42
+ """BenchStore over ggufbench artifacts; opens files for reading only (R2, R3)."""
43
+
44
+ def __init__(self, artifact_root: Path) -> None:
45
+ self._artifact_root = artifact_root
46
+
47
+ def findings(self) -> list[Finding] | DebriefError:
48
+ try:
49
+ results_files = self._results_files()
50
+ except OSError as err:
51
+ return DebriefError(
52
+ code=SOURCE_UNPARSEABLE,
53
+ message=f"{self._artifact_root} could not be scanned for {RESULTS_FILE}",
54
+ detail=str(err),
55
+ )
56
+ except _Unparseable as err:
57
+ # A symlink below the requested path must not turn the store into a
58
+ # read primitive for files outside the evidence root (conventions:
59
+ # resolve against an explicit allowed root — security-audit A03).
60
+ return DebriefError(
61
+ code=SOURCE_UNPARSEABLE,
62
+ message=f"a run's {RESULTS_FILE} escapes the ggufbench artifact root",
63
+ detail=str(err),
64
+ )
65
+ if results_files is None:
66
+ return DebriefError(
67
+ code=BENCH_SOURCE_MISSING,
68
+ message="no ggufbench artifacts at the requested path",
69
+ detail=str(self._artifact_root),
70
+ )
71
+ if not results_files:
72
+ return DebriefError(
73
+ code=BENCH_SOURCE_MISSING,
74
+ message=f"no {RESULTS_FILE} under any run directory",
75
+ detail=str(self._artifact_root),
76
+ )
77
+ findings: list[Finding] = []
78
+ for results_file in results_files:
79
+ mapped = _read_run(results_file)
80
+ if isinstance(mapped, DebriefError):
81
+ return mapped
82
+ findings.extend(mapped)
83
+ log.info("read %d ggufbench row(s) from %d run(s)", len(findings), len(results_files))
84
+ return findings
85
+
86
+ def _results_files(self) -> list[Path] | None:
87
+ """One results.json per run under the root; None when the root itself is absent.
88
+
89
+ Scans with stat(), not is_file(): pathlib's is_*() answer False for
90
+ unreadable directories, which would silently drop a run the tool must
91
+ report; stat() raises, and findings() maps that to a structured error.
92
+ Every candidate is resolved and required to stay under the resolved
93
+ root before anything is read, so symlink indirection below the
94
+ requested path cannot escape the evidence root (A03).
95
+ """
96
+ root = self._artifact_root.resolve()
97
+ if not root.is_dir():
98
+ return None
99
+ own = _contained_results(root, root / RESULTS_FILE)
100
+ if own is not None:
101
+ return [own]
102
+ found: list[Path] = []
103
+ for path in root.iterdir():
104
+ contained = _contained_results(root, path / RESULTS_FILE)
105
+ if contained is not None:
106
+ found.append(contained)
107
+ return sorted(found)
108
+
109
+
110
+ def _contained_results(root: Path, candidate: Path) -> Path | None:
111
+ """Resolve `candidate` and return it only when it stays under `root` and is a file.
112
+
113
+ A `<run>/results.json` may be a symlink: without this check an artifact
114
+ could name any file on the host and its bytes would enter findings,
115
+ bypassing the root boundary every agent-controlled path respects.
116
+ An escaping candidate refuses the whole scan — an evidence tool never
117
+ silently drops (or silently reads past its boundary) a run directory.
118
+ """
119
+ try:
120
+ resolved = candidate.resolve()
121
+ except OSError as err:
122
+ raise _Unparseable(f"{candidate} could not be resolved") from err
123
+ if not resolved.is_relative_to(root):
124
+ raise _Unparseable(f"{candidate} resolves outside the artifact root")
125
+ return resolved if _is_regular_file(resolved) else None
126
+
127
+
128
+ def _is_regular_file(path: Path) -> bool:
129
+ try:
130
+ return stat.S_ISREG(path.stat().st_mode)
131
+ except _MISSING_PATH_ERRORS:
132
+ return False
133
+
134
+
135
+ def _read_run(results_file: Path) -> list[Finding] | DebriefError:
136
+ try:
137
+ document: Any = json.loads(results_file.read_text(encoding="utf-8"))
138
+ except (OSError, ValueError, RecursionError) as err:
139
+ # ValueError covers JSONDecodeError and undecodable bytes; RecursionError
140
+ # covers nesting-bomb JSON, which no legitimate artifact carries.
141
+ return DebriefError(
142
+ code=SOURCE_UNPARSEABLE,
143
+ message=f"{results_file} could not be read as JSON",
144
+ detail=str(err),
145
+ )
146
+ try:
147
+ return _map_document(document)
148
+ except _Unparseable as err:
149
+ return DebriefError(
150
+ code=SOURCE_UNPARSEABLE,
151
+ message=f"{results_file} is not a ggufbench results document",
152
+ detail=str(err),
153
+ )
154
+
155
+
156
+ def _map_document(document: Any) -> list[Finding]:
157
+ if not isinstance(document, dict):
158
+ raise _Unparseable("document is not an object")
159
+ engine_build = _required_token(document, "engine_build")
160
+ baseline = _required_token(document, "baseline")
161
+ rows = document.get("rows")
162
+ if not isinstance(rows, list):
163
+ raise _Unparseable("rows is not an array")
164
+ return [_map_row(row, baseline, engine_build) for row in rows]
165
+
166
+
167
+ def _map_row(row: Any, baseline: str, engine_build: str) -> Finding:
168
+ if not isinstance(row, dict):
169
+ raise _Unparseable("row is not an object")
170
+ graded = _required_counts(row)
171
+ score = row.get("score")
172
+ if isinstance(score, bool) or not isinstance(score, (int, float)):
173
+ raise _Unparseable("score is not a number")
174
+ try:
175
+ measure = float(score)
176
+ except OverflowError as err:
177
+ # JSON integers are arbitrary-precision; a score too wide for a float is
178
+ # corrupt evidence, not a crash (R1: every answer is findings or a
179
+ # structured error).
180
+ raise _Unparseable(f"score overflows a float: {score}") from err
181
+ if not math.isfinite(measure):
182
+ # json.loads accepts the NaN/Infinity/-Infinity tokens; a non-finite
183
+ # measure would win the proposal ranking and fabricate a role change
184
+ # (the ADR-0004 fabrication class), so it refuses at this boundary.
185
+ raise _Unparseable(f"score is not a finite number: {measure!r}")
186
+ outcome = _required_str(row, "outcome")
187
+ if outcome not in OUTCOMES:
188
+ raise _Unparseable(f"outcome is not one of {sorted(OUTCOMES)}: {outcome!r}")
189
+ return Finding(
190
+ model=_required_token(row, "model"),
191
+ task=_required_token(row, "task"),
192
+ outcome=outcome,
193
+ reason=_required_line(row, "reason"),
194
+ evidence=_required_line(row, "artifact"),
195
+ signature=TaskSignature.of(graded, baseline, engine_build),
196
+ measure=measure,
197
+ )
198
+
199
+
200
+ def _required_counts(row: dict[str, Any]) -> dict[str, int]:
201
+ graded = row.get("graded")
202
+ if not isinstance(graded, dict):
203
+ raise _Unparseable("graded is not an object of counts")
204
+ counts: dict[str, int] = {}
205
+ for dimension, count in graded.items():
206
+ if not isinstance(dimension, str) or isinstance(count, bool) or not isinstance(count, int):
207
+ raise _Unparseable("graded counts must map dimension names to integers")
208
+ if _TOKEN_RE.fullmatch(dimension) is None:
209
+ # Dimension names join the task signature and the proposal text; a
210
+ # multi-line name would forge proposal lines exactly like a model id.
211
+ raise _Unparseable(f"graded dimension is not a single-line token: {dimension!r}")
212
+ counts[dimension] = count
213
+ return counts
214
+
215
+
216
+ def _required_str(document: dict[str, Any], key: str) -> str:
217
+ value = document.get(key)
218
+ if not isinstance(value, str) or not value:
219
+ raise _Unparseable(f"{key} is not a non-empty string")
220
+ return value
221
+
222
+
223
+ def _required_token(document: dict[str, Any], key: str) -> str:
224
+ """Identifier-like field: one non-empty, whitespace-free line (no newline forgery)."""
225
+ value = _required_str(document, key)
226
+ if _TOKEN_RE.fullmatch(value) is None:
227
+ raise _Unparseable(f"{key} is not a single-line whitespace-free token: {value!r}")
228
+ _refuse_control_chars(key, value)
229
+ return value
230
+
231
+
232
+ def _required_line(document: dict[str, Any], key: str) -> str:
233
+ """Free-text field: one non-empty line with no control characters (incl. newlines).
234
+
235
+ The single-line rule is str.splitlines(), not just ``ord(ch) < 0x20``:
236
+ Unicode line separators (U+0085 NEL, U+2028, U+2029) sit above that range
237
+ but still split the value everywhere Python splits text, so a poisoned
238
+ reason or artifact path could smuggle a forged proposal line past a pure
239
+ control-character check.
240
+ """
241
+ value = _required_str(document, key)
242
+ _refuse_control_chars(key, value)
243
+ if value.splitlines() != [value]:
244
+ raise _Unparseable(f"{key} is not a single line: {value!r}")
245
+ return value
246
+
247
+
248
+ def _refuse_control_chars(key: str, value: str) -> None:
249
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value):
250
+ raise _Unparseable(f"{key} contains control characters: {value!r}")
@@ -0,0 +1,79 @@
1
+ """Domain: the shared currency of both ports and the comparability rule (ADR-0002, ADR-0004).
2
+
3
+ Imports nothing outward — no ggufbench, buildwright, mcp or filesystem here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+
11
+ from debrief.errors import INCOMPARABLE_ROWS, DebriefError
12
+
13
+ PASS = "pass"
14
+ FAIL = "fail"
15
+ OUTCOMES = frozenset({PASS, FAIL})
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class TaskSignature:
20
+ """Graded counts per dimension plus the baseline and engine build (ADR-0004).
21
+
22
+ ggufbench derives it from the data because version labels have lied before;
23
+ two rows compare only inside one signature.
24
+ """
25
+
26
+ graded_counts: tuple[tuple[str, int], ...]
27
+ baseline: str
28
+ engine_build: str
29
+
30
+ @classmethod
31
+ def of(cls, graded: Mapping[str, int], baseline: str, engine_build: str) -> TaskSignature:
32
+ return cls(tuple(sorted(graded.items())), baseline, engine_build)
33
+
34
+ @property
35
+ def key(self) -> str:
36
+ graded = ",".join(f"{dimension}:{count}" for dimension, count in self.graded_counts)
37
+ return f"{graded}@{self.baseline}@{self.engine_build}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Finding:
42
+ """The shared currency: model, task, outcome, reason, evidence pointer (ADR-0002).
43
+
44
+ `signature`, `measure` and `role` carry what one port knows and the other
45
+ does not: bench rows carry a signature and a score, run steps carry the
46
+ buildwright role. Neither port fills the other's fields.
47
+ """
48
+
49
+ model: str
50
+ task: str
51
+ outcome: str
52
+ reason: str
53
+ evidence: str
54
+ signature: TaskSignature | None = None
55
+ measure: float | None = None
56
+ role: str | None = None
57
+
58
+
59
+ def shared_signature(rows: Sequence[Finding]) -> TaskSignature | DebriefError:
60
+ """The one task signature covering every row, or a refusal (ADR-0004).
61
+
62
+ Rows that do not share a signature answer different questions, so the
63
+ caller gets `incomparable_rows` with the groups named — never an average.
64
+ """
65
+ signatures = {row.signature for row in rows if row.signature is not None}
66
+ if len(signatures) > 1:
67
+ listed = ", ".join(sorted(signature.key for signature in signatures))
68
+ return DebriefError(
69
+ code=INCOMPARABLE_ROWS,
70
+ message="rows span more than one task signature; refusing to rank or average",
71
+ detail=f"{len(signatures)} signatures: {listed}",
72
+ )
73
+ if not signatures:
74
+ return DebriefError(
75
+ code=INCOMPARABLE_ROWS,
76
+ message="no row records a task signature, so comparability cannot be established",
77
+ detail=f"{len(rows)} row(s): " + ", ".join(f"{row.model}/{row.task}" for row in rows),
78
+ )
79
+ return signatures.pop()
@@ -0,0 +1,24 @@
1
+ """Structured errors: every debrief answer is findings or one of these (R1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ BENCH_SOURCE_MISSING = "bench_source_missing"
8
+ RUN_SOURCE_MISSING = "run_source_missing"
9
+ SOURCE_UNPARSEABLE = "source_unparseable"
10
+ INCOMPARABLE_ROWS = "incomparable_rows"
11
+ PATH_OUTSIDE_ROOT = "path_outside_root"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DebriefError:
16
+ """A refusal a tool returns instead of findings.
17
+
18
+ Returned, never raised, so the transport adapter can always render a typed
19
+ response; `code` is the stable contract an agent branches on.
20
+ """
21
+
22
+ code: str
23
+ message: str
24
+ detail: str
@@ -0,0 +1,218 @@
1
+ """MCP stdio adapter: three tools that answer from receipts and never write (R1, R2).
2
+
3
+ Tool names carry the port (bench.*, run.*, routing.propose) so an agent cannot
4
+ confuse what was measured with what happened in one run (ADR-0002).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.metadata
10
+ import logging
11
+ import os
12
+ import signal
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from mcp.server.mcpserver import MCPServer
18
+
19
+ from debrief.adapters.buildwright_log import BuildwrightLog
20
+ from debrief.adapters.ggufbench_store import GgufbenchStore
21
+ from debrief.domain import Finding
22
+ from debrief.errors import PATH_OUTSIDE_ROOT, DebriefError
23
+ from debrief.use_cases import InspectRun, ProposeRole, QueryBench
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ BENCH_ROOT_ENV = "DEBRIEF_BENCH_ROOT"
28
+ RUN_ROOT_ENV = "DEBRIEF_RUN_ROOT"
29
+ DEFAULT_BENCH_ROOT = "artifacts/ggufbench"
30
+ DEFAULT_RUN_ROOT = "artifacts/buildwright"
31
+
32
+ _HELP = f"""\
33
+ debrief-mcp {importlib.metadata.version("debrief-mcp")} — answer agent questions from receipts
34
+ MCP stdio server, read-only: every answer is findings or a structured error; never writes.
35
+
36
+ usage: debrief [-h | --help] [--version]
37
+
38
+ tools (MCP stdio, JSON-RPC over stdin/stdout; logs go to stderr):
39
+
40
+ bench.query What did ggufbench measure? Findings from ggufbench artifacts.
41
+ arg: path — run directory (or the artifact root itself) relative
42
+ to the bench root.
43
+ run.inspect Where did a buildwright run break? Findings from one run log.
44
+ arg: path — the log file relative to the run root.
45
+ routing.propose A BW_ROLE_* proposal as text citing both ports' evidence.
46
+ args: bench_path (ggufbench artifacts dir), run_path (run log).
47
+ Text only; never applied (ADR-0001), targets roles, never
48
+ models.yaml (ADR-0003); incomparable rows are refused, never
49
+ averaged (ADR-0004).
50
+
51
+ environment:
52
+
53
+ DEBRIEF_BENCH_ROOT allowed root for ggufbench artifacts
54
+ (default: {DEFAULT_BENCH_ROOT})
55
+ DEBRIEF_RUN_ROOT allowed root for buildwright run logs
56
+ (default: {DEFAULT_RUN_ROOT})
57
+ DEBRIEF_LOG_LEVEL stderr logging level (default: INFO)
58
+
59
+ options:
60
+
61
+ -h, --help show this help and exit
62
+ --version show the installed debrief version and exit
63
+
64
+ exit codes:
65
+
66
+ 0 normal (help/version, or the client closed the session)
67
+ 2 unknown argument
68
+ 130 interrupted (SIGINT)
69
+ """
70
+
71
+ server = MCPServer(
72
+ "debrief",
73
+ instructions=(
74
+ "debrief answers agent questions from receipts: bench.query (ggufbench artifacts), "
75
+ "run.inspect (one buildwright run log), routing.propose (a BW_ROLE_* diff as text). "
76
+ "It never writes; every answer is findings or a structured error."
77
+ ),
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class FindingsResponse:
83
+ """A bench.* / run.* answer: findings, or a structured error and no findings (R1)."""
84
+
85
+ findings: list[Finding] = field(default_factory=list)
86
+ error: DebriefError | None = None
87
+
88
+ @classmethod
89
+ def of(cls, answer: list[Finding] | DebriefError) -> FindingsResponse:
90
+ if isinstance(answer, DebriefError):
91
+ return cls(error=answer)
92
+ return cls(findings=list(answer))
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ProposalResponse:
97
+ """The routing.propose answer: a BW_ROLE_* diff as text, or a structured error (R1, R5)."""
98
+
99
+ proposal: str = ""
100
+ error: DebriefError | None = None
101
+
102
+ @classmethod
103
+ def of(cls, answer: str | DebriefError) -> ProposalResponse:
104
+ if isinstance(answer, DebriefError):
105
+ return cls(error=answer)
106
+ return cls(proposal=answer)
107
+
108
+
109
+ @server.tool(
110
+ name="bench.query",
111
+ title="What did ggufbench measure?",
112
+ description=(
113
+ "Findings from ggufbench artifacts. `path` is a run directory (or the artifact root "
114
+ "itself) relative to DEBRIEF_BENCH_ROOT. Read-only."
115
+ ),
116
+ )
117
+ def bench_query(path: str) -> FindingsResponse:
118
+ resolved = _resolve_within(BENCH_ROOT_ENV, DEFAULT_BENCH_ROOT, path)
119
+ if isinstance(resolved, DebriefError):
120
+ return FindingsResponse(error=resolved)
121
+ return FindingsResponse.of(QueryBench(GgufbenchStore(resolved)).run())
122
+
123
+
124
+ @server.tool(
125
+ name="run.inspect",
126
+ title="Where did this run break?",
127
+ description=(
128
+ "Findings from one buildwright run log. `path` is the log file relative to "
129
+ "DEBRIEF_RUN_ROOT. Read-only."
130
+ ),
131
+ )
132
+ def run_inspect(path: str) -> FindingsResponse:
133
+ resolved = _resolve_within(RUN_ROOT_ENV, DEFAULT_RUN_ROOT, path)
134
+ if isinstance(resolved, DebriefError):
135
+ return FindingsResponse(error=resolved)
136
+ return FindingsResponse.of(InspectRun(BuildwrightLog(resolved)).run())
137
+
138
+
139
+ @server.tool(
140
+ name="routing.propose",
141
+ title="What would you change?",
142
+ description=(
143
+ "A BW_ROLE_* proposal as text, citing bench.query and run.inspect evidence. "
144
+ "`bench_path` is a ggufbench artifact directory under DEBRIEF_BENCH_ROOT, `run_path` "
145
+ "a buildwright log under DEBRIEF_RUN_ROOT. Returns text; never writes."
146
+ ),
147
+ )
148
+ def routing_propose(bench_path: str, run_path: str) -> ProposalResponse:
149
+ bench_dir = _resolve_within(BENCH_ROOT_ENV, DEFAULT_BENCH_ROOT, bench_path)
150
+ if isinstance(bench_dir, DebriefError):
151
+ return ProposalResponse.of(bench_dir)
152
+ run_file = _resolve_within(RUN_ROOT_ENV, DEFAULT_RUN_ROOT, run_path)
153
+ if isinstance(run_file, DebriefError):
154
+ return ProposalResponse.of(run_file)
155
+ propose = ProposeRole(
156
+ QueryBench(GgufbenchStore(bench_dir)), InspectRun(BuildwrightLog(run_file))
157
+ )
158
+ return ProposalResponse.of(propose.run())
159
+
160
+
161
+ def _resolve_within(root_env: str, default_root: str, requested: str) -> Path | DebriefError:
162
+ """Resolve `requested` against the allowed root; refuse anything that escapes it (A03)."""
163
+ root = Path(os.environ.get(root_env, default_root)).resolve()
164
+ try:
165
+ candidate = (root / requested).resolve() if requested else root
166
+ except (ValueError, OSError) as err:
167
+ # ValueError: embedded NUL bytes survive Path construction and only
168
+ # explode when os calls lstat under resolve(); refuse, never crash.
169
+ return DebriefError(
170
+ code=PATH_OUTSIDE_ROOT,
171
+ message="requested path could not be resolved within the allowed evidence root",
172
+ detail=f"{requested!r}: {err}",
173
+ )
174
+ if not candidate.is_relative_to(root):
175
+ return DebriefError(
176
+ code=PATH_OUTSIDE_ROOT,
177
+ message="requested path is outside the allowed evidence root",
178
+ detail=f"{requested!r} resolves outside {root}",
179
+ )
180
+ return candidate
181
+
182
+
183
+ def _on_sigint(signum: int, frame: object) -> None:
184
+ # Ctrl+C on a stdio server is a normal shutdown, not a crash. Exit
185
+ # immediately with the conventional SIGINT code: os._exit skips the
186
+ # interpreter shutdown, where a still-running SDK thread would otherwise
187
+ # turn a second Ctrl+C into an "Exception ignored" traceback.
188
+ log.info("debrief MCP server stopping (SIGINT)")
189
+ sys.stderr.flush()
190
+ os._exit(130)
191
+
192
+
193
+ def main() -> None:
194
+ args = sys.argv[1:]
195
+ if "-h" in args or "--help" in args:
196
+ print(_HELP, end="")
197
+ return
198
+ if "--version" in args:
199
+ print(f"debrief-mcp {importlib.metadata.version('debrief-mcp')}")
200
+ return
201
+ if args:
202
+ # The server takes no positional input: anything else is a typo that
203
+ # should fail fast instead of silently starting a stdio session that
204
+ # would then hang waiting for JSON-RPC on the terminal.
205
+ print(
206
+ f"debrief: unrecognized argument(s): {' '.join(args)}\ntry 'debrief --help'",
207
+ file=sys.stderr,
208
+ )
209
+ raise SystemExit(2)
210
+
211
+ logging.basicConfig(
212
+ level=os.environ.get("DEBRIEF_LOG_LEVEL", "INFO"),
213
+ stream=sys.stderr,
214
+ format="%(levelname)s %(name)s %(message)s",
215
+ )
216
+ log.info("debrief MCP server starting on stdio (read-only)")
217
+ signal.signal(signal.SIGINT, _on_sigint)
218
+ server.run()
@@ -0,0 +1,20 @@
1
+ """Ports the domain drives: two independent evidence sources (ADR-0002)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from debrief.domain import Finding
8
+ from debrief.errors import DebriefError
9
+
10
+
11
+ class BenchStore(Protocol):
12
+ """What ggufbench measured (R3): the only bench source debrief reads."""
13
+
14
+ def findings(self) -> list[Finding] | DebriefError: ...
15
+
16
+
17
+ class RunLog(Protocol):
18
+ """One buildwright run (R4): the only run source debrief reads."""
19
+
20
+ def findings(self) -> list[Finding] | DebriefError: ...
@@ -0,0 +1,131 @@
1
+ """Driving use cases: one per agent question (QueryBench S1, InspectRun S2, ProposeRole S3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from debrief.domain import FAIL, PASS, Finding, shared_signature
8
+ from debrief.errors import DebriefError
9
+ from debrief.ports import BenchStore, RunLog
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class QueryBench:
15
+ """ "What did ggufbench measure?" — answered only from a BenchStore (R3)."""
16
+
17
+ def __init__(self, store: BenchStore) -> None:
18
+ self._store = store
19
+
20
+ def run(self) -> list[Finding] | DebriefError:
21
+ answer = self._store.findings()
22
+ if isinstance(answer, DebriefError):
23
+ log.warning("bench query refused: %s", answer.code)
24
+ return answer
25
+ log.info("bench query answered from receipts: %d findings", len(answer))
26
+ return answer
27
+
28
+
29
+ class InspectRun:
30
+ """ "Where did this run break?" — answered only from a RunLog (R4)."""
31
+
32
+ def __init__(self, run_log: RunLog) -> None:
33
+ self._run_log = run_log
34
+
35
+ def run(self) -> list[Finding] | DebriefError:
36
+ answer = self._run_log.findings()
37
+ if isinstance(answer, DebriefError):
38
+ log.warning("run inspection refused: %s", answer.code)
39
+ return answer
40
+ log.info("run inspection answered from receipts: %d findings", len(answer))
41
+ return answer
42
+
43
+
44
+ class ProposeRole:
45
+ """ "What would you change?" — a BW_ROLE_* diff as text (R5, ADR-0003).
46
+
47
+ The only use case allowed to read findings from both ports (ADR-0002);
48
+ it renders text and writes nothing (ADR-0001).
49
+ """
50
+
51
+ def __init__(self, bench: QueryBench, run: InspectRun) -> None:
52
+ self._bench = bench
53
+ self._run = run
54
+
55
+ def run(self) -> str | DebriefError:
56
+ bench_answer = self._bench.run()
57
+ if isinstance(bench_answer, DebriefError):
58
+ return bench_answer
59
+ run_answer = self._run.run()
60
+ if isinstance(run_answer, DebriefError):
61
+ return run_answer
62
+ return _propose(bench_answer, run_answer)
63
+
64
+
65
+ def _propose(bench_rows: list[Finding], run_rows: list[Finding]) -> str | DebriefError:
66
+ failures = [row for row in run_rows if row.outcome == FAIL]
67
+ if not failures:
68
+ return "no proposal: every logged step passed"
69
+ lines: list[str] = []
70
+ proposed_roles: set[str] = set()
71
+ for failure in failures:
72
+ role = failure.role
73
+ if role is None:
74
+ lines.append(
75
+ f"note: step on {failure.task} recorded no role, nothing to propose"
76
+ f" ({failure.evidence})"
77
+ )
78
+ continue
79
+ canonical = _canonical_role(role)
80
+ if canonical in proposed_roles:
81
+ continue
82
+ proposal = _role_proposal(canonical, failure, bench_rows)
83
+ if isinstance(proposal, DebriefError):
84
+ return proposal
85
+ lines.extend(proposal)
86
+ proposed_roles.add(canonical)
87
+ return "\n".join(lines)
88
+
89
+
90
+ def _canonical_role(role: str) -> str:
91
+ """One env name per role: step lines record the env-var name as often as the
92
+ bare role, and a token that already carries the canonical prefix would
93
+ otherwise render a `BW_ROLE_BW_ROLE_*` diff nobody can paste into the
94
+ buildwright env."""
95
+ return role.upper().removeprefix("BW_ROLE_")
96
+
97
+
98
+ def _role_proposal(
99
+ role: str, failure: Finding, bench_rows: list[Finding]
100
+ ) -> list[str] | DebriefError:
101
+ role_env = f"BW_ROLE_{role.upper()}"
102
+ cohort = [row for row in bench_rows if row.task == failure.task]
103
+ if not cohort:
104
+ return [
105
+ f"note: ggufbench never measured {failure.task}, keeping {role_env}={failure.model}"
106
+ ]
107
+ signature = shared_signature(cohort)
108
+ if isinstance(signature, DebriefError):
109
+ return signature
110
+ candidates = [
111
+ row
112
+ for row in cohort
113
+ if row.model != failure.model and row.outcome == PASS and row.measure is not None
114
+ ]
115
+ if not candidates:
116
+ return [
117
+ f"note: no passing model for {failure.task} in the bench cohort,"
118
+ f" keeping {role_env}={failure.model}"
119
+ ]
120
+ winner = sorted(candidates, key=lambda row: (-(row.measure or 0.0), row.model))[0]
121
+ return [
122
+ f"proposal: {role_env} holds {failure.model}, which failed task {failure.task}",
123
+ f"- {role_env}={failure.model}",
124
+ f"+ {role_env}={winner.model}",
125
+ f"why run: {failure.model} failed {failure.task} ({failure.reason}) — {failure.evidence}",
126
+ f"why bench: {winner.model} passed {failure.task} at score {winner.measure:.2f}"
127
+ f" ({winner.reason}) — {winner.evidence}",
128
+ f"signature: {signature.key}",
129
+ "not applied: debrief never writes; paste the + line into the buildwright role env"
130
+ " (ADR-0001). Proposals target roles, never models.yaml (ADR-0003).",
131
+ ]