tdd-cli 0.1.0__py3-none-any.whl
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.
- tdd_cli-0.1.0.dist-info/METADATA +391 -0
- tdd_cli-0.1.0.dist-info/RECORD +24 -0
- tdd_cli-0.1.0.dist-info/WHEEL +4 -0
- tdd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tdd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- tddcli/__init__.py +6 -0
- tddcli/adapters/__init__.py +48 -0
- tddcli/adapters/base.py +119 -0
- tddcli/adapters/pytest_adapter.py +179 -0
- tddcli/adapters/vitest_adapter.py +170 -0
- tddcli/advance.py +423 -0
- tddcli/cli.py +1043 -0
- tddcli/config.py +255 -0
- tddcli/contract.py +237 -0
- tddcli/envelope.py +96 -0
- tddcli/fleet.py +128 -0
- tddcli/gitutil.py +138 -0
- tddcli/identity.py +82 -0
- tddcli/leases.py +118 -0
- tddcli/ledger.py +433 -0
- tddcli/machine.py +390 -0
- tddcli/render.py +275 -0
- tddcli/snapshot.py +90 -0
- tddcli/staging.py +130 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tdd-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ledger-backed TDD process controller for autonomous coding agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/geuben/tdd-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/geuben/tdd-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/geuben/tdd-cli/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/geuben/tdd-cli/blob/main/CHANGELOG.md
|
|
9
|
+
Author: geuben
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,ai,pytest,tdd,testing,vitest,workflow
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# tdd-cli
|
|
30
|
+
|
|
31
|
+
A ledger-backed TDD process controller for autonomous coding agents.
|
|
32
|
+
|
|
33
|
+
Process state is **derived from observed test execution**, never asserted by the caller.
|
|
34
|
+
There is no command that accepts a phase, and no file an agent can edit to claim progress
|
|
35
|
+
it has not made.
|
|
36
|
+
|
|
37
|
+
Implements [`tdd-cli-prd.md`](./docs/PRD.md). Requirement ids (`R9.14`, `§6.2`) in the source
|
|
38
|
+
refer to that document.
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
An agent instructed to follow TDD will report that it did. The usual ways to hold it to
|
|
43
|
+
that — prompt rules, a checklist, a state file in the worktree — all share one flaw: the
|
|
44
|
+
record of progress is written by the same agent it is meant to constrain. That flaw
|
|
45
|
+
produces four failure classes, reliably: state that is corrupted or edited to claim
|
|
46
|
+
progress never made; "the test failed first" as an unverifiable self-report; runs that
|
|
47
|
+
stop silently mid-plan; and no record comparable across runs, plans, or models.
|
|
48
|
+
|
|
49
|
+
This tool removes the agent from the reporting path. It runs the suites itself, computes
|
|
50
|
+
every phase transition from what the tests observably did, and records the whole run in a
|
|
51
|
+
ledger the agent cannot reach — which is also what makes the friction logs and metrics at
|
|
52
|
+
the end trustworthy.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
pip install tdd-cli # or: uv tool install tdd-cli
|
|
58
|
+
tdd --help
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
From source:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
uv sync
|
|
65
|
+
uv run tdd --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
tdd init # scaffold tdd.toml from detected projects — then review it
|
|
72
|
+
tdd doctor # environment preflight
|
|
73
|
+
tdd plan register tasks/my-plan.md
|
|
74
|
+
tdd run start --plan tasks/my-plan.md
|
|
75
|
+
tdd advance # the only command that changes phase
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every command emits JSON with a `next_action`. That verb is the single authority on control
|
|
79
|
+
flow — skills describe *how* to do the work and must never contain stopping instructions.
|
|
80
|
+
[`docs/harness-integration.md`](./docs/harness-integration.md) specifies the verb set and how
|
|
81
|
+
to write such a skill; [`examples/skills/tdd-drive/`](./examples/skills/tdd-drive/) is a
|
|
82
|
+
runnable one for Claude Code. Its planning-side counterpart,
|
|
83
|
+
[`examples/skills/tdd-handoff/`](./examples/skills/tdd-handoff/), hardens a
|
|
84
|
+
draft plan and authors its contract before the run starts.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
`tdd.toml` at the worktree root. Roots are declared, never discovered by scanning for marker
|
|
89
|
+
files: two projects can share a marker, and directory-listing order must not decide which
|
|
90
|
+
suite runs.
|
|
91
|
+
|
|
92
|
+
A single-project repository declares the worktree root itself:
|
|
93
|
+
|
|
94
|
+
```toml
|
|
95
|
+
[project.app]
|
|
96
|
+
root = "."
|
|
97
|
+
adapter = "pytest"
|
|
98
|
+
test_paths = ["tests/"]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A monorepo declares one project per root:
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
[project.backend]
|
|
105
|
+
root = "backend"
|
|
106
|
+
adapter = "pytest"
|
|
107
|
+
test_paths = ["tests/"]
|
|
108
|
+
lint = ["ruff check"]
|
|
109
|
+
typecheck = ["mypy ."]
|
|
110
|
+
|
|
111
|
+
[project.frontend]
|
|
112
|
+
root = "frontend"
|
|
113
|
+
adapter = "vitest"
|
|
114
|
+
test_paths = ["**/__tests__/**", "**/*.test.ts"]
|
|
115
|
+
typecheck = ["tsc --noEmit"]
|
|
116
|
+
|
|
117
|
+
[artifact.openapi]
|
|
118
|
+
path = "schema/openapi.json"
|
|
119
|
+
produced_by = "backend"
|
|
120
|
+
regenerate = "uv run python -m app.export_openapi"
|
|
121
|
+
consumed_by = ["frontend"]
|
|
122
|
+
|
|
123
|
+
[artifact.api_client]
|
|
124
|
+
path = "frontend/generated"
|
|
125
|
+
produced_by = "artifact.openapi" # artifacts chain
|
|
126
|
+
regenerate = "npm --prefix codegen run generate"
|
|
127
|
+
check = "npm --prefix codegen run check"
|
|
128
|
+
consumed_by = ["frontend"]
|
|
129
|
+
generated = true # excluded from authorship accounting
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
A generator that is never hand-edited (`codegen`) is an artifact regeneration command, not a
|
|
133
|
+
project. It has no tests and no cycles.
|
|
134
|
+
|
|
135
|
+
## Sharing cores between concurrent agents
|
|
136
|
+
|
|
137
|
+
Several agents running tdd-cli on one machine (each in its own worktree) face a bad
|
|
138
|
+
trade: a fixed worker count in the test command either oversubscribes the box when
|
|
139
|
+
agents run together or serialises every suite when an agent is alone. Instead, declare
|
|
140
|
+
where the worker count goes and let the tool compute it:
|
|
141
|
+
|
|
142
|
+
```toml
|
|
143
|
+
[project.backend]
|
|
144
|
+
test_command = "uv run pytest -n {workers}"
|
|
145
|
+
|
|
146
|
+
[project.frontend]
|
|
147
|
+
test_command = "npx vitest run --maxWorkers={workers}"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each suite invocation takes a lease in a machine-wide directory (`~/.cache/tdd-cli/leases`,
|
|
151
|
+
override with `TDD_LEASE_DIR`) held for the duration of the run, and receives
|
|
152
|
+
`max(1, cores // live_leases)` workers: one agent gets the whole machine, four agents get a
|
|
153
|
+
quarter each. Leases whose process has died, or older than a suite could legitimately run,
|
|
154
|
+
are swept — a crash never throttles the machine.
|
|
155
|
+
|
|
156
|
+
`{workers}` is opt-in per project; without it the declared command runs verbatim, but the
|
|
157
|
+
budget is still exported as `TDD_WORKERS` for commands that prefer to read it themselves,
|
|
158
|
+
and the lease is still held so other agents account for the running suite. Set
|
|
159
|
+
`TDD_CORE_BUDGET` to cap the total below `os.cpu_count()` and keep headroom for the agents
|
|
160
|
+
themselves.
|
|
161
|
+
|
|
162
|
+
The split is computed at lease acquisition: an agent arriving mid-run takes the smaller
|
|
163
|
+
share immediately, and the earlier agent's share corrects on its next invocation. Per-file
|
|
164
|
+
collection stays serial — collection is cheap and xdist adds startup cost per file.
|
|
165
|
+
|
|
166
|
+
## Watching every agent at once
|
|
167
|
+
|
|
168
|
+
The ledger is one database per repository, shared by all worktrees, so every agent's
|
|
169
|
+
progress is already in one place. `tdd fleet` reads it:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
tdd fleet # one line per active run, plus in-flight baselines and executing suites
|
|
173
|
+
tdd fleet --json # the same as a machine envelope
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Each run line carries the worktree, plan, cycle N of M, phase, and the age of the newest
|
|
177
|
+
suite invocation — a stale age is the signal for a wedged agent. Baselines still being
|
|
178
|
+
collected are listed separately, and the worker-lease directory is read (never modified)
|
|
179
|
+
to show how many suites are executing right now and each one's share of the cores.
|
|
180
|
+
|
|
181
|
+
The command is safe to run while agents are mid-run from any worktree on any branch: it
|
|
182
|
+
opens the ledger with SQLite's read-only mode, so it is structurally incapable of creating,
|
|
183
|
+
migrating, or writing the database, and it requires no `tdd.toml`, plan, or active run.
|
|
184
|
+
|
|
185
|
+
## Plan contracts
|
|
186
|
+
|
|
187
|
+
The plan carries its own contract in YAML front-matter, so a planning agent needs no
|
|
188
|
+
integration with this tool. The contract is hashed at the **committed blob**, so editing
|
|
189
|
+
front-matter mid-run raises `plan_blob_changed`.
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
---
|
|
193
|
+
cycles:
|
|
194
|
+
- n: 1
|
|
195
|
+
project: backend
|
|
196
|
+
title: "unmapped exception is not swallowed"
|
|
197
|
+
test: "tests/test_map.py::test_unmapped_is_not_swallowed"
|
|
198
|
+
stub_expected: ["app/exception_map.py"]
|
|
199
|
+
commit_red: "test: unmapped exception is not swallowed"
|
|
200
|
+
commit_green: "feat: domain exception map skeleton"
|
|
201
|
+
- n: 8
|
|
202
|
+
project: backend
|
|
203
|
+
pin_cycle: true # characterisation; passes on arrival by design
|
|
204
|
+
test: "tests/test_keys.py::test_enrol_maps_signature_error_to_422"
|
|
205
|
+
- n: 12
|
|
206
|
+
projects: ["backend", "frontend"]
|
|
207
|
+
contract_cycle: true # breaking change: no intermediate green state
|
|
208
|
+
tests:
|
|
209
|
+
- "backend::tests/test_openapi.py::test_upload_body_schema"
|
|
210
|
+
- "frontend::services/__tests__/upload.test.ts > matches contract"
|
|
211
|
+
annotation_keys: ["literal_detail_handlers_kept"]
|
|
212
|
+
---
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Absent front-matter is legitimate — the run proceeds as `undeclared` with
|
|
216
|
+
`--allow-undeclared`, and fidelity metrics are unavailable. **Malformed** front-matter
|
|
217
|
+
hard-fails registration: it is almost always a defect in the planning process, and that
|
|
218
|
+
signal must surface rather than degrade silently.
|
|
219
|
+
|
|
220
|
+
[`examples/plan.md`](./examples/plan.md) is a complete plan — every cycle kind, the full
|
|
221
|
+
front-matter vocabulary, and the body structure (context, verified repo facts, per-cycle
|
|
222
|
+
expected failures) that lets an agent execute it without conversation context. The test
|
|
223
|
+
suite registers it, so it cannot drift from the contract parser.
|
|
224
|
+
|
|
225
|
+
Producing a plan of that shape is itself a process.
|
|
226
|
+
[`examples/skills/tdd-handoff/`](./examples/skills/tdd-handoff/) is a Claude
|
|
227
|
+
Code skill that takes a draft plan, verifies its claims against the codebase, probes each
|
|
228
|
+
cycle's RED path empirically, assigns cycle kinds, and authors the contract — gated on
|
|
229
|
+
`tdd plan register` succeeding with the intended cycle count and kind breakdown.
|
|
230
|
+
|
|
231
|
+
## The friction log
|
|
232
|
+
|
|
233
|
+
`tdd log render` projects the ledger into a markdown friction log — the feedback channel
|
|
234
|
+
back to the **planning** process. It reports plan fidelity (declared vs delivered vs
|
|
235
|
+
skipped vs never-reached cycles, human interventions) and, per cycle: the target, suite
|
|
236
|
+
runs by phase, the first-run outcome against expectation, sensitivity checks, commits,
|
|
237
|
+
and integrity events.
|
|
238
|
+
|
|
239
|
+
Every observable fact in it is projected from recorded events. The agent that did the
|
|
240
|
+
work cannot compose it — that is what makes it worth reading, and why the log is
|
|
241
|
+
rendered, never written. Judgement enters in exactly two ways:
|
|
242
|
+
|
|
243
|
+
- **Per cycle, through `tdd annotate`** — rendered inline in the cycle it concerns.
|
|
244
|
+
Beyond keys the plan requires via `annotation_keys`, these keys are reserved for
|
|
245
|
+
judgement agents volunteer: `plan_defect`, `friction_note`, `red_expectation`,
|
|
246
|
+
`commit_shape_deviation`, `test_setup_smell`, `unplanned_change`, `new_work_raised`.
|
|
247
|
+
`plan_defect` is the one that matters most: it records where the plan and the codebase
|
|
248
|
+
disagreed, which is precisely what the next plan needs to know.
|
|
249
|
+
- **Per run, as prose appended below the rendered document.** Legitimate and expected —
|
|
250
|
+
post-run narrative (CI failures, patterns noticed) has no cycle to attach to. But it
|
|
251
|
+
is unverified: an auditor should trust the projected sections and read appended
|
|
252
|
+
narrative as the agent's opinion.
|
|
253
|
+
|
|
254
|
+
`tdd metrics` is the quantitative companion: attempts per cycle, RED-first violation
|
|
255
|
+
rate, fidelity, blockers, interventions. Cross-plan aggregates are deliberately labelled
|
|
256
|
+
non-comparable — cycle difficulty varies too much — so compare runs of the same contract
|
|
257
|
+
only (e.g. the same plan executed by two models).
|
|
258
|
+
|
|
259
|
+
The loop closes when the rendered log is committed alongside the plan and read before
|
|
260
|
+
the next plan is written.
|
|
261
|
+
|
|
262
|
+
## Adapters
|
|
263
|
+
|
|
264
|
+
`pytest` and `vitest` are built in. The pytest adapter runs the suite through the
|
|
265
|
+
project's own environment manager, detected from its marker files — `uv.lock`,
|
|
266
|
+
`poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` — checked at
|
|
267
|
+
the project root first, then the worktree root (workspace layouts keep one lockfile at the
|
|
268
|
+
top). With no marker, the active environment's bare `pytest` runs. An explicit
|
|
269
|
+
`test_command` always wins.
|
|
270
|
+
|
|
271
|
+
Third-party adapters register under the
|
|
272
|
+
`tddcli.adapters` entry-point group:
|
|
273
|
+
|
|
274
|
+
```toml
|
|
275
|
+
[project.entry-points."tddcli.adapters"]
|
|
276
|
+
cargo = "tddcli_cargo:CargoAdapter"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The class must implement `tddcli.adapters.base.Adapter`. Built-in names cannot be
|
|
280
|
+
shadowed: a plugin named `pytest` is ignored, so what "observed test execution" means for
|
|
281
|
+
existing configs can never change underneath them.
|
|
282
|
+
|
|
283
|
+
## Platform support
|
|
284
|
+
|
|
285
|
+
Linux and macOS. Windows is refused at startup with `reason: "unsupported_platform"` —
|
|
286
|
+
worker leases and process-liveness checks are POSIX-only. Use WSL.
|
|
287
|
+
|
|
288
|
+
Every JSON envelope carries `envelope_version`; consumers should check it rather than
|
|
289
|
+
assuming the shape is stable across releases. See also [SECURITY.md](./SECURITY.md) for
|
|
290
|
+
the trust model: running `tdd` executes the repository's declared commands.
|
|
291
|
+
|
|
292
|
+
## Cycle kinds
|
|
293
|
+
|
|
294
|
+
**Standard / contract** — `AWAITING_TEST → AWAITING_IMPL → AWAITING_REFACTOR → CLOSED`
|
|
295
|
+
|
|
296
|
+
**Pin** — `AWAITING_PIN → SENSITIVITY_REQUIRED → AWAITING_REFACTOR → CLOSED`
|
|
297
|
+
|
|
298
|
+
A pin characterises existing behaviour before deleting or restructuring it, so its test
|
|
299
|
+
passes on arrival by design and its sensitivity check is mandatory. Pins are excluded from
|
|
300
|
+
the RED-first violation metric; a *standard* cycle that passes on arrival remains a violation
|
|
301
|
+
and is never reclassified as a pin.
|
|
302
|
+
|
|
303
|
+
## What the tool does, that agents do not
|
|
304
|
+
|
|
305
|
+
- **Stages and commits.** The staged set is derived from the phase: RED takes tests and
|
|
306
|
+
declared stubs, GREEN takes the rest. This makes "implementation written during RED" an
|
|
307
|
+
exact, language-independent detection with no source parsing.
|
|
308
|
+
- **Regenerates stale artifacts**, in their own commit, so hand-written and generated changes
|
|
309
|
+
stay separately reviewable.
|
|
310
|
+
- **Resolves executor identity** from the session transcript. It is never an argument.
|
|
311
|
+
- **Runs the close sweep** over the cycle's projects plus anything downstream of an artifact
|
|
312
|
+
it touched — not every project every time.
|
|
313
|
+
|
|
314
|
+
## Commands
|
|
315
|
+
|
|
316
|
+
| Command | Purpose |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `tdd init` / `tdd doctor` | scaffold config; environment preflight |
|
|
319
|
+
| `tdd plan register <path>` | parse and hash the contract |
|
|
320
|
+
| `tdd run start --plan <path>` | capture baselines, resolve executor, open cycle 1 |
|
|
321
|
+
| `tdd status` | position and `next_action`; safe any time |
|
|
322
|
+
| `tdd advance [--retry]` | run suites, compute the transition, commit |
|
|
323
|
+
| `tdd cycle skip --reason` | sanctioned path for a cycle the plan got wrong |
|
|
324
|
+
| `tdd sensitivity begin\|check\|end` | prove a passing test can fail; verify restore |
|
|
325
|
+
| `tdd annotate --key --value` | attach judgement to the current cycle |
|
|
326
|
+
| `tdd blocker --kind --detail` | typed blocker; releases the stop hook |
|
|
327
|
+
| `tdd resume [--unblock --note]` | reconstruct position; human intervention |
|
|
328
|
+
| `tdd log render [--out]` | project the ledger into a friction log |
|
|
329
|
+
| `tdd metrics` | fidelity, attempts, violations, interventions |
|
|
330
|
+
| `tdd fleet [--json]` | all active runs across every worktree; read-only |
|
|
331
|
+
|
|
332
|
+
## Running a long baseline
|
|
333
|
+
|
|
334
|
+
`run start` probes every project's suite before a run exists (R9.5a), and on a real project
|
|
335
|
+
that can take minutes — well past an agent harness's default Bash timeout. If the command
|
|
336
|
+
appears to hang or time out, **do not re-run it**: the probe is still making progress in the
|
|
337
|
+
background, and a second `run start` against the same worktree is refused with
|
|
338
|
+
`reason: "baseline_in_progress"` — retrying on timeout just stacks refusals on top of a
|
|
339
|
+
baseline that was never stuck. In order of preference:
|
|
340
|
+
|
|
341
|
+
1. **Background it.** Run `tdd run start` in the background if your harness supports it. The
|
|
342
|
+
heartbeat (`baseline_captured` / `project_completed` lines on stderr) lands in the task log
|
|
343
|
+
as each project finishes, and most harnesses re-invoke the agent when a backgrounded command
|
|
344
|
+
exits — a real completion callback, with no timeout ceiling.
|
|
345
|
+
2. **Raise the timeout.** Claude Code's Bash tool takes an explicit `timeout` (default 120000ms,
|
|
346
|
+
max 600000ms). A baseline that takes 3–8 minutes fits inside ten.
|
|
347
|
+
3. **Poll.** `tdd progress` (and `tdd status`) report `collecting_baseline` with per-project
|
|
348
|
+
counters and elapsed time while a baseline is in flight, with `next_action.verb ==
|
|
349
|
+
"await_baseline"` — the fallback for an agent that inherited a run it did not start itself.
|
|
350
|
+
|
|
351
|
+
## Storage
|
|
352
|
+
|
|
353
|
+
One SQLite ledger **per repository**, in `~/.local/share/tdd-cli/` (override with
|
|
354
|
+
`TDD_LEDGER_HOME`), keyed by the common git dir. Never inside the worktree, never resolved
|
|
355
|
+
from the current directory, never committed. `worktree_path` is a column, so concurrent runs
|
|
356
|
+
in separate worktrees are isolated without a pruned worktree orphaning its history.
|
|
357
|
+
|
|
358
|
+
## Enforcement boundary
|
|
359
|
+
|
|
360
|
+
The CLI cannot compel an agent — only the harness can.
|
|
361
|
+
|
|
362
|
+
**Hard gates here:** phase is never caller-supplied; a cycle cannot close over a stale
|
|
363
|
+
artifact; a passed-on-arrival cycle cannot close without a verified sensitivity check;
|
|
364
|
+
`advance` refuses an unchanged tree unless `--retry`.
|
|
365
|
+
|
|
366
|
+
**Recorded, never blocked:** non-stub writes during RED, undeclared file touches, scope
|
|
367
|
+
divergence, extra attempts. Prevention rules with edge cases produce false denials, and a
|
|
368
|
+
blocked agent improvises around them — putting it right back in the reporting path the
|
|
369
|
+
tool exists to keep it out of.
|
|
370
|
+
|
|
371
|
+
**Delegated to hooks:** a Stop hook that queries `tdd status` and refuses to let an agent
|
|
372
|
+
stop while a run is live; a Bash hook redirecting bare `pytest`/`vitest` through `tdd advance`.
|
|
373
|
+
Ready-made Claude Code implementations of both live in
|
|
374
|
+
[`examples/claude-code-hooks/`](./examples/claude-code-hooks/).
|
|
375
|
+
|
|
376
|
+
**Delegated to the skill:** how to respond to each `next_action` verb — writing the test,
|
|
377
|
+
the stub, the implementation. [`docs/harness-integration.md`](./docs/harness-integration.md)
|
|
378
|
+
is the contract for writing one against any harness.
|
|
379
|
+
|
|
380
|
+
## Development
|
|
381
|
+
|
|
382
|
+
```sh
|
|
383
|
+
uv run pytest
|
|
384
|
+
uv run ruff check src tests
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
388
|
+
|
|
389
|
+
## License
|
|
390
|
+
|
|
391
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
tddcli/__init__.py,sha256=tGM1_L4f_rJ1N2dps6a3IDvEtcb1YsmVkvno1PK40Y8,189
|
|
2
|
+
tddcli/advance.py,sha256=_DBSBVQIC9vzlmEbg-7tPlMAnS5UWJmtGTC5iPAoF7Y,16794
|
|
3
|
+
tddcli/cli.py,sha256=8tV7OeNat7Gkrksa27WRMO2Ey_8Ns671ykP2RIiOtd0,41214
|
|
4
|
+
tddcli/config.py,sha256=nqm3OCRu6NIN1dTqmXGErOwGE6Z157vgV9u3vpoXS_I,9168
|
|
5
|
+
tddcli/contract.py,sha256=9G0cD22Pgns5uSQIx3AtUNFmj7RGpYWc2UQYsoPRqGA,8273
|
|
6
|
+
tddcli/envelope.py,sha256=qBDL-PfPoEwh0q5VxXz5UE-wOJJgk_Cu7Y3WTI9fLVI,3101
|
|
7
|
+
tddcli/fleet.py,sha256=CpxKRR99jOS8D_nJK9dCx_bcHVMdCdBLJVA5Z_qBmqk,4678
|
|
8
|
+
tddcli/gitutil.py,sha256=u72NJvcGFf2lRYVQSFud_vCshYlkGHXz31Mw05tfzh0,4297
|
|
9
|
+
tddcli/identity.py,sha256=sk4BGPyI3zTQLYL5FtwqjbqAWyCKeo31Ncf3fbjjYQQ,2734
|
|
10
|
+
tddcli/leases.py,sha256=pUTzXMwvl8wMSyfzt-dk281qyHvqyH8OgpyYd4vig9w,4103
|
|
11
|
+
tddcli/ledger.py,sha256=D03btDrxZJ0c5-Yd1T7QzQ07jFgIeemHh64-4m0gew0,16071
|
|
12
|
+
tddcli/machine.py,sha256=W31Z1vUkl-4W2tz9gDboX--ysSFQdEZwal4T9Jk0jAo,15859
|
|
13
|
+
tddcli/render.py,sha256=kLPtiY4Gvoav1ChIZzxg9eZs5IGot4mYYX3Mv1S4K1E,10676
|
|
14
|
+
tddcli/snapshot.py,sha256=uMP3vq9g2TmWch5ndhy3LIgVZ644Hqr2e1pZZgzoRK8,2981
|
|
15
|
+
tddcli/staging.py,sha256=O7_58jpK4decmx6jcBdXAPrj--mOm1DJmQVwapk_Zlg,4549
|
|
16
|
+
tddcli/adapters/__init__.py,sha256=eo5jBGnud3OLjxery6-0vNe9Izv2HocfSqM0wuZooWk,1620
|
|
17
|
+
tddcli/adapters/base.py,sha256=9BhqnzALkvseRuPINuzp3sMFaLbNzlJwHNxA5siIEdg,3618
|
|
18
|
+
tddcli/adapters/pytest_adapter.py,sha256=qLaW2YlYMyvg-PmZL_fKNbJmyYx06wUScFyMcjIxNlc,7387
|
|
19
|
+
tddcli/adapters/vitest_adapter.py,sha256=py0lU8T7EEzXwTe3wBwZsc8vFfL0S_JSlR83lATenYk,6635
|
|
20
|
+
tdd_cli-0.1.0.dist-info/METADATA,sha256=ZDmFLNR8s3r1-BYHPsZpRR5bKGn8QCUzvxcQXsZxcmY,17584
|
|
21
|
+
tdd_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
22
|
+
tdd_cli-0.1.0.dist-info/entry_points.txt,sha256=ToEhgkhPB4epNYbzkGE5lB-ZhiKj12r4B_Vp7KIbELY,40
|
|
23
|
+
tdd_cli-0.1.0.dist-info/licenses/LICENSE,sha256=58gZrnAWFIvOo6A6l6XeG7tMHZZTQa2SRaZFc784NaY,1063
|
|
24
|
+
tdd_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 geuben
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tddcli/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .base import Adapter, Collection, GateResult, Verdict
|
|
7
|
+
from .pytest_adapter import PytestAdapter
|
|
8
|
+
from .vitest_adapter import VitestAdapter
|
|
9
|
+
|
|
10
|
+
REGISTRY: dict[str, type[Adapter]] = {
|
|
11
|
+
"pytest": PytestAdapter,
|
|
12
|
+
"vitest": VitestAdapter,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _entry_points():
|
|
17
|
+
"""Third-party adapters, published under the `tddcli.adapters` entry-point group.
|
|
18
|
+
|
|
19
|
+
A separate seam so tests can substitute fake entry points without installing a
|
|
20
|
+
distribution. Loading is deferred to `build`: enumerating names must stay cheap
|
|
21
|
+
(doctor lists them), and a broken plugin must not break projects that never
|
|
22
|
+
reference it.
|
|
23
|
+
"""
|
|
24
|
+
return importlib.metadata.entry_points(group="tddcli.adapters")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def available() -> set[str]:
|
|
28
|
+
"""Every adapter name that `build` could resolve, built-in or plugin."""
|
|
29
|
+
return set(REGISTRY) | {ep.name for ep in _entry_points()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build(project, worktree: Path) -> Adapter:
|
|
33
|
+
# Built-ins always win: a plugin must not be able to shadow `pytest` and
|
|
34
|
+
# change what observed test execution means for every existing config.
|
|
35
|
+
cls = REGISTRY.get(project.adapter)
|
|
36
|
+
if cls is None:
|
|
37
|
+
for ep in _entry_points():
|
|
38
|
+
if ep.name == project.adapter:
|
|
39
|
+
cls = ep.load()
|
|
40
|
+
break
|
|
41
|
+
if cls is None:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
f"unknown adapter {project.adapter!r}; available: {sorted(available())}"
|
|
44
|
+
)
|
|
45
|
+
return cls(project, worktree)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["Adapter", "Collection", "GateResult", "Verdict", "REGISTRY", "available", "build"]
|
tddcli/adapters/base.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Adapter contract (§10). Adding an adapter requires no change to core logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .. import leases
|
|
11
|
+
|
|
12
|
+
NOT_FOUND = "not_found"
|
|
13
|
+
NOT_COLLECTED = "not_collected"
|
|
14
|
+
PASSED = "passed"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Verdict:
|
|
20
|
+
project: str
|
|
21
|
+
adapter: str
|
|
22
|
+
target: str | None = None
|
|
23
|
+
target_outcome: str = NOT_FOUND
|
|
24
|
+
target_failure: str = ""
|
|
25
|
+
passed: list[str] = field(default_factory=list)
|
|
26
|
+
failed: list[str] = field(default_factory=list)
|
|
27
|
+
duration_ms: int = 0
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GateResult:
|
|
33
|
+
ok: bool
|
|
34
|
+
output: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Collection:
|
|
39
|
+
"""Per-file collection (R10.3): one uncollectable file must not destroy the set."""
|
|
40
|
+
|
|
41
|
+
tests: set[str] = field(default_factory=set)
|
|
42
|
+
failed_files: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_command(
|
|
46
|
+
command: str, cwd: Path, timeout: int = 1800,
|
|
47
|
+
extra_env: dict[str, str] | None = None,
|
|
48
|
+
) -> tuple[int, str, str]:
|
|
49
|
+
proc = subprocess.run(
|
|
50
|
+
command,
|
|
51
|
+
shell=True,
|
|
52
|
+
cwd=str(cwd),
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
env=None if extra_env is None else {**os.environ, **extra_env},
|
|
57
|
+
)
|
|
58
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Adapter:
|
|
62
|
+
name = "base"
|
|
63
|
+
|
|
64
|
+
def __init__(self, project, worktree: Path):
|
|
65
|
+
self.project = project
|
|
66
|
+
self.worktree = worktree
|
|
67
|
+
self.root = worktree / project.root
|
|
68
|
+
|
|
69
|
+
def qualify(self, raw_id: str) -> str:
|
|
70
|
+
"""Namespace a runner-native id with its project (R9.12)."""
|
|
71
|
+
return f"{self.project.name}::{raw_id}"
|
|
72
|
+
|
|
73
|
+
def strip(self, qualified: str) -> str:
|
|
74
|
+
prefix = f"{self.project.name}::"
|
|
75
|
+
return qualified[len(prefix):] if qualified.startswith(prefix) else qualified
|
|
76
|
+
|
|
77
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
def _run_suite(self, command: str) -> tuple[int, str, str]:
|
|
81
|
+
"""Run the suite under a machine-wide worker lease.
|
|
82
|
+
|
|
83
|
+
Substituting `{workers}` is opt-in per project; a command without the
|
|
84
|
+
placeholder runs verbatim, so parallelism stays exactly as the project
|
|
85
|
+
declared (§10). TDD_WORKERS is exported either way for commands that
|
|
86
|
+
prefer to read the budget themselves. The lease is held for the whole
|
|
87
|
+
invocation so concurrent agents in other worktrees see this one and
|
|
88
|
+
take a smaller share.
|
|
89
|
+
"""
|
|
90
|
+
with leases.worker_lease() as workers:
|
|
91
|
+
return run_command(
|
|
92
|
+
command.replace("{workers}", str(workers)),
|
|
93
|
+
self.root,
|
|
94
|
+
extra_env={"TDD_WORKERS": str(workers)},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def stub_hint(self) -> str:
|
|
98
|
+
"""The language idiom for a stub body, quoted into the create_stub directive."""
|
|
99
|
+
return "a body that fails loudly, never working logic"
|
|
100
|
+
|
|
101
|
+
def collect(self) -> Collection:
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
def collectable(self) -> GateResult:
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
def lint(self) -> GateResult:
|
|
108
|
+
return self._gate(self.project.lint)
|
|
109
|
+
|
|
110
|
+
def typecheck(self) -> GateResult:
|
|
111
|
+
return self._gate(self.project.typecheck)
|
|
112
|
+
|
|
113
|
+
def _gate(self, commands: list[str]) -> GateResult:
|
|
114
|
+
chunks = []
|
|
115
|
+
for cmd in commands:
|
|
116
|
+
code, out, err = run_command(cmd, self.root)
|
|
117
|
+
if code != 0:
|
|
118
|
+
chunks.append(f"$ {cmd}\n{out}\n{err}".strip())
|
|
119
|
+
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:4000])
|