tdd-cli 0.13.0__tar.gz → 0.14.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/CHANGELOG.md +40 -0
  2. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/PKG-INFO +27 -2
  3. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/README.md +26 -1
  4. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/docs/PRD.md +22 -3
  5. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/docs/harness-integration.md +2 -2
  6. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/__init__.py +1 -1
  7. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/vitest_adapter.py +52 -28
  8. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/advance.py +94 -69
  9. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/cli.py +93 -10
  10. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/ledger.py +10 -0
  11. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/render.py +10 -0
  12. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/conftest.py +33 -0
  13. tdd_cli-0.14.1/tests/fake_vitest.py +121 -0
  14. tdd_cli-0.14.1/tests/test_advance_adoption.py +278 -0
  15. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_batch_collection.py +35 -10
  16. tdd_cli-0.14.1/tests/test_run_abandon.py +256 -0
  17. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_suite_overrides.py +42 -2
  18. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_target_validation.py +33 -0
  19. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_vitest_adapter.py +66 -0
  20. tdd_cli-0.13.0/tests/test_advance_adoption.py +0 -129
  21. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/.gitignore +0 -0
  22. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/LICENSE +0 -0
  23. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/SECURITY.md +0 -0
  24. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/docs/split-runner.md +0 -0
  25. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/README.md +0 -0
  26. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  27. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  28. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/plan.md +0 -0
  29. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/skills/tdd-drive/README.md +0 -0
  30. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  31. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/skills/tdd-handoff/README.md +0 -0
  32. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  33. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/pyproject.toml +0 -0
  34. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/actor.py +0 -0
  35. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/__init__.py +0 -0
  36. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/base.py +0 -0
  37. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/cargo_adapter.py +0 -0
  38. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/exec_adapter.py +0 -0
  39. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/gradle_adapter.py +0 -0
  40. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/pytest_adapter.py +0 -0
  41. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/adapters/xctest_adapter.py +0 -0
  42. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/agentfs.py +0 -0
  43. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/config.py +0 -0
  44. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/contract.py +0 -0
  45. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/docs.py +0 -0
  46. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/envelope.py +0 -0
  47. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/fleet.py +0 -0
  48. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/gitutil.py +0 -0
  49. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/identity.py +0 -0
  50. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/leases.py +0 -0
  51. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/machine.py +0 -0
  52. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/plan_paths.py +0 -0
  53. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/runner.py +0 -0
  54. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/snapshot.py +0 -0
  55. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/staging.py +0 -0
  56. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/src/tddcli/target_lint.py +0 -0
  57. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_actor_seams.py +0 -0
  58. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_ancillary_files.py +0 -0
  59. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_artifact_regeneration.py +0 -0
  60. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_baseline_integrity.py +0 -0
  61. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_baseline_sanity.py +0 -0
  62. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_cargo_adapter.py +0 -0
  63. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_close_sweep_gates.py +0 -0
  64. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_concurrent_advance.py +0 -0
  65. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_config_and_staging.py +0 -0
  66. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_config_drift.py +0 -0
  67. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_contract.py +0 -0
  68. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_docs_command.py +0 -0
  69. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_doctor_attribution.py +0 -0
  70. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_doctor_blockers.py +0 -0
  71. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_end_to_end.py +0 -0
  72. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_evidence_extraction.py +0 -0
  73. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_example_plan.py +0 -0
  74. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_exec_adapter.py +0 -0
  75. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_executor_attribution.py +0 -0
  76. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_executor_notes.py +0 -0
  77. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_failure_clipping.py +0 -0
  78. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_fleet.py +0 -0
  79. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_gradle_adapter.py +0 -0
  80. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_heartbeat.py +0 -0
  81. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_id_normalisation.py +0 -0
  82. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_init_detection.py +0 -0
  83. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_late_baseline.py +0 -0
  84. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_named_leases_and_timeout.py +0 -0
  85. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_pin_cycles.py +0 -0
  86. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_plan_paths.py +0 -0
  87. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_progress.py +0 -0
  88. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_project_commands.py +0 -0
  89. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_project_env.py +0 -0
  90. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_pytest_xdist_group.py +0 -0
  91. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_python_env_managers.py +0 -0
  92. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_refactor_cycles.py +0 -0
  93. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_release_surface.py +0 -0
  94. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_run_claim.py +0 -0
  95. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_runner_import.py +0 -0
  96. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_sensitivity_evidence.py +0 -0
  97. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_single_project_repo.py +0 -0
  98. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_snapshot_and_identity.py +0 -0
  99. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_split_client.py +0 -0
  100. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_split_config.py +0 -0
  101. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_split_doctor.py +0 -0
  102. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_split_identity.py +0 -0
  103. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_split_ledger.py +0 -0
  104. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_split_runner.py +0 -0
  105. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_stub_hint.py +0 -0
  106. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_sudo_actor.py +0 -0
  107. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_suite_scope.py +0 -0
  108. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_target_lint.py +0 -0
  109. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_target_only_runs.py +0 -0
  110. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_time_report.py +0 -0
  111. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_timing_visibility.py +0 -0
  112. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_tree_hash.py +0 -0
  113. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_undeclared_close_gate.py +0 -0
  114. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_undeclared_dedup.py +0 -0
  115. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_worker_leases.py +0 -0
  116. {tdd_cli-0.13.0 → tdd_cli-0.14.1}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,46 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.1] - 2026-09-25
10
+
11
+ ### Fixed
12
+
13
+ - **vitest ids from named vitest projects** (#163). Whole-suite collection read `vitest list`
14
+ text, which prefixes `[<project>] ` to every line of a named vitest project and prints paths
15
+ relative to that project's `root`, so it collected ids no run ever reports (and, via the
16
+ per-file loop, a second spelling of every test). It now reads `vitest list --json` and roots
17
+ each id at the test's own file. tdd appends `--json` itself; a collect command must not.
18
+ - **The vitest override-isolation check sees named vitest projects** (#163). `tdd doctor`'s
19
+ check that the default config does not reach an override's files read the same text
20
+ listing, so a `[<project>] ` prefix meant it could never match. It now reads
21
+ `vitest list --json`, and a listing that is not JSON fails the check, naming the command.
22
+ - **Adoption never picks another cycle's test** (#163). When a cycle's declared test was
23
+ missing, `tdd advance` adopted any test new since the run started, including an earlier
24
+ cycle's target, so a premature advance handed cycle 1's test to cycle 2. Every target in
25
+ the run is now excluded, compared by normalised id.
26
+ - **`tdd target` accepts the plan's spelling of a test** (#163). It matched only the exact
27
+ collected id, so it could not name the declared vitest id (`src/a.test.ts > math > adds`
28
+ for a collected `frontend::src/a.test.ts > math adds`). It now qualifies and normalises
29
+ its argument as a plan declaration is, and stores the collected id.
30
+ - **An adopted target the run cannot find is refused** (#163). A test that collection
31
+ listed but no run reported was recorded as the target and came back `not_found` on every
32
+ advance, re-adopting a different test each time. The adoption is now evaluated first; on
33
+ `not_found` the declared target is kept, `adopted_target_not_found` is recorded, and
34
+ `tdd advance` answers `resolve_blocker`.
35
+
36
+ ## [0.14.0] - 2026-09-24
37
+
38
+ ### Added
39
+
40
+ - **`tdd run abandon`: end a run that will not be finished** (#150). `tdd run abandon
41
+ --reason <text>` ends the worktree's live or blocked run with `outcome = abandoned`;
42
+ `--run <id>` does the same for a run whose worktree is gone. It records the reason and
43
+ who did it (account and executor) in a new `abandonment` table, counts as a human
44
+ intervention, and releases the worktree's claims. `tdd fleet` stops listing the run,
45
+ `tdd metrics` reports it with an `abandoned: {reason, by}` entry, and a new run can
46
+ start at the same path. The table is added on open; the ledger schema version is
47
+ unchanged.
48
+
9
49
  ## [0.13.0] - 2026-09-23
10
50
 
11
51
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.13.0
3
+ Version: 0.14.1
4
4
  Summary: Ledger-backed TDD process controller for autonomous coding agents
5
5
  Project-URL: Homepage, https://github.com/geuben/tdd-cli
6
6
  Project-URL: Repository, https://github.com/geuben/tdd-cli
@@ -170,7 +170,11 @@ being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded fr
170
170
  environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
171
171
  composes with the run command); a vitest override must declare one — `vitest list` knows
172
172
  nothing of the override config, and the mismatch is refused at `tdd run start`, not
173
- mid-cycle.
173
+ mid-cycle. For the whole-suite listing tdd appends `--json` to each vitest collect command
174
+ (the default and every override's) and reads each test's file from vitest's JSON, so a
175
+ collect command must not carry `--json` itself: `vitest list --json --json` prints nothing.
176
+ The override-isolation check in `tdd doctor` reads the same JSON listing, and a listing it
177
+ cannot read fails the check, naming the command it ran.
174
178
 
175
179
  ## Sharing cores between concurrent agents
176
180
 
@@ -296,6 +300,26 @@ When the last declared cycle closes, the tool gathers every path that appeared i
296
300
  emitted (visible in `tdd metrics` and the friction log) and the run completes. The event
297
301
  makes a silent drop visible without blocking, since the file is already gone.
298
302
 
303
+ ### Abandoning a run
304
+
305
+ A run that will not be finished — stopped on purpose, or blocked for good — is ended with
306
+ `tdd run abandon --reason "<why>"` rather than `resume --unblock`. It takes the worktree's
307
+ live run, else its most recent blocked run. It sets the run's `outcome` to `abandoned`,
308
+ records the reason and who abandoned it (the account, and the executor identity as `run
309
+ start` resolves it; on a split runner the account is the calling agent's `SUDO_USER`),
310
+ records a `human_intervention`, and releases the worktree's baseline and advance claims.
311
+ `tdd fleet` stops listing the run, `tdd metrics` reports it as `abandoned` with an
312
+ `abandoned: {reason, by}` entry, and a new run can start at the same worktree path.
313
+
314
+ When the worktree is gone, abandon by id from any worktree of the repo: `tdd run abandon
315
+ --run <id> --reason "<why>"`. `--run` is accepted only for a run whose worktree no longer
316
+ exists or is the caller's own, so one agent cannot end another's live run.
317
+
318
+ Refusals, under `result.reason`, write nothing: `reason_required` (blank reason),
319
+ `no_run` (no live or blocked run here), `run_not_found`, `run_ended` (already complete or
320
+ abandoned), `worktree_exists` (`--run` names another worktree that still exists), and
321
+ `advance_in_flight` (a live `tdd advance` holds the worktree; a stale claim is released).
322
+
299
323
  The gate composes with `ancillary_files`: declared paths never fire `undeclared_file_touched`,
300
324
  so they are never seen by the gate.
301
325
 
@@ -495,6 +519,7 @@ and is never reclassified as a pin.
495
519
  | `tdd note "<text>"` | attach a free-text narrative note to the current cycle or run |
496
520
  | `tdd blocker --kind --detail` | typed blocker; releases the stop hook |
497
521
  | `tdd resume [--unblock --note]` | reconstruct position; human intervention |
522
+ | `tdd run abandon --reason <text> [--run <id>]` | end a run that will not be finished; human only |
498
523
  | `tdd log render [--out]` | project the ledger into a friction log |
499
524
  | `tdd metrics` | fidelity, attempts, violations, interventions, time |
500
525
  | `tdd fleet [--json]` | all active runs across every worktree; read-only |
@@ -142,7 +142,11 @@ being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded fr
142
142
  environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
143
143
  composes with the run command); a vitest override must declare one — `vitest list` knows
144
144
  nothing of the override config, and the mismatch is refused at `tdd run start`, not
145
- mid-cycle.
145
+ mid-cycle. For the whole-suite listing tdd appends `--json` to each vitest collect command
146
+ (the default and every override's) and reads each test's file from vitest's JSON, so a
147
+ collect command must not carry `--json` itself: `vitest list --json --json` prints nothing.
148
+ The override-isolation check in `tdd doctor` reads the same JSON listing, and a listing it
149
+ cannot read fails the check, naming the command it ran.
146
150
 
147
151
  ## Sharing cores between concurrent agents
148
152
 
@@ -268,6 +272,26 @@ When the last declared cycle closes, the tool gathers every path that appeared i
268
272
  emitted (visible in `tdd metrics` and the friction log) and the run completes. The event
269
273
  makes a silent drop visible without blocking, since the file is already gone.
270
274
 
275
+ ### Abandoning a run
276
+
277
+ A run that will not be finished — stopped on purpose, or blocked for good — is ended with
278
+ `tdd run abandon --reason "<why>"` rather than `resume --unblock`. It takes the worktree's
279
+ live run, else its most recent blocked run. It sets the run's `outcome` to `abandoned`,
280
+ records the reason and who abandoned it (the account, and the executor identity as `run
281
+ start` resolves it; on a split runner the account is the calling agent's `SUDO_USER`),
282
+ records a `human_intervention`, and releases the worktree's baseline and advance claims.
283
+ `tdd fleet` stops listing the run, `tdd metrics` reports it as `abandoned` with an
284
+ `abandoned: {reason, by}` entry, and a new run can start at the same worktree path.
285
+
286
+ When the worktree is gone, abandon by id from any worktree of the repo: `tdd run abandon
287
+ --run <id> --reason "<why>"`. `--run` is accepted only for a run whose worktree no longer
288
+ exists or is the caller's own, so one agent cannot end another's live run.
289
+
290
+ Refusals, under `result.reason`, write nothing: `reason_required` (blank reason),
291
+ `no_run` (no live or blocked run here), `run_not_found`, `run_ended` (already complete or
292
+ abandoned), `worktree_exists` (`--run` names another worktree that still exists), and
293
+ `advance_in_flight` (a live `tdd advance` holds the worktree; a stale claim is released).
294
+
271
295
  The gate composes with `ancillary_files`: declared paths never fire `undeclared_file_touched`,
272
296
  so they are never seen by the gate.
273
297
 
@@ -467,6 +491,7 @@ and is never reclassified as a pin.
467
491
  | `tdd note "<text>"` | attach a free-text narrative note to the current cycle or run |
468
492
  | `tdd blocker --kind --detail` | typed blocker; releases the stop hook |
469
493
  | `tdd resume [--unblock --note]` | reconstruct position; human intervention |
494
+ | `tdd run abandon --reason <text> [--run <id>]` | end a run that will not be finished; human only |
470
495
  | `tdd log render [--out]` | project the ledger into a friction log |
471
496
  | `tdd metrics` | fidelity, attempts, violations, interventions, time |
472
497
  | `tdd fleet [--json]` | all active runs across every worktree; read-only |
@@ -528,12 +528,16 @@ one has no move left but to re-run doctor and read the same output again.
528
528
  | `tdd blocker --kind <k> --detail <text>` | record a typed blocker; set run `outcome = blocked`, releasing the stop hook (H1) |
529
529
  | `tdd resume` | reconstruct position from the ledger and emit `next_action` |
530
530
  | `tdd resume --unblock --note <text>` | **human only.** Reopen a blocked run, recording a `human_intervention` event with the note |
531
+ | `tdd run abandon --reason <text> [--run <id>]` | **human only.** End a live or blocked run that will not be finished: `outcome = abandoned`, an `abandonment` row (reason, account, executor), a `human_intervention` event, and the worktree's claims released. `--run` only for a run whose worktree is gone or is the caller's own |
531
532
 
532
533
  - **R8.7** A blocked run is not live. H1 must permit the agent to stop, or a blocker traps it.
533
534
  - **R8.8** `human_intervention` events are the input to interventions-per-run, the primary
534
535
  autonomy metric.
535
- - **R8.9** In `AWAITING_TEST`, `advance` resolves the target by diffing `collect()` against cycle
536
- open. When the declared target is `not_found`, the adoption flow runs:
536
+ - **R8.9** In `AWAITING_TEST`, `advance` resolves the target by diffing `collect()` against the
537
+ run's start, and never offers a test that another cycle of the run targets: an earlier cycle's
538
+ test is new since the start too, and adopting it would hand one test to two cycles (#163). The
539
+ exclusion compares `normalise_id` forms (R10.8), because a target may be stored in its declared
540
+ spelling. When the declared target is `not_found`, the adoption flow runs:
537
541
  - **Single new test:** adopted as the target; `declared_test_mismatch` is recorded. The verdict
538
542
  from the run that already happened is evaluated immediately in the same `advance` call — no
539
543
  extra suite run. If the adopted test failed, the RED commit is made and the cycle moves to
@@ -543,6 +547,14 @@ one has no move left but to re-run doctor and read the same output again.
543
547
  above; `declared_test_mismatch` is recorded with the full candidate list.
544
548
  - **Multiple new tests — ambiguous:** recorded as `multiple_new_tests`; `next_action` requires
545
549
  the agent to name the intended target with `tdd target <id>` rather than guessing.
550
+ `tdd target` qualifies its argument the way a plan declaration is qualified and matches it by
551
+ `normalise_id` (R10.8), so the plan's own spelling names the test; it stores the collected id,
552
+ and refuses an id that matches no collected test in any spelling (#163).
553
+ - **Adopted target the run cannot find:** the adoption is evaluated before it is recorded. If
554
+ the run reports the adopted test `not_found`, collection and execution disagree about its id,
555
+ and no test the agent writes can fix that: the declared target is kept, no
556
+ `declared_test_mismatch` is recorded, `adopted_target_not_found` is (`{declared, adopted}`),
557
+ and `next_action` is `resolve_blocker`, asking for a `tooling` blocker (#163).
546
558
  The run that produced `not_found` already executed the new test; its verdict is retrieved from
547
559
  the in-hand `Verdict` objects without an extra suite run.
548
560
 
@@ -860,7 +872,14 @@ Adapter.typecheck(project) -> GateResult
860
872
  file it came from. `tdd doctor` reads the subprocess's **stdout**: `uv` writes
861
873
  environment warnings (e.g. `VIRTUAL_ENV=... does not match ...`) to stderr, while pytest writes
862
874
  the actual collection error (e.g. `ModuleNotFoundError`) to stdout. A check that reads stderr
863
- reports the wrapper's noise and loses the real error underneath it.
875
+ reports the wrapper's noise and loses the real error underneath it. Collection's whole-suite
876
+ vitest listing is `vitest list --json`, not the text listing `collectable()` probes: the text
877
+ prefixes `[<project>] ` to every line of a named vitest project and prints paths relative to
878
+ that project's own `root`, while the JSON carries each test's absolute `file`, which the
879
+ adapter makes root-relative exactly as `run()` does (#163). The vitest override-isolation probe
880
+ reads the same `vitest list --json` and decides reach from each entry's root-relative `file`; a
881
+ listing that is not a JSON array fails the probe and names the command, since passing it would
882
+ disable the check without a signal.
864
883
 
865
884
  ---
866
885
 
@@ -74,11 +74,11 @@ skill that duplicates any of it will fight the ledger.
74
74
  | `write_implementation` | RED is confirmed and committed, or the target still fails at GREEN (only the target ran) | write the minimum code to pass the target, then `tdd advance` |
75
75
  | `fix_regression` | tests outside the cycle are failing at GREEN, or the close sweep / lint / typecheck gates failed | fix them without breaking the target, then `tdd advance` |
76
76
  | `run_sensitivity_check` | a test passed where proof it *can* fail is required | `tdd sensitivity begin`, mutate the behaviour under test, `tdd sensitivity check`, then `tdd sensitivity end` |
77
- | `name_target_test` | several new tests appeared; a cycle covers one behaviour | pick the intended one: `tdd target <id>` |
77
+ | `name_target_test` | several new tests appeared; a cycle covers one behaviour | pick the intended one: `tdd target <id>`, in the collected spelling or the plan's own (`src/a.test.ts > math > adds`); the collected id is stored |
78
78
  | `refactor_or_advance` | GREEN is committed, or the tool simply wants `tdd advance` next | refactor only if the plan calls for it, then `tdd advance` |
79
79
  | `confirm_cycle_applicable` | a judgement point outside the cycle loop: config scaffolded, no active run, doctor passed | review / register / start as the `detail` names; if a cycle no longer applies, `tdd cycle skip --reason "..."` |
80
80
  | `annotate_cycle` | the plan requires judgement annotations before the cycle closes | `tdd annotate --key <k> --value "..."` for each missing key, then `tdd advance` |
81
- | `resolve_blocker` | wedged: three unchanged retries, or failing environment checks | fix the cause, or record it: `tdd blocker --kind <kind> --detail "..."` |
81
+ | `resolve_blocker` | wedged: three unchanged retries, failing environment checks, or an adopted target the run cannot find | fix the cause, or record it: `tdd blocker --kind <kind> --detail "..."` |
82
82
  | `await_baseline` | baseline collection is in flight (collector pid alive) | poll `tdd progress`; **never** re-run `tdd run start` for a live claim |
83
83
  | `complete` *(terminal)* | the run (or command) is finished | render the friction log if the `detail` asks, then stop |
84
84
  | `blocked` *(terminal)* | a typed blocker was recorded | surface the blocker to the human and stop |
@@ -3,4 +3,4 @@
3
3
  State is derived from observed test execution, never asserted by the caller.
4
4
  """
5
5
 
6
- __version__ = "0.13.0"
6
+ __version__ = "0.14.1"
@@ -41,6 +41,18 @@ def _extract_json(raw: str) -> dict | None:
41
41
  return None
42
42
 
43
43
 
44
+ def _json_listing(raw: str) -> list[dict] | None:
45
+ """`vitest list --json`'s array of `{name, file, projectName}`, or None when the
46
+ output is not one."""
47
+ try:
48
+ payload = json.loads(raw)
49
+ except json.JSONDecodeError:
50
+ return None
51
+ if not isinstance(payload, list):
52
+ return None
53
+ return [entry for entry in payload if isinstance(entry, dict)]
54
+
55
+
44
56
  #: The characters a JavaScript regex treats as syntax. Only these are escaped: vitest
45
57
  #: may compile `-t` with the `u` flag, which rejects the identity escapes (`\ `, `\-`)
46
58
  #: that Python's `re.escape` emits.
@@ -86,13 +98,14 @@ class VitestAdapter(Adapter):
86
98
  name = " ".join(part.strip() for part in remainder.split(" > "))
87
99
  return self.qualify(f"{file_part} > {name}")
88
100
 
89
- def _id_for(self, suite_path: str, full_name: str) -> str:
90
- abs_path = Path(suite_path)
101
+ def _rel(self, suite_path: str) -> str:
91
102
  try:
92
- rel = os.path.relpath(abs_path, self.root)
103
+ return os.path.relpath(Path(suite_path), self.root)
93
104
  except ValueError:
94
- rel = suite_path
95
- return self.qualify(f"{rel} > {full_name}")
105
+ return suite_path
106
+
107
+ def _id_for(self, suite_path: str, full_name: str) -> str:
108
+ return self.qualify(f"{self._rel(suite_path)} > {full_name}")
96
109
 
97
110
  def _test_cmd(self) -> str:
98
111
  return self.project.test_command or "npx vitest run"
@@ -267,24 +280,31 @@ class VitestAdapter(Adapter):
267
280
  return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
268
281
 
269
282
  def override_isolation(self) -> GateResult:
270
- """Probes with the default `vitest list` — the same stand-in for the
283
+ """Probes with the default `vitest list --json` — the same stand-in for the
271
284
  default run config that `collectable()` already relies on (`vitest run`
272
285
  has no listing mode, and running the suite just to enumerate it would
273
- execute against whatever the tests need live)."""
286
+ execute against whatever the tests need live). Reach is decided from each
287
+ entry's root-relative `file`; a listing that is not JSON fails the gate
288
+ rather than passing it by reaching nothing."""
274
289
  if not self.project.overrides:
275
290
  return GateResult(ok=True)
291
+ command = f"{self._collect_cmd()} --json"
276
292
  code, out, err = run_command(
277
- self._collect_cmd(), self.root, extra_env=self._suite_env(None), label="doctor"
278
- )
279
- reached = sorted(
280
- {
281
- f
282
- for f in (
283
- line.strip().partition(" > ")[0] for line in out.splitlines() if " > " in line
284
- )
285
- if self.project.override_for(f)
286
- }
293
+ command, self.root, extra_env=self._suite_env(None), label="doctor"
287
294
  )
295
+ listing = _json_listing(out)
296
+ if listing is None:
297
+ return GateResult(
298
+ ok=False,
299
+ output=(
300
+ f"`{command}` did not print a JSON array (exit {code}); tdd reads"
301
+ " vitest's JSON listing to check which files the default config"
302
+ " reaches, so a collect command must not already carry --json: "
303
+ + (err or out).strip()[:600]
304
+ ),
305
+ )
306
+ listed = {self._rel(entry["file"]) for entry in listing if entry.get("file")}
307
+ reached = sorted(f for f in listed if self.project.override_for(f))
288
308
  if not reached:
289
309
  return GateResult(ok=True)
290
310
  return GateResult(
@@ -311,22 +331,26 @@ class VitestAdapter(Adapter):
311
331
  self, command: str, env: dict[str, str] | None
312
332
  ) -> tuple[set[str], set[str]] | None:
313
333
  """Unlike `_parse_list_output`, which pins every id to the one file it was
314
- given, a whole-suite listing must read the file from each line — the same
315
- `file > describe > name` shape, one file per line rather than one per run."""
316
- code, out, err = run_command(command, self.root, extra_env=env, label="collect")
334
+ given, a whole-suite listing must read each test's file. The text listing
335
+ cannot supply it: a named vitest project prefixes `[<name>] ` to every line,
336
+ and a project with its own `root` prints paths relative to that root. The
337
+ JSON listing carries the absolute `file`, rooted here exactly as `run()`
338
+ roots its report. Anything that is not a JSON array is left to the loop."""
339
+ code, out, err = run_command(f"{command} --json", self.root, extra_env=env, label="collect")
317
340
  if code != 0:
318
341
  return None
342
+ listing = _json_listing(out)
343
+ if listing is None:
344
+ return None
319
345
  tests: set[str] = set()
320
346
  files: set[str] = set()
321
- for line in out.splitlines():
322
- line = line.strip()
323
- if " > " not in line:
347
+ for entry in listing:
348
+ file, name = entry.get("file"), entry.get("name")
349
+ if not (file and name):
324
350
  continue
325
- rel, _, remainder = line.partition(" > ")
326
- full_name = " ".join(part.strip() for part in remainder.split(" > "))
327
- if rel and full_name:
328
- tests.add(self.qualify(f"{rel.strip()} > {full_name}"))
329
- files.add(rel.strip())
351
+ full_name = " ".join(part.strip() for part in name.split(" > "))
352
+ tests.add(self._id_for(file, full_name))
353
+ files.add(self._rel(file))
330
354
  # An empty result needs no special case: it accounts for no files, so every
331
355
  # file falls to the loop exactly as a failure would.
332
356
  return tests, files
@@ -58,17 +58,30 @@ def _last_invocation_hash(engine: Engine, cycle) -> str | None:
58
58
 
59
59
 
60
60
  def _adopt_target(engine: Engine, cycle, missing: list[str]) -> tuple[list[str], list[str]]:
61
- """R8.9 — resolve the target from newly collected tests when the declared id misses."""
61
+ """R8.9 — resolve the target from newly collected tests when the declared id misses.
62
+
63
+ A test another cycle of the run already targets is never a candidate: it is new
64
+ since the run started too. Targets may be stored in the declared spelling, so the
65
+ exclusion compares normalised ids (#163)."""
62
66
  projects = json.loads(cycle["projects"])
63
67
  at_start = engine.ledger.collection(engine.run["id"])
64
68
  known = {t for tests in at_start.values() for t in tests}
65
- existing_targets = set(json.loads(cycle["target_tests"]))
69
+ run_targets = {
70
+ t
71
+ for row in engine.ledger.all(
72
+ "SELECT target_tests FROM cycle WHERE run_id = ?", (engine.run["id"],)
73
+ )
74
+ for t in json.loads(row["target_tests"])
75
+ }
66
76
 
67
77
  new_tests: list[str] = []
68
78
  for name in projects:
69
79
  adapter = adapters.build(engine.config.project(name), engine.worktree)
80
+ claimed = {adapter.normalise_id(t) for t in run_targets}
70
81
  current = adapter.collect()
71
- new_tests.extend(sorted(current.tests - known - existing_targets))
82
+ new_tests.extend(
83
+ sorted(t for t in current.tests - known if adapter.normalise_id(t) not in claimed)
84
+ )
72
85
  return new_tests, missing
73
86
 
74
87
 
@@ -200,6 +213,60 @@ def _evaluate_adopted(
200
213
  return outcomes.get(adopted), failure
201
214
 
202
215
 
216
+ def _adopt(
217
+ engine: Engine,
218
+ cycle,
219
+ projects: list[str],
220
+ targets: list[str],
221
+ missing: list[str],
222
+ adopted: str,
223
+ detail: dict,
224
+ verdicts,
225
+ retried: bool,
226
+ ):
227
+ """R8.9's one adoption path, whichever branch chose `adopted`: evaluate the new
228
+ target, then record the mismatch and retarget the cycle. Returns the reply when
229
+ the target is refused or has no outcome yet, else `(cycle, targets, outcome,
230
+ failure)`.
231
+
232
+ The evaluation comes first because a test that collection lists but the run
233
+ cannot find would otherwise become the target and come back `not_found` on every
234
+ advance (#163): collection and execution disagree about its id, which no test
235
+ the agent writes can fix."""
236
+ kept = [t for t in targets if t not in missing]
237
+ outcome, failure = _evaluate_adopted(engine, cycle, projects, kept, adopted, verdicts, retried)
238
+ if outcome == NOT_FOUND:
239
+ engine.ledger.event(
240
+ engine.run["id"],
241
+ cycle["id"],
242
+ "adopted_target_not_found",
243
+ json.dumps({"declared": missing, "adopted": [adopted]}),
244
+ )
245
+ return _reply(
246
+ engine,
247
+ cycle,
248
+ Verb.RESOLVE_BLOCKER,
249
+ f"{adopted} is collected but the run cannot find it: collection and execution"
250
+ " disagree about its id. The declared target is unchanged. Record it:"
251
+ " `tdd blocker --kind tooling --detail '...'`.",
252
+ adopted=[adopted],
253
+ declared=missing,
254
+ )
255
+ engine.ledger.event(engine.run["id"], cycle["id"], "declared_test_mismatch", json.dumps(detail))
256
+ engine.ledger.update("cycle", cycle["id"], target_tests=json.dumps(kept + [adopted]))
257
+ cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
258
+ if outcome is None:
259
+ return _reply(
260
+ engine,
261
+ cycle,
262
+ Verb.REFACTOR_OR_ADVANCE,
263
+ f"Adopted {adopted} as the target (declared {missing[0]} was not"
264
+ " collected). Run `tdd advance` again to evaluate it.",
265
+ adopted=[adopted],
266
+ )
267
+ return cycle, kept + [adopted], outcome, failure
268
+
269
+
203
270
  # -- handlers ------------------------------------------------------------
204
271
 
205
272
 
@@ -219,67 +286,23 @@ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool)
219
286
  missing = [t for t, o in outcomes.items() if o == NOT_FOUND]
220
287
  if missing:
221
288
  candidates, _ = _adopt_target(engine, cycle, missing)
222
- if len(candidates) == 1:
223
- engine.ledger.event(
224
- engine.run["id"],
225
- cycle["id"],
226
- "declared_test_mismatch",
227
- json.dumps({"declared": missing, "adopted": candidates}),
228
- )
229
- kept = [t for t in targets if t not in missing]
230
- engine.ledger.update("cycle", cycle["id"], target_tests=json.dumps(kept + candidates))
231
- cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
232
- adopted_outcome, adopted_failure = _evaluate_adopted(
233
- engine, cycle, projects, kept, candidates[0], verdicts, retried
289
+ if not candidates:
290
+ return _reply(
291
+ engine,
292
+ cycle,
293
+ Verb.WRITE_TEST,
294
+ f"Target {missing[0]} was not collected and no new test was found."
295
+ " Write the failing test.",
296
+ missing=missing,
234
297
  )
235
- if adopted_outcome is None:
236
- return _reply(
237
- engine,
238
- cycle,
239
- Verb.REFACTOR_OR_ADVANCE,
240
- f"Adopted {candidates[0]} as the target (declared {missing[0]} was not"
241
- " collected). Run `tdd advance` again to evaluate it.",
242
- adopted=candidates,
243
- )
244
- targets = kept + candidates
245
- outcomes = {candidates[0]: adopted_outcome}
246
- failure = adopted_failure or failure
247
- others = [t for t in others if t != candidates[0]]
248
- elif len(candidates) > 1:
298
+ if len(candidates) == 1:
299
+ adopted = candidates[0]
300
+ detail = {"declared": missing, "adopted": candidates}
301
+ else:
249
302
  owner = missing[0].split("::", 1)[0]
250
303
  adapter = adapters.build(engine.config.project(owner), engine.worktree)
251
304
  resolved = _disambiguate(candidates, missing[0], adapter)
252
- if resolved is not None:
253
- engine.ledger.event(
254
- engine.run["id"],
255
- cycle["id"],
256
- "declared_test_mismatch",
257
- json.dumps(
258
- {"declared": missing, "adopted": [resolved], "all_candidates": candidates}
259
- ),
260
- )
261
- kept = [t for t in targets if t not in missing]
262
- engine.ledger.update(
263
- "cycle", cycle["id"], target_tests=json.dumps(kept + [resolved])
264
- )
265
- cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
266
- adopted_outcome, adopted_failure = _evaluate_adopted(
267
- engine, cycle, projects, kept, resolved, verdicts, retried
268
- )
269
- if adopted_outcome is None:
270
- return _reply(
271
- engine,
272
- cycle,
273
- Verb.REFACTOR_OR_ADVANCE,
274
- f"Adopted {resolved} as the target (declared {missing[0]} was not"
275
- " collected). Run `tdd advance` again to evaluate it.",
276
- adopted=[resolved],
277
- )
278
- targets = kept + [resolved]
279
- outcomes = {resolved: adopted_outcome}
280
- failure = adopted_failure or failure
281
- others = [t for t in others if t != resolved]
282
- else:
305
+ if resolved is None:
283
306
  engine.ledger.event(
284
307
  engine.run["id"],
285
308
  cycle["id"],
@@ -294,15 +317,17 @@ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool)
294
317
  " intended target with `tdd target <id>`.",
295
318
  candidates=candidates,
296
319
  )
297
- else:
298
- return _reply(
299
- engine,
300
- cycle,
301
- Verb.WRITE_TEST,
302
- f"Target {missing[0]} was not collected and no new test was found."
303
- " Write the failing test.",
304
- missing=missing,
305
- )
320
+ adopted = resolved
321
+ detail = {"declared": missing, "adopted": [resolved], "all_candidates": candidates}
322
+ adoption = _adopt(
323
+ engine, cycle, projects, targets, missing, adopted, detail, verdicts, retried
324
+ )
325
+ if isinstance(adoption, Envelope):
326
+ return adoption
327
+ cycle, targets, adopted_outcome, adopted_failure = adoption
328
+ outcomes = {adopted: adopted_outcome}
329
+ failure = adopted_failure or failure
330
+ others = [t for t in others if t != adopted]
306
331
 
307
332
  not_collected = [t for t, o in outcomes.items() if o == NOT_COLLECTED]
308
333
  if not_collected: