Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e240122
test: verdict outcome lookup admits it cannot judge an unexecuted id
geuben Aug 29, 2026
e60c9e5
feat: _outcome_from_verdicts helper (None when the id never ran)
geuben Aug 29, 2026
6d97ea0
refactor: outcome lookup returns None for an id absent from every ver…
geuben Aug 29, 2026
e6de782
test: adoption of the one new failing test reaches RED without a re-run
geuben Aug 29, 2026
14aea52
feat: evaluate the adopted target from the suite run that already hap…
geuben Aug 29, 2026
7eb1bfe
refactor: a single adopted test that failed is evaluated as RED in th…
geuben Aug 29, 2026
8df5166
refactor: a single adopted test that passed drives sensitivity in the…
geuben Aug 29, 2026
f9813f2
test: _disambiguate resolves a vitest separator-only mismatch
geuben Aug 29, 2026
add2525
feat: _disambiguate — unique normalise-equal candidate wins
geuben Aug 29, 2026
e3c694f
refactor: disambiguation picks the candidate that normalise-matches t…
geuben Aug 29, 2026
537bd6e
test: same-file disambiguation adopts and evaluates without asking
geuben Aug 29, 2026
88cb4d3
feat: wire _disambiguate into the multiple-new-tests branch (same-fil…
geuben Aug 29, 2026
d4c0d1e
test: pin that two same-file candidates still demand tdd target
geuben Aug 29, 2026
371f0c2
docs: update R8.9 adoption text and event descriptions for same-advan…
geuben Aug 29, 2026
e25ac86
docs: friction log for issue-72-adopt-on-first-run
geuben Aug 29, 2026
3a7f73f
Merge branch 'main' into feat/72-adopt-on-first-run
geuben Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,10 +477,18 @@ one has no move left but to re-run doctor and read the same output again.
- **R8.8** `human_intervention` events are the input to interventions-per-run, the primary
autonomy metric.
- **R8.9** In `AWAITING_TEST`, `advance` resolves the target by diffing `collect()` against cycle
open. If exactly one new test appeared and it fails, it is adopted as the target and
`declared_test_mismatch` is recorded against the contract. If several appeared, that is the
one-behaviour-per-cycle violation: it is recorded, and `next_action` requires the agent to name
the intended target rather than guessing.
open. When the declared target is `not_found`, the adoption flow runs:
- **Single new test:** adopted as the target; `declared_test_mismatch` is recorded. The verdict
from the run that already happened is evaluated immediately in the same `advance` call — no
extra suite run. If the adopted test failed, the RED commit is made and the cycle moves to
`AWAITING_IMPL`; if it passed, sensitivity is demanded.
- **Multiple new tests — unambiguous:** if exactly one candidate normalise-matches the declared id
(R10.5), or exactly one lives in the declared target's file, it is adopted and evaluated as
above; `declared_test_mismatch` is recorded with the full candidate list.
- **Multiple new tests — ambiguous:** recorded as `multiple_new_tests`; `next_action` requires
the agent to name the intended target with `tdd target <id>` rather than guessing.
The run that produced `not_found` already executed the new test; its verdict is retrieved from
the in-hand `Verdict` objects without an extra suite run.

### 8.4 Sensitivity checks

Expand Down
96 changes: 74 additions & 22 deletions src/tddcli/advance.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ def _stage_and_commit(engine: Engine, cycle, phase: str, declared) -> tuple[str
return sha, staged, classification


def _disambiguate(candidates: list[str], declared: str, adapter) -> str | None:
norm_declared = adapter.normalise_id(declared)
matches = [c for c in candidates if adapter.normalise_id(c) == norm_declared]
if len(matches) == 1:
return matches[0]
declared_file = declared.split("::", 1)[-1].split("::")[0].split(" > ")[0]
same_file = [c for c in candidates if c.split("::", 1)[-1].split("::")[0].split(" > ")[0] == declared_file]
if len(same_file) == 1:
return same_file[0]
return None


def _outcome_from_verdicts(verdicts, test_id: str) -> str | None:
for v in verdicts:
if test_id in v.failed:
return FAILED
if test_id in v.passed:
return PASSED
return None


# -- handlers ------------------------------------------------------------


Expand All @@ -127,7 +148,7 @@ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool)
targets = json.loads(cycle["target_tests"])
phase = cycle["phase"]

outcomes, others, _, failure = engine.run_projects(
outcomes, others, verdicts, failure = engine.run_projects(
projects, targets, cycle, phase, retried
)

Expand All @@ -144,29 +165,60 @@ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool)
"cycle", cycle["id"], target_tests=json.dumps(kept + candidates)
)
cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
adopted_outcome = _outcome_from_verdicts(verdicts, candidates[0])
if adopted_outcome is None:
return _reply(
engine, cycle, Verb.REFACTOR_OR_ADVANCE,
f"Adopted {candidates[0]} as the target (declared {missing[0]} was not"
" collected). Run `tdd advance` again to evaluate it.",
adopted=candidates,
)
targets = kept + candidates
outcomes = {candidates[0]: adopted_outcome}
others = [t for t in others if t != candidates[0]]
elif len(candidates) > 1:
owner = missing[0].split("::", 1)[0]
adapter = adapters.build(engine.config.project(owner), engine.worktree)
resolved = _disambiguate(candidates, missing[0], adapter)
if resolved is not None:
engine.ledger.event(
engine.run["id"], cycle["id"], "declared_test_mismatch",
json.dumps({"declared": missing, "adopted": [resolved], "all_candidates": candidates}),
)
kept = [t for t in targets if t not in missing]
engine.ledger.update(
"cycle", cycle["id"], target_tests=json.dumps(kept + [resolved])
)
cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
adopted_outcome = _outcome_from_verdicts(verdicts, resolved)
if adopted_outcome is None:
return _reply(
engine, cycle, Verb.REFACTOR_OR_ADVANCE,
f"Adopted {resolved} as the target (declared {missing[0]} was not"
" collected). Run `tdd advance` again to evaluate it.",
adopted=[resolved],
)
targets = kept + [resolved]
outcomes = {resolved: adopted_outcome}
others = [t for t in others if t != resolved]
else:
engine.ledger.event(
engine.run["id"], cycle["id"], "multiple_new_tests",
json.dumps(candidates),
)
return _reply(
engine, cycle, Verb.NAME_TARGET_TEST,
"Several new tests appeared; a cycle covers one behaviour. Name the"
" intended target with `tdd target <id>`.",
candidates=candidates,
)
else:
return _reply(
engine, cycle, Verb.REFACTOR_OR_ADVANCE,
f"Adopted {candidates[0]} as the target (declared {missing[0]} was not"
" collected). Run `tdd advance` again to evaluate it.",
adopted=candidates,
)
if len(candidates) > 1:
engine.ledger.event(
engine.run["id"], cycle["id"], "multiple_new_tests",
json.dumps(candidates),
)
return _reply(
engine, cycle, Verb.NAME_TARGET_TEST,
"Several new tests appeared; a cycle covers one behaviour. Name the"
" intended target with `tdd target <id>`.",
candidates=candidates,
engine, cycle, Verb.WRITE_TEST,
f"Target {missing[0]} was not collected and no new test was found."
" Write the failing test.",
missing=missing,
)
return _reply(
engine, cycle, Verb.WRITE_TEST,
f"Target {missing[0]} was not collected and no new test was found."
" Write the failing test.",
missing=missing,
)

not_collected = [t for t, o in outcomes.items() if o == NOT_COLLECTED]
if not_collected:
Expand Down
75 changes: 75 additions & 0 deletions tasks/friction-logs/issue-72-adopt-on-first-run-friction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Implementation Friction Log: tasks/issue-72-adopt-on-first-run.md

- Run: 14
- Executor: claude-sonnet-4-6 (source: transcript)
- Plan blob: `a93211d4ed71c1c73fa1c12447c48495efb0262a` (declared)
- Started: 2026-08-29T07:05:11.715696+00:00 Ended: 2026-08-29T07:46:47.353961+00:00 Outcome: complete
- Baseline failures at start: tddcli=0

## Plan fidelity

- Declared cycles: 6
- Delivered: 6 Skipped: 0
- Never reached: none
- Human interventions: 0

### Cycle 6: genuinely ambiguous new tests still ask the agent _(pin)_
- **Target:** `tddcli::tests/test_advance_adoption.py::test_ambiguous_new_tests_still_ask_the_agent`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_PIN': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (as expected)
- **Sensitivity check:** verified, restore byte-identical
- observed: `repo = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-283/test_ambiguous_new_tests_still0/workspace')`
- **Commits:**
- `d4c0d1e1d` [pin] test: pin that two same-file candidates still demand tdd target (1 files)

### Cycle 5: the unique candidate in the declared file is adopted among several new tests _(standard)_
- **Target:** `tddcli::tests/test_advance_adoption.py::test_unique_same_file_candidate_is_adopted_and_evaluated`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `537bd6e2a` [red] test: same-file disambiguation adopts and evaluates without asking (1 files)
- `88cb4d3cf` [green] feat: wire _disambiguate into the multiple-new-tests branch (same-file rule) (1 files)

### Cycle 4: disambiguation picks the candidate that normalise-matches the declared id _(standard)_
- **Target:** `tddcli::tests/test_advance_adoption.py::test_disambiguate_picks_the_normalisation_match`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `f9813f282` [red] test: _disambiguate resolves a vitest separator-only mismatch (1 files)
- `add252586` [green] feat: _disambiguate — unique normalise-equal candidate wins (1 files)
- `e3c694fd7` [refactor] refactor: disambiguation picks the candidate that normalise-matches the declared id (1 files)

### Cycle 3: a single adopted test that passed drives sensitivity in the same advance _(standard)_
- **Target:** `tddcli::tests/test_advance_adoption.py::test_adopted_passing_test_demands_sensitivity_in_one_advance`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (**passed**)
- **Sensitivity check:** verified, restore byte-identical
- observed: `repo = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-262/test_adopted_passing_test_dema0/workspace')`
- **Commits:**
- `8df5166dc` [refactor] refactor: a single adopted test that passed drives sensitivity in the same advance (1 files)
- **Event — red_first_violation:** ["tddcli::tests/test_advance_adoption.py::test_adopted_passing_test_demands_sensitivity_in_one_advance"]

### Cycle 2: a single adopted test that failed is evaluated as RED in the same advance _(standard)_
- **Target:** `tddcli::tests/test_advance_adoption.py::test_single_new_test_is_adopted_and_evaluated_in_one_advance`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `e6de7826b` [red] test: adoption of the one new failing test reaches RED without a re-run (1 files)
- `14aea5202` [green] feat: evaluate the adopted target from the suite run that already happened (2 files)
- `7eb1bfeb0` [refactor] refactor: a single adopted test that failed is evaluated as RED in the same advance (1 files)

### Cycle 1: outcome lookup returns None for an id absent from every verdict _(standard)_
- **Target:** `tddcli::tests/test_advance_adoption.py::test_outcome_lookup_returns_none_for_unexecuted_id`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `e24012204` [red] test: verdict outcome lookup admits it cannot judge an unexecuted id (1 files)
- `e60c9e5e3` [green] feat: _outcome_from_verdicts helper (None when the id never ran) (2 files)
- `6d97ea080` [refactor] refactor: outcome lookup returns None for an id absent from every verdict (1 files)

126 changes: 126 additions & 0 deletions tests/test_advance_adoption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from pathlib import Path

from conftest import run_cli, write_plan
from tddcli import advance
from tddcli import config as config_mod
from tddcli.adapters.base import Verdict
from tddcli.adapters.vitest_adapter import VitestAdapter

VITEST_TOML = """
[project.frontend]
root = "frontend"
adapter = "vitest"
test_paths = ["**/*.test.ts"]
"""


def _vitest_adapter(tmp_path: Path) -> VitestAdapter:
(tmp_path / "tdd.toml").write_text(VITEST_TOML)
(tmp_path / "frontend").mkdir()
cfg = config_mod.load(tmp_path)
return VitestAdapter(cfg.project("frontend"), tmp_path)

PLAN_SINGLE_CANDIDATE = """---
cycles:
- n: 1
project: backend
title: "add two numbers"
test: "tests/test_add.py::test_add_two_numbers"
commit_red: "test: add two numbers"
commit_green: "feat: add()"
---
"""

TEST_ADDING = """\
from app.calc import add


def test_adding():
assert add(2, 3) == 5
"""

CALC_STUB = """\
def add(a, b):
raise NotImplementedError
"""


def test_outcome_lookup_returns_none_for_unexecuted_id():
verdicts = [
Verdict("p1", "pytest", passed=["p1::tests/test_x.py::test_a"], failed=[]),
Verdict("p2", "pytest", passed=[], failed=["p2::tests/test_y.py::test_b"]),
]
assert advance._outcome_from_verdicts(verdicts, "backend::tests/test_x.py::test_y") is None


def test_single_new_test_is_adopted_and_evaluated_in_one_advance(repo):
plan = write_plan(repo, PLAN_SINGLE_CANDIDATE)
run_cli(repo, "plan", "register", plan)
run_cli(repo, "run", "start", "--plan", plan)

(repo / "backend" / "tests" / "test_add.py").write_text(TEST_ADDING)
(repo / "backend" / "app" / "calc.py").write_text(CALC_STUB)

out = run_cli(repo, "advance")
assert out["next_action"]["verb"] == "write_implementation", out
assert out["run"]["phase"] == "AWAITING_IMPL"


def test_disambiguate_picks_the_normalisation_match(tmp_path):
adapter = _vitest_adapter(tmp_path)
candidates = [
"frontend::a.test.ts > helper formats a value",
"frontend::b.test.ts > other case",
]
declared = "frontend::a.test.ts > helper > formats a value"
assert advance._disambiguate(candidates, declared, adapter) == candidates[0]


def test_unique_same_file_candidate_is_adopted_and_evaluated(repo):
plan = write_plan(repo, PLAN_SINGLE_CANDIDATE)
run_cli(repo, "plan", "register", plan)
run_cli(repo, "run", "start", "--plan", plan)

(repo / "backend" / "tests" / "test_add.py").write_text(TEST_ADDING)
(repo / "backend" / "app" / "calc.py").write_text(CALC_STUB)
(repo / "backend" / "tests" / "test_other.py").write_text("def test_other_thing():\n assert True\n")

out = run_cli(repo, "advance")
assert out["next_action"]["verb"] == "write_implementation", out
assert out["run"]["phase"] == "AWAITING_IMPL"


def test_ambiguous_new_tests_still_ask_the_agent(repo):
plan = write_plan(repo, PLAN_SINGLE_CANDIDATE)
run_cli(repo, "plan", "register", plan)
run_cli(repo, "run", "start", "--plan", plan)

two_tests = """\
from app.calc import add


def test_adding_two():
assert add(2, 3) == 5


def test_adding_three():
assert add(1, 2) == 3
"""
(repo / "backend" / "tests" / "test_add.py").write_text(two_tests)
(repo / "backend" / "app" / "calc.py").write_text(CALC_STUB)

out = run_cli(repo, "advance")
assert out["next_action"]["verb"] == "name_target_test", out
assert len(out["result"]["candidates"]) == 2


def test_adopted_passing_test_demands_sensitivity_in_one_advance(repo):
plan = write_plan(repo, PLAN_SINGLE_CANDIDATE)
run_cli(repo, "plan", "register", plan)
run_cli(repo, "run", "start", "--plan", plan)

(repo / "backend" / "tests" / "test_add.py").write_text(TEST_ADDING)
(repo / "backend" / "app" / "calc.py").write_text("def add(a, b):\n return a + b\n")

out = run_cli(repo, "advance")
assert out["next_action"]["verb"] == "run_sensitivity_check", out