diff --git a/docs/PRD.md b/docs/PRD.md index 847021c..82932f8 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 ` 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 diff --git a/src/tddcli/advance.py b/src/tddcli/advance.py index efca1e5..a566583 100644 --- a/src/tddcli/advance.py +++ b/src/tddcli/advance.py @@ -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 ------------------------------------------------------------ @@ -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 ) @@ -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 `.", + 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 `.", - 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: diff --git a/tasks/friction-logs/issue-72-adopt-on-first-run-friction.md b/tasks/friction-logs/issue-72-adopt-on-first-run-friction.md new file mode 100644 index 0000000..1564835 --- /dev/null +++ b/tasks/friction-logs/issue-72-adopt-on-first-run-friction.md @@ -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) + diff --git a/tests/test_advance_adoption.py b/tests/test_advance_adoption.py new file mode 100644 index 0000000..db65008 --- /dev/null +++ b/tests/test_advance_adoption.py @@ -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