From dffa231edaf6036ef5dfda6b15327f4c86e37999 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:17:52 +0100 Subject: [PATCH 01/21] test: TDD_EXECUTOR_MODEL yields source declared TDD-Run: 15 TDD-Cycle: 1 TDD-Phase: red --- tests/test_executor_attribution.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_executor_attribution.py diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py new file mode 100644 index 0000000..792d369 --- /dev/null +++ b/tests/test_executor_attribution.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tddcli import identity + + +def test_env_override_resolves_as_declared(tmp_path, monkeypatch): + monkeypatch.setenv("TDD_EXECUTOR_MODEL", "harness-model") + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "nowhere") + + e = identity.resolve(None) + assert e.model == "harness-model" + assert e.source == "declared" From 63a3fa815b4977a87923f41f953a0f34d5da4451 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:19:59 +0100 Subject: [PATCH 02/21] feat: harness-declared executor identity via TDD_EXECUTOR_MODEL TDD-Run: 15 TDD-Cycle: 1 TDD-Phase: green --- src/tddcli/identity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tddcli/identity.py b/src/tddcli/identity.py index 671f326..e0cd60a 100644 --- a/src/tddcli/identity.py +++ b/src/tddcli/identity.py @@ -76,6 +76,10 @@ def resolve(project_path: Path | None = None, human_label: str | None = None) -> if model: return Executor(model=model, session=session, source="transcript") + declared = os.environ.get("TDD_EXECUTOR_MODEL") + if declared: + return Executor(model=declared, session=session, source="declared") + if human_label: return Executor(model=human_label, session=session, source="human") From 6c606917c561b1a8c02a7a1a52b186825da33f3b Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:21:47 +0100 Subject: [PATCH 03/21] refactor: TDD_EXECUTOR_MODEL resolves with source declared TDD-Run: 15 TDD-Cycle: 1 TDD-Phase: refactor --- tests/test_executor_attribution.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 792d369..bef678a 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -1,8 +1,5 @@ from __future__ import annotations -import json -from pathlib import Path - from tddcli import identity From 325b820e42d262badbdee824d892b4233f5332f1 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:25:15 +0100 Subject: [PATCH 04/21] test: declared identity overrides transcript detection TDD-Run: 15 TDD-Cycle: 2 TDD-Phase: red --- tests/test_executor_attribution.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index bef678a..60d63d4 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -3,6 +3,9 @@ from tddcli import identity +import json + + def test_env_override_resolves_as_declared(tmp_path, monkeypatch): monkeypatch.setenv("TDD_EXECUTOR_MODEL", "harness-model") monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) @@ -11,3 +14,19 @@ def test_env_override_resolves_as_declared(tmp_path, monkeypatch): e = identity.resolve(None) assert e.model == "harness-model" assert e.source == "declared" + + +def test_declared_override_beats_transcript(tmp_path, monkeypatch): + slug = str(tmp_path / "proj").replace("/", "-") + transcripts = tmp_path / "projects" / slug + transcripts.mkdir(parents=True) + (transcripts / "sess-99.jsonl").write_text( + json.dumps({"type": "assistant", "model": "claude-transcript-model"}) + "\n" + ) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "projects") + monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "sess-99") + monkeypatch.setenv("TDD_EXECUTOR_MODEL", "harness-model") + + e = identity.resolve(tmp_path / "proj") + assert e.source == "declared" + assert e.model == "harness-model" From 1a2d64046c697c025dd38522a7383c7884f70afe Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:27:06 +0100 Subject: [PATCH 05/21] feat: declared executor identity takes precedence TDD-Run: 15 TDD-Cycle: 2 TDD-Phase: green --- src/tddcli/identity.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tddcli/identity.py b/src/tddcli/identity.py index e0cd60a..f925542 100644 --- a/src/tddcli/identity.py +++ b/src/tddcli/identity.py @@ -69,6 +69,11 @@ def _model_from_transcript(path: Path) -> str | None: def resolve(project_path: Path | None = None, human_label: str | None = None) -> Executor: session = os.environ.get("CLAUDE_CODE_SESSION_ID") + + declared = os.environ.get("TDD_EXECUTOR_MODEL") + if declared: + return Executor(model=declared, session=session, source="declared") + if session: transcript = _find_transcript(session, project_path) if transcript is not None: @@ -76,10 +81,6 @@ def resolve(project_path: Path | None = None, human_label: str | None = None) -> if model: return Executor(model=model, session=session, source="transcript") - declared = os.environ.get("TDD_EXECUTOR_MODEL") - if declared: - return Executor(model=declared, session=session, source="declared") - if human_label: return Executor(model=human_label, session=session, source="human") From 369b18d35a5b013c775af57a5fa03cf59bce466c Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:29:10 +0100 Subject: [PATCH 06/21] refactor: the declared override wins over a readable transcript TDD-Run: 15 TDD-Cycle: 2 TDD-Phase: refactor --- tests/test_executor_attribution.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 60d63d4..4f52248 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -1,10 +1,9 @@ from __future__ import annotations -from tddcli import identity - - import json +from tddcli import identity + def test_env_override_resolves_as_declared(tmp_path, monkeypatch): monkeypatch.setenv("TDD_EXECUTOR_MODEL", "harness-model") From 603318a7e926dd33b3ed5e808a8fcff98d076196 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:32:33 +0100 Subject: [PATCH 07/21] test: unknown executor carries the missing-env reason TDD-Run: 15 TDD-Cycle: 3 TDD-Phase: red --- tests/test_executor_attribution.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 4f52248..6f10e6c 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -29,3 +29,13 @@ def test_declared_override_beats_transcript(tmp_path, monkeypatch): e = identity.resolve(tmp_path / "proj") assert e.source == "declared" assert e.model == "harness-model" + + +def test_reason_names_the_missing_session_env(tmp_path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "nowhere") + + e = identity.resolve(None) + assert e.model == "unknown" + assert "CLAUDE_CODE_SESSION_ID" in e.reason From f648879aea700263870cbbb55fbe2b1db56ddd4c Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:34:28 +0100 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20Executor.reason=20=E2=80=94=20CLA?= =?UTF-8?q?UDE=5FCODE=5FSESSION=5FID=20not=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD-Run: 15 TDD-Cycle: 3 TDD-Phase: green --- src/tddcli/identity.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tddcli/identity.py b/src/tddcli/identity.py index f925542..9485f97 100644 --- a/src/tddcli/identity.py +++ b/src/tddcli/identity.py @@ -26,7 +26,8 @@ class Executor: model: str session: str | None - source: str # transcript | human | unknown + source: str # transcript | human | declared | unknown + reason: str | None = None def _slug(path: Path) -> str: @@ -84,4 +85,12 @@ def resolve(project_path: Path | None = None, human_label: str | None = None) -> if human_label: return Executor(model=human_label, session=session, source="human") + if not session: + return Executor( + model="unknown", + session=session, + source="unknown", + reason="CLAUDE_CODE_SESSION_ID is not set", + ) + return Executor(model="unknown", session=session, source="unknown") From cd3685c78ce6407d863136580c53b4b3ec664c64 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:37:52 +0100 Subject: [PATCH 09/21] test: unknown executor carries the no-transcript reason TDD-Run: 15 TDD-Cycle: 4 TDD-Phase: red --- tests/test_executor_attribution.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 6f10e6c..09758f9 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -39,3 +39,15 @@ def test_reason_names_the_missing_session_env(tmp_path, monkeypatch): e = identity.resolve(None) assert e.model == "unknown" assert "CLAUDE_CODE_SESSION_ID" in e.reason + + +def test_reason_names_the_missing_transcript(tmp_path, monkeypatch): + empty_root = tmp_path / "projects" + empty_root.mkdir() + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", empty_root) + monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "sess-gone") + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) + + e = identity.resolve(None) + assert e.model == "unknown" + assert e.reason and "sess-gone" in e.reason From 8e0a826010b20c1baaf762930c0604da0c0b05bd Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:39:47 +0100 Subject: [PATCH 10/21] feat: reason names the session whose transcript was not found TDD-Run: 15 TDD-Cycle: 4 TDD-Phase: green --- src/tddcli/identity.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/tddcli/identity.py b/src/tddcli/identity.py index 9485f97..5162890 100644 --- a/src/tddcli/identity.py +++ b/src/tddcli/identity.py @@ -75,9 +75,14 @@ def resolve(project_path: Path | None = None, human_label: str | None = None) -> if declared: return Executor(model=declared, session=session, source="declared") - if session: + reason: str | None = None + if not session: + reason = "CLAUDE_CODE_SESSION_ID is not set" + else: transcript = _find_transcript(session, project_path) - if transcript is not None: + if transcript is None: + reason = f"no transcript for session {session} under {TRANSCRIPT_ROOT}" + else: model = _model_from_transcript(transcript) if model: return Executor(model=model, session=session, source="transcript") @@ -85,12 +90,4 @@ def resolve(project_path: Path | None = None, human_label: str | None = None) -> if human_label: return Executor(model=human_label, session=session, source="human") - if not session: - return Executor( - model="unknown", - session=session, - source="unknown", - reason="CLAUDE_CODE_SESSION_ID is not set", - ) - - return Executor(model="unknown", session=session, source="unknown") + return Executor(model="unknown", session=session, source="unknown", reason=reason) From 6764fa31949cc331c5c6b54ce3da223e32d132f8 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:43:17 +0100 Subject: [PATCH 11/21] test: unknown executor carries the no-model-record reason TDD-Run: 15 TDD-Cycle: 5 TDD-Phase: red --- tests/test_executor_attribution.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 09758f9..97bfa51 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -51,3 +51,18 @@ def test_reason_names_the_missing_transcript(tmp_path, monkeypatch): e = identity.resolve(None) assert e.model == "unknown" assert e.reason and "sess-gone" in e.reason + + +def test_reason_names_the_model_less_transcript(tmp_path, monkeypatch): + root = tmp_path / "projects" + (root / "slug").mkdir(parents=True) + (root / "slug" / "sess-empty.jsonl").write_text( + json.dumps({"type": "user", "content": "hello"}) + "\n" + ) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", root) + monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "sess-empty") + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) + + e = identity.resolve(None) + assert e.model == "unknown" + assert e.reason and "no model" in e.reason.lower() From 34029594a376ea7d118abb25186abc5fdd36d5fa Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:45:05 +0100 Subject: [PATCH 12/21] feat: reason distinguishes a model-less transcript from a missing one TDD-Run: 15 TDD-Cycle: 5 TDD-Phase: green --- src/tddcli/identity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tddcli/identity.py b/src/tddcli/identity.py index 5162890..476cce8 100644 --- a/src/tddcli/identity.py +++ b/src/tddcli/identity.py @@ -86,6 +86,7 @@ def resolve(project_path: Path | None = None, human_label: str | None = None) -> model = _model_from_transcript(transcript) if model: return Executor(model=model, session=session, source="transcript") + reason = f"no model records in transcript {transcript}" if human_label: return Executor(model=human_label, session=session, source="human") From 726ea02f28fb6ebc993a17d0d5ff40d430b955d5 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:49:43 +0100 Subject: [PATCH 13/21] test: an unattributed run leaves an executor_unknown event TDD-Run: 15 TDD-Cycle: 6 TDD-Phase: red --- tests/test_executor_attribution.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 97bfa51..f573923 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -2,7 +2,22 @@ import json +from conftest import run_cli, write_plan from tddcli import identity +from tddcli.ledger import Ledger + +MINIMAL_PLAN = """\ +--- +cycles: + - n: 1 + project: backend + title: "placeholder" + test: "tests/test_smoke.py::test_smoke" + commit_red: "test: placeholder" + commit_green: "feat: placeholder" +--- +# Minimal plan for executor attribution tests +""" def test_env_override_resolves_as_declared(tmp_path, monkeypatch): @@ -66,3 +81,21 @@ def test_reason_names_the_model_less_transcript(tmp_path, monkeypatch): e = identity.resolve(None) assert e.model == "unknown" assert e.reason and "no model" in e.reason.lower() + + +def test_run_start_records_executor_unknown_event(repo, tmp_path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "empty-transcripts") + + plan = write_plan(repo, MINIMAL_PLAN) + run_cli(repo, "plan", "register", plan) + out = run_cli(repo, "run", "start", "--plan", plan) + assert out["ok"], out + + ledger = Ledger(repo) + rows = ledger.all( + "SELECT detail FROM integrity_event WHERE kind = 'executor_unknown'" + ) + assert len(rows) == 1 + assert "CLAUDE_CODE_SESSION_ID" in rows[0]["detail"] From daf7f7f97d4aa4587eb70b1b59a05d3e8c3c4279 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:51:31 +0100 Subject: [PATCH 14/21] feat: run start logs executor_unknown with the detection reason TDD-Run: 15 TDD-Cycle: 6 TDD-Phase: green --- src/tddcli/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tddcli/cli.py b/src/tddcli/cli.py index 2aea8d1..60b391c 100644 --- a/src/tddcli/cli.py +++ b/src/tddcli/cli.py @@ -778,6 +778,8 @@ def cmd_run_start(args) -> Envelope: ledger.event(run_id, None, "baseline_reused", json.dumps(sorted(reused))) if implausible: ledger.event(run_id, None, "baseline_accepted", json.dumps(implausible)) + if executor.source == "unknown": + ledger.event(run_id, None, "executor_unknown", executor.reason or "") # Baselines and the collection snapshot, per project (R9.5, R8.9) — from the # probe above, so the suite is not run twice. From 86541dc3d6f3e6a7ba54d13cc6d24a8a4dba7c0d Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:54:47 +0100 Subject: [PATCH 15/21] test: run start result warns when the executor is unknown TDD-Run: 15 TDD-Cycle: 7 TDD-Phase: red --- tests/test_executor_attribution.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index f573923..8ad8d6d 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -99,3 +99,17 @@ def test_run_start_records_executor_unknown_event(repo, tmp_path, monkeypatch): ) assert len(rows) == 1 assert "CLAUDE_CODE_SESSION_ID" in rows[0]["detail"] + + +def test_run_start_envelope_carries_executor_warning(repo, tmp_path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "empty-transcripts") + + plan = write_plan(repo, MINIMAL_PLAN) + run_cli(repo, "plan", "register", plan) + out = run_cli(repo, "run", "start", "--plan", plan) + assert out["ok"], out + + warning = out["result"].get("executor_warning") + assert warning and "CLAUDE_CODE_SESSION_ID" in warning From 2d33e0d0e1d03803dff7eab0262655031bc4cf39 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 08:56:30 +0100 Subject: [PATCH 16/21] feat: executor_warning in the run start envelope TDD-Run: 15 TDD-Cycle: 7 TDD-Phase: green --- src/tddcli/cli.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/tddcli/cli.py b/src/tddcli/cli.py index 60b391c..36b4032 100644 --- a/src/tddcli/cli.py +++ b/src/tddcli/cli.py @@ -832,12 +832,15 @@ def cmd_run_start(args) -> Envelope: verb, opening = engine.opening_action(cycle) detail = f"Run {run_id} started ({executor.model}, via {executor.source}). {opening}" + result: dict = { + "baselines": {n: len(v) for n, v in ledger.baselines(run_id).items()}, + "executor_source": executor.source, + } + if executor.source == "unknown": + result["executor_warning"] = executor.reason or "" return Envelope( run=engine.run_state(cycle), - result={ - "baselines": {n: len(v) for n, v in ledger.baselines(run_id).items()}, - "executor_source": executor.source, - }, + result=result, next_action=NextAction(verb, detail), ) finally: From 241591a2202bb283b1cb176ed6ae818e595f8a49 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 09:00:00 +0100 Subject: [PATCH 17/21] test: doctor names the executor-identity diagnosis TDD-Run: 15 TDD-Cycle: 8 TDD-Phase: red --- tests/test_executor_attribution.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_executor_attribution.py b/tests/test_executor_attribution.py index 8ad8d6d..3d3b823 100644 --- a/tests/test_executor_attribution.py +++ b/tests/test_executor_attribution.py @@ -113,3 +113,21 @@ def test_run_start_envelope_carries_executor_warning(repo, tmp_path, monkeypatch warning = out["result"].get("executor_warning") assert warning and "CLAUDE_CODE_SESSION_ID" in warning + + +def test_doctor_reports_executor_identity(repo, tmp_path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) + monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "empty-transcripts") + + out = run_cli(repo, "doctor") + assert out["result"]["healthy"] is True + + check = next( + (c for c in out["result"]["checks"] if c["check"] == "executor identity"), + None, + ) + assert check is not None, "executor identity check not found" + assert check["ok"] is True + assert "unknown" in check["detail"] + assert "CLAUDE_CODE_SESSION_ID" in check["detail"] From c658611f1b9f12dd1dbb3728cfab00074d97fff7 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 09:02:08 +0100 Subject: [PATCH 18/21] feat: informational executor identity check in doctor TDD-Run: 15 TDD-Cycle: 8 TDD-Phase: green --- src/tddcli/cli.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tddcli/cli.py b/src/tddcli/cli.py index 36b4032..46627d9 100644 --- a/src/tddcli/cli.py +++ b/src/tddcli/cli.py @@ -303,6 +303,12 @@ def cmd_doctor(args) -> Envelope: "ledger outside worktree", not str(ledger.path).startswith(str(worktree)), str(ledger.path) ) + ex = identity.resolve(worktree) + ex_detail = f"{ex.source}: {ex.model}" + if ex.source == "unknown": + ex_detail += f" — {ex.reason}" + check("executor identity", True, ex_detail) + projects: dict[str, dict] = {} for name, project in cfg.projects.items(): before = len(checks) From e9e8bcaccce54460d56dbabfc1db6a236195a3df Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 09:05:22 +0100 Subject: [PATCH 19/21] =?UTF-8?q?docs:=20document=20TDD=5FEXECUTOR=5FMODEL?= =?UTF-8?q?,=20Executor.reason,=20executor=5Funknown=20in=20=C2=A75.1=20an?= =?UTF-8?q?d=20harness-integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PRD.md | 26 ++++++++++++++++++++------ docs/harness-integration.md | 25 ++++++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 3db41b8..d0a92cc 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -109,16 +109,30 @@ Many runs may reference one contract. This is what makes A/B comparison across m The harness exposes a session identifier but **not** the model. Resolution order: -1. `CLAUDE_CODE_SESSION_ID` from the environment → locate +1. `TDD_EXECUTOR_MODEL` environment variable — set by the launching harness when it knows + the answer (e.g. a subagent harness that inherits its parent's `CLAUDE_CODE_SESSION_ID` + and would be mis-attributed). Recorded with `source: declared`. Wins over transcript. +2. `CLAUDE_CODE_SESSION_ID` from the environment → locate `~/.claude/projects//.jsonl` → read the `model` field. -2. Failing that, a `--executor` label supplied by a **human** at `run start`. -3. Failing that, `unknown`, and the run is excluded from model-comparison metrics. + Recorded with `source: transcript`. +3. Failing that, a `--executor` label supplied by a **human** at `run start`. + Recorded with `source: human`. +4. Failing that, `unknown` (`source: unknown`), and the run is excluded from model-comparison + metrics. `Executor.reason` records why: `CLAUDE_CODE_SESSION_ID` not set; no transcript + found for the session; the transcript contains no model records. + +When resolution yields `unknown`, `run start` emits an `executor_unknown` integrity event +(detail = the reason) and includes `executor_warning` in the success envelope so the gap is +visible at the moment it can still be fixed. + +`tdd doctor` reports an informational `executor identity` check (always `ok: true`) showing +`: `, plus the reason when the source is `unknown`. - **R5.1** The transcript lookup is isolated behind a single resolver so an undocumented format change breaks one function, not the tool. -- **R5.2** Agents never supply executor identity by any path. Step 2 is a human affordance. +- **R5.2** Agents never supply executor identity by any path. Step 3 is a human affordance. - **R5.3** Resolution requires the tool to run on the same host as the agent. Remote or CI - execution falls through to step 2. + execution can set `TDD_EXECUTOR_MODEL` to declare the answer explicitly. ### Cycle | Field | Notes | @@ -161,7 +175,7 @@ Reserved per-run keys: `plan_quality_score` (per plan phase, with rationale), `c ### IntegrityEvent Typed: `test_removed`, `test_weakened`, `undeclared_file_touched`, `restore_mismatch`, -`off_protocol_invocation`, `stale_artifact`, `plan_blob_changed`. +`off_protocol_invocation`, `stale_artifact`, `plan_blob_changed`, `executor_unknown`. ### Blocker Typed: `regression`, `target_unfixable`, `bad_red`, `plan_defect`, `tooling`, `context_exhausted`, diff --git a/docs/harness-integration.md b/docs/harness-integration.md index e4ec0f4..a63d279 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -104,16 +104,27 @@ and auditors should read the two accordingly. ## Executor identity and subagents -`tdd run start` records which model is executing by reading the harness session id -(`CLAUDE_CODE_SESSION_ID`) and resolving the model from that session's transcript. -This is trustworthy only when the executor is a **top-level session** — its own -terminal, worktree, or cloud session. +`tdd run start` records which model is executing. Resolution order (first match wins): + +1. **`TDD_EXECUTOR_MODEL`** — set this env var when the launching harness knows the answer + (e.g. a CI pipeline or a subagent harness). Recorded with `source: declared`. Takes + precedence over transcript detection. +2. **Transcript** — reads `CLAUDE_CODE_SESSION_ID` and resolves the model from + `~/.claude/projects//.jsonl`. Recorded with `source: transcript`. + Trustworthy only for top-level sessions (see below). +3. **`--executor`** — human-supplied label at `run start`. Recorded with `source: human`. +4. **`unknown`** — `Executor.reason` records why: env var not set; no transcript for the + session; transcript contains no model records. An `executor_unknown` integrity event is + emitted and `result.executor_warning` is set in the `run start` envelope. + +`tdd doctor` always reports an informational `executor identity` check (`ok: true`) showing +the resolved `: `, plus the reason when the source is `unknown`. An in-process subagent (Claude Code's Agent/Task tool) inherits the parent's session id and has no transcript of its own in the location the resolver reads, so a run -started by a subagent is attributed to the **parent's** model. The run itself is -unaffected — but if you are comparing models across runs, dispatch executors as -separate top-level sessions, not as subagents, or the comparison is silently wrong. +started by a subagent would be attributed to the **parent's** model. Set +`TDD_EXECUTOR_MODEL` in the subagent's environment to declare the correct model +explicitly — the declared path was added precisely to fix this case. ## Concurrent-command refusals From 7274cf405a180fd0f83fff2ccb3e2d43a2635f9f Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 09:05:25 +0100 Subject: [PATCH 20/21] docs: friction log for issue-74-executor-attribution --- .../issue-74-executor-attribution-friction.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tasks/friction-logs/issue-74-executor-attribution-friction.md diff --git a/tasks/friction-logs/issue-74-executor-attribution-friction.md b/tasks/friction-logs/issue-74-executor-attribution-friction.md new file mode 100644 index 0000000..6006fd1 --- /dev/null +++ b/tasks/friction-logs/issue-74-executor-attribution-friction.md @@ -0,0 +1,89 @@ +# Implementation Friction Log: tasks/issue-74-executor-attribution.md + +- Run: 15 +- Executor: claude-sonnet-4-6 (source: transcript) +- Plan blob: `a66891f7adb8b6ea80f680ac92727388eff9ed8a` (declared) +- Started: 2026-08-29T07:15:49.931037+00:00 Ended: 2026-08-29T08:03:42.177931+00:00 Outcome: complete +- Baseline failures at start: tddcli=0 + +## Plan fidelity + +- Declared cycles: 8 +- Delivered: 8 Skipped: 0 +- Never reached: none +- Human interventions: 0 + +### Cycle 8: doctor reports executor identity and the failure reason informationally _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_doctor_reports_executor_identity` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `241591a22` [red] test: doctor names the executor-identity diagnosis (1 files) + - `c658611f1` [green] feat: informational executor identity check in doctor (1 files) + +### Cycle 7: the run start envelope surfaces the attribution warning _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_run_start_envelope_carries_executor_warning` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `86541dc3d` [red] test: run start result warns when the executor is unknown (1 files) + - `2d33e0d0e` [green] feat: executor_warning in the run start envelope (1 files) + +### Cycle 6: run start records an executor_unknown event with the reason _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_run_start_records_executor_unknown_event` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `726ea02f2` [red] test: an unattributed run leaves an executor_unknown event (1 files) + - `daf7f7f97` [green] feat: run start logs executor_unknown with the detection reason (1 files) + +### Cycle 5: resolve records why detection failed: transcript has no model line _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_reason_names_the_model_less_transcript` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `6764fa319` [red] test: unknown executor carries the no-model-record reason (1 files) + - `34029594a` [green] feat: reason distinguishes a model-less transcript from a missing one (1 files) + +### Cycle 4: resolve records why detection failed: transcript not found _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_reason_names_the_missing_transcript` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `cd3685c78` [red] test: unknown executor carries the no-transcript reason (1 files) + - `8e0a82601` [green] feat: reason names the session whose transcript was not found (1 files) + +### Cycle 3: resolve records why detection failed: session env missing _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_reason_names_the_missing_session_env` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `603318a7e` [red] test: unknown executor carries the missing-env reason (1 files) + - `f648879ae` [green] feat: Executor.reason — CLAUDE_CODE_SESSION_ID not set (1 files) + +### Cycle 2: the declared override wins over a readable transcript _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_declared_override_beats_transcript` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2} +- **First run outcome:** failed (as expected) +- **Commits:** + - `325b820e4` [red] test: declared identity overrides transcript detection (1 files) + - `1a2d64046` [green] feat: declared executor identity takes precedence (1 files) + - `369b18d35` [refactor] refactor: the declared override wins over a readable transcript (1 files) + +### Cycle 1: TDD_EXECUTOR_MODEL resolves with source declared _(standard)_ +- **Target:** `tddcli::tests/test_executor_attribution.py::test_env_override_resolves_as_declared` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2} +- **First run outcome:** failed (as expected) +- **Commits:** + - `dffa231ed` [red] test: TDD_EXECUTOR_MODEL yields source declared (1 files) + - `63a3fa815` [green] feat: harness-declared executor identity via TDD_EXECUTOR_MODEL (1 files) + - `6c606917c` [refactor] refactor: TDD_EXECUTOR_MODEL resolves with source declared (1 files) + From 8c107f18879ed61bdf8d2dad94756fdd8e88d916 Mon Sep 17 00:00:00 2001 From: geuben Date: Sat, 29 Aug 2026 09:49:10 +0100 Subject: [PATCH 21/21] test: pin a declared executor identity so the suite runs the same in CI --- tests/conftest.py | 9 +++++++++ tests/test_snapshot_and_identity.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 6864f9c..3ebc2d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,15 @@ def _isolated_lease_dir(tmp_path, monkeypatch): monkeypatch.setenv("TDD_LEASE_DIR", str(tmp_path / "worker-leases")) +@pytest.fixture(autouse=True) +def _pinned_executor_identity(monkeypatch): + """A developer's shell resolves identity from its live Claude session; CI + resolves nothing and every run logs executor_unknown. Pin a declared + identity so both behave the same; the attribution tests delenv this to + exercise the unknown paths.""" + monkeypatch.setenv("TDD_EXECUTOR_MODEL", "pytest-executor") + + @pytest.fixture def ledger_home(tmp_path, monkeypatch): home = tmp_path / "ledger-home" diff --git a/tests/test_snapshot_and_identity.py b/tests/test_snapshot_and_identity.py index abc4a03..6c66327 100644 --- a/tests/test_snapshot_and_identity.py +++ b/tests/test_snapshot_and_identity.py @@ -81,6 +81,7 @@ def test_model_is_read_from_the_session_transcript(tmp_path, monkeypatch): identity, "TRANSCRIPT_ROOT", tmp_path / "home" / ".claude" / "projects" ) monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "sess-1") + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) executor = identity.resolve(project) assert executor.model == "claude-sonnet-4-6" @@ -90,6 +91,7 @@ def test_model_is_read_from_the_session_transcript(tmp_path, monkeypatch): def test_human_label_is_the_fallback_not_the_default(tmp_path, monkeypatch): monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", tmp_path / "nowhere") monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) assert identity.resolve(None, "opus-by-hand").source == "human" assert identity.resolve(None).model == "unknown" @@ -104,6 +106,7 @@ def test_last_model_wins_when_a_session_switches(tmp_path, monkeypatch): ) monkeypatch.setattr(identity, "TRANSCRIPT_ROOT", root) monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "s") + monkeypatch.delenv("TDD_EXECUTOR_MODEL", raising=False) assert identity.resolve(None).model == "claude-sonnet-4-6"