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
tddcli/machine.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""The state machine (§6). Phases are computed from observed test runs, never asserted.
|
|
2
|
+
|
|
3
|
+
Two cycle kinds:
|
|
4
|
+
|
|
5
|
+
* standard/contract — AWAITING_TEST → AWAITING_IMPL → AWAITING_REFACTOR → CLOSED
|
|
6
|
+
* pin — AWAITING_PIN → SENSITIVITY_REQUIRED → AWAITING_REFACTOR → CLOSED
|
|
7
|
+
|
|
8
|
+
A pin cycle's test passes on arrival by design (R6.2), so it is excluded from the
|
|
9
|
+
RED-first violation metric. A *standard* cycle that passes on arrival remains a
|
|
10
|
+
violation and is never reclassified as a pin (R6.4) — the kind is declared by the plan
|
|
11
|
+
in advance, never inferred from the outcome.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import adapters, gitutil, staging
|
|
22
|
+
from . import contract as contract_mod
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .contract import PIN, REFACTOR, DeclaredCycle
|
|
25
|
+
from .envelope import Verb, heartbeat
|
|
26
|
+
from .ledger import Ledger, now
|
|
27
|
+
|
|
28
|
+
AWAITING_TEST = "AWAITING_TEST"
|
|
29
|
+
AWAITING_IMPL = "AWAITING_IMPL"
|
|
30
|
+
AWAITING_REFACTOR = "AWAITING_REFACTOR"
|
|
31
|
+
AWAITING_PIN = "AWAITING_PIN"
|
|
32
|
+
SENSITIVITY_REQUIRED = "SENSITIVITY_REQUIRED"
|
|
33
|
+
CLOSED = "CLOSED"
|
|
34
|
+
SKIPPED = "SKIPPED"
|
|
35
|
+
|
|
36
|
+
#: A refactor cycle has no test of its own, so it opens straight into the refactor
|
|
37
|
+
#: phase: the existing suite plus the close sweep are its only guard.
|
|
38
|
+
OPENING_PHASE = {PIN: AWAITING_PIN, REFACTOR: AWAITING_REFACTOR}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SweepOutcome:
|
|
43
|
+
failures: list[str]
|
|
44
|
+
gates: list[tuple[str, str, str]] # (project, kind, output)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def ok(self) -> bool:
|
|
48
|
+
return not self.failures and not self.gates
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Engine:
|
|
52
|
+
def __init__(self, ledger: Ledger, config: Config, worktree: Path, run_row):
|
|
53
|
+
self.ledger = ledger
|
|
54
|
+
self.config = config
|
|
55
|
+
self.worktree = worktree
|
|
56
|
+
self.run = run_row
|
|
57
|
+
self.contract_row = ledger.one(
|
|
58
|
+
"SELECT * FROM plan_contract WHERE id = ?", (run_row["plan_contract_id"],)
|
|
59
|
+
)
|
|
60
|
+
self.declared = contract_mod.cycles_from_json(self.contract_row["declared_cycles"])
|
|
61
|
+
self.annotation_keys = json.loads(self.contract_row["annotation_keys"])
|
|
62
|
+
self.excluded = set(json.loads(run_row["preexisting_dirty"]))
|
|
63
|
+
|
|
64
|
+
# -- helpers ---------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def declared_for(self, ordinal: int) -> DeclaredCycle | None:
|
|
67
|
+
return next((c for c in self.declared if c.ordinal == ordinal), None)
|
|
68
|
+
|
|
69
|
+
def roots(self, project_names: list[str]) -> list[str]:
|
|
70
|
+
return [self.config.project(n).root for n in project_names]
|
|
71
|
+
|
|
72
|
+
def tree_hash(self, project_names: list[str]) -> str:
|
|
73
|
+
return gitutil.tree_hash(self.worktree, self.roots(project_names))
|
|
74
|
+
|
|
75
|
+
def authored_changes(self, cycle_row) -> set[str]:
|
|
76
|
+
return {
|
|
77
|
+
p
|
|
78
|
+
for p in gitutil.changed_paths(self.worktree)
|
|
79
|
+
if p not in self.excluded
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def trailers(self, cycle_row, phase: str) -> dict[str, str]:
|
|
83
|
+
return {
|
|
84
|
+
"TDD-Run": str(self.run["id"]),
|
|
85
|
+
"TDD-Cycle": str(cycle_row["ordinal"]),
|
|
86
|
+
"TDD-Phase": phase,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def run_state(self, cycle_row) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"id": self.run["id"],
|
|
92
|
+
"plan": self.contract_row["plan_path"],
|
|
93
|
+
"cycle": cycle_row["ordinal"] if cycle_row else None,
|
|
94
|
+
"of": len(self.declared) or None,
|
|
95
|
+
"kind": cycle_row["kind"] if cycle_row else None,
|
|
96
|
+
"phase": cycle_row["phase"] if cycle_row else None,
|
|
97
|
+
"projects": json.loads(cycle_row["projects"]) if cycle_row else [],
|
|
98
|
+
"executor": self.run["executor_model"],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# -- suite execution -------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _baselines(self) -> dict[str, set[str]]:
|
|
104
|
+
return self.ledger.baselines(self.run["id"])
|
|
105
|
+
|
|
106
|
+
def run_projects(
|
|
107
|
+
self, project_names: list[str], targets: list[str], cycle_row, phase: str,
|
|
108
|
+
retried: bool = False,
|
|
109
|
+
) -> tuple[dict[str, str], list[str], list, str]:
|
|
110
|
+
"""Run each project once. Returns (target outcomes, other failures, verdicts, failure text)."""
|
|
111
|
+
baselines = self._baselines()
|
|
112
|
+
outcomes: dict[str, str] = {}
|
|
113
|
+
others: list[str] = []
|
|
114
|
+
verdicts = []
|
|
115
|
+
failure_text = ""
|
|
116
|
+
tree = self.tree_hash(project_names)
|
|
117
|
+
|
|
118
|
+
for name in project_names:
|
|
119
|
+
project = self.config.project(name)
|
|
120
|
+
adapter = adapters.build(project, self.worktree)
|
|
121
|
+
target = next((t for t in targets if t.startswith(f"{name}::")), None)
|
|
122
|
+
started = time.monotonic()
|
|
123
|
+
verdict = adapter.run(target)
|
|
124
|
+
elapsed = time.monotonic() - started
|
|
125
|
+
verdicts.append(verdict)
|
|
126
|
+
# Unconditional, not past a duration threshold — a conditional heartbeat
|
|
127
|
+
# goes silent exactly when a run is slow for an unexpected reason.
|
|
128
|
+
# Distinct event name from `baseline_captured`: same channel, different
|
|
129
|
+
# meaning. `phase` separates these from the close sweep's own lines,
|
|
130
|
+
# which `sweep()` emits — it does not route through here.
|
|
131
|
+
heartbeat(
|
|
132
|
+
event="project_completed", project=name, phase=phase,
|
|
133
|
+
elapsed_s=round(elapsed, 2),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
base = baselines.get(name, set())
|
|
137
|
+
other = [f for f in verdict.failed if f not in base and f not in targets]
|
|
138
|
+
others.extend(other)
|
|
139
|
+
if target is not None:
|
|
140
|
+
outcomes[target] = verdict.target_outcome
|
|
141
|
+
if verdict.target_failure:
|
|
142
|
+
failure_text = verdict.target_failure
|
|
143
|
+
|
|
144
|
+
self.ledger.insert(
|
|
145
|
+
"invocation",
|
|
146
|
+
run_id=self.run["id"],
|
|
147
|
+
cycle_id=cycle_row["id"],
|
|
148
|
+
phase_at=phase,
|
|
149
|
+
project=name,
|
|
150
|
+
adapter=adapter.name,
|
|
151
|
+
target_test=target,
|
|
152
|
+
target_outcome=verdict.target_outcome if target else None,
|
|
153
|
+
target_failure=verdict.target_failure[:2000],
|
|
154
|
+
total_passed=len(verdict.passed),
|
|
155
|
+
total_failed=len(verdict.failed),
|
|
156
|
+
other_failures=json.dumps(other),
|
|
157
|
+
duration_ms=verdict.duration_ms,
|
|
158
|
+
retried=int(retried),
|
|
159
|
+
tree_hash=tree,
|
|
160
|
+
started_at=now(),
|
|
161
|
+
)
|
|
162
|
+
if verdict.error:
|
|
163
|
+
self.ledger.event(
|
|
164
|
+
self.run["id"], cycle_row["id"], "tooling_defect", verdict.error
|
|
165
|
+
)
|
|
166
|
+
return outcomes, others, verdicts, failure_text
|
|
167
|
+
|
|
168
|
+
def sweep(self, cycle_row, touched: set[str], skip_own: bool = False) -> SweepOutcome:
|
|
169
|
+
"""R9.2 — cycle projects plus anything downstream of an artifact it touched.
|
|
170
|
+
|
|
171
|
+
`skip_own` drops the cycle's own suites when the tree is unchanged since GREEN:
|
|
172
|
+
they just passed on an identical tree. Downstream projects still run, having
|
|
173
|
+
not run at all yet. Never set for a refactor cycle, where the existing suite
|
|
174
|
+
is the only guard there is.
|
|
175
|
+
"""
|
|
176
|
+
cycle_projects = json.loads(cycle_row["projects"])
|
|
177
|
+
names = self.config.close_sweep_projects(cycle_projects, touched)
|
|
178
|
+
if skip_own:
|
|
179
|
+
names = [n for n in names if n not in cycle_projects]
|
|
180
|
+
baselines = self._baselines()
|
|
181
|
+
failures: list[str] = []
|
|
182
|
+
gates: list[tuple[str, str, str]] = []
|
|
183
|
+
|
|
184
|
+
for name in names:
|
|
185
|
+
project = self.config.project(name)
|
|
186
|
+
adapter = adapters.build(project, self.worktree)
|
|
187
|
+
started = time.monotonic()
|
|
188
|
+
verdict = adapter.run(None)
|
|
189
|
+
heartbeat(
|
|
190
|
+
event="project_completed", project=name, phase="CLOSE_SWEEP",
|
|
191
|
+
elapsed_s=round(time.monotonic() - started, 2),
|
|
192
|
+
)
|
|
193
|
+
base = baselines.get(name, set())
|
|
194
|
+
failures.extend(f for f in verdict.failed if f not in base)
|
|
195
|
+
self.ledger.insert(
|
|
196
|
+
"invocation",
|
|
197
|
+
run_id=self.run["id"],
|
|
198
|
+
cycle_id=cycle_row["id"],
|
|
199
|
+
phase_at="CLOSE_SWEEP",
|
|
200
|
+
project=name,
|
|
201
|
+
adapter=adapter.name,
|
|
202
|
+
target_test=None,
|
|
203
|
+
target_outcome=None,
|
|
204
|
+
target_failure="",
|
|
205
|
+
total_passed=len(verdict.passed),
|
|
206
|
+
total_failed=len(verdict.failed),
|
|
207
|
+
other_failures=json.dumps(verdict.failed),
|
|
208
|
+
duration_ms=verdict.duration_ms,
|
|
209
|
+
tree_hash=self.tree_hash([name]),
|
|
210
|
+
started_at=now(),
|
|
211
|
+
)
|
|
212
|
+
for kind, gate in (("lint", adapter.lint()), ("typecheck", adapter.typecheck())):
|
|
213
|
+
self.ledger.insert(
|
|
214
|
+
"gate_result",
|
|
215
|
+
run_id=self.run["id"],
|
|
216
|
+
cycle_id=cycle_row["id"],
|
|
217
|
+
project=name,
|
|
218
|
+
kind=kind,
|
|
219
|
+
ok=int(gate.ok),
|
|
220
|
+
output=gate.output,
|
|
221
|
+
at=now(),
|
|
222
|
+
)
|
|
223
|
+
if not gate.ok:
|
|
224
|
+
gates.append((name, kind, gate.output))
|
|
225
|
+
return SweepOutcome(failures=failures, gates=gates)
|
|
226
|
+
|
|
227
|
+
# -- artifacts -------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def check_artifacts(self, cycle_row) -> list[str]:
|
|
230
|
+
"""R9.12/R9.20 — the tool regenerates; the agent is informed, not asked."""
|
|
231
|
+
regenerated: list[str] = []
|
|
232
|
+
for art in self.config.artifacts.values():
|
|
233
|
+
if not art.check and not art.regenerate:
|
|
234
|
+
continue
|
|
235
|
+
stale = self._artifact_stale(art)
|
|
236
|
+
self.ledger.insert(
|
|
237
|
+
"artifact_check",
|
|
238
|
+
run_id=self.run["id"],
|
|
239
|
+
cycle_id=cycle_row["id"] if cycle_row else None,
|
|
240
|
+
artifact=art.name,
|
|
241
|
+
stale=int(stale),
|
|
242
|
+
regenerated=0,
|
|
243
|
+
at=now(),
|
|
244
|
+
)
|
|
245
|
+
if not stale:
|
|
246
|
+
continue
|
|
247
|
+
self.ledger.event(
|
|
248
|
+
self.run["id"], cycle_row["id"] if cycle_row else None,
|
|
249
|
+
"stale_artifact", art.name,
|
|
250
|
+
)
|
|
251
|
+
if art.regenerate:
|
|
252
|
+
adapters.base.run_command(art.regenerate, self.worktree)
|
|
253
|
+
# The artifact's own path is staged even without `generated = true`,
|
|
254
|
+
# and so is its upstream chain: one hook often refreshes the spec and
|
|
255
|
+
# the client together, and a spec left dirty here is never committed
|
|
256
|
+
# by any phase commit — CI then diffs a stale spec against a fresh
|
|
257
|
+
# client.
|
|
258
|
+
chain = self.config.artifact_chain(art)
|
|
259
|
+
paths = [
|
|
260
|
+
p for p in gitutil.changed_paths(self.worktree)
|
|
261
|
+
if self.config.is_generated(p) or any(a.owns(p) for a in chain)
|
|
262
|
+
]
|
|
263
|
+
sha, staged = staging.commit_generated(
|
|
264
|
+
self.worktree, paths, art.name,
|
|
265
|
+
self.trailers(cycle_row, "artifact") if cycle_row else {},
|
|
266
|
+
)
|
|
267
|
+
if sha:
|
|
268
|
+
self.ledger.insert(
|
|
269
|
+
"commit_record",
|
|
270
|
+
run_id=self.run["id"],
|
|
271
|
+
cycle_id=cycle_row["id"] if cycle_row else None,
|
|
272
|
+
phase="artifact",
|
|
273
|
+
sha=sha,
|
|
274
|
+
message=f"chore({art.name}): regenerate",
|
|
275
|
+
files=json.dumps(staged),
|
|
276
|
+
at=now(),
|
|
277
|
+
)
|
|
278
|
+
regenerated.append(art.name)
|
|
279
|
+
return regenerated
|
|
280
|
+
|
|
281
|
+
def _artifact_stale(self, art) -> bool:
|
|
282
|
+
if art.check:
|
|
283
|
+
code, _, _ = adapters.base.run_command(art.check, self.worktree)
|
|
284
|
+
return code != 0
|
|
285
|
+
if not art.regenerate:
|
|
286
|
+
return False
|
|
287
|
+
before = gitutil.tree_hash(self.worktree, [art.path])
|
|
288
|
+
adapters.base.run_command(art.regenerate, self.worktree)
|
|
289
|
+
return gitutil.tree_hash(self.worktree, [art.path]) != before
|
|
290
|
+
|
|
291
|
+
# -- cycle lifecycle -------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def open_cycle(self, ordinal: int):
|
|
294
|
+
declared = self.declared_for(ordinal)
|
|
295
|
+
if declared is None:
|
|
296
|
+
return None
|
|
297
|
+
phase = OPENING_PHASE.get(declared.kind, AWAITING_TEST)
|
|
298
|
+
cycle_id = self.ledger.insert(
|
|
299
|
+
"cycle",
|
|
300
|
+
run_id=self.run["id"],
|
|
301
|
+
ordinal=ordinal,
|
|
302
|
+
kind=declared.kind,
|
|
303
|
+
projects=json.dumps(declared.projects),
|
|
304
|
+
declared_tests=json.dumps(
|
|
305
|
+
[self._qualify(declared, t) for t in declared.tests]
|
|
306
|
+
),
|
|
307
|
+
target_tests=json.dumps(
|
|
308
|
+
[self._qualify(declared, t) for t in declared.tests]
|
|
309
|
+
),
|
|
310
|
+
phase=phase,
|
|
311
|
+
head_at_open=gitutil.head(self.worktree),
|
|
312
|
+
title=declared.title,
|
|
313
|
+
opened_at=now(),
|
|
314
|
+
)
|
|
315
|
+
return self.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle_id,))
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _qualify(declared: DeclaredCycle, test_id: str) -> str:
|
|
319
|
+
if "::" in test_id and test_id.split("::", 1)[0] in declared.projects:
|
|
320
|
+
return test_id
|
|
321
|
+
for project in declared.projects:
|
|
322
|
+
if test_id.startswith(f"{project}/"):
|
|
323
|
+
return f"{project}::{test_id[len(project) + 1:]}"
|
|
324
|
+
return f"{declared.projects[0]}::{test_id}"
|
|
325
|
+
|
|
326
|
+
def transition(self, cycle_row, to_phase: str) -> None:
|
|
327
|
+
self.ledger.insert(
|
|
328
|
+
"transition",
|
|
329
|
+
cycle_id=cycle_row["id"],
|
|
330
|
+
from_phase=cycle_row["phase"],
|
|
331
|
+
to_phase=to_phase,
|
|
332
|
+
at=now(),
|
|
333
|
+
)
|
|
334
|
+
self.ledger.update("cycle", cycle_row["id"], phase=to_phase)
|
|
335
|
+
|
|
336
|
+
def close_cycle(self, cycle_row):
|
|
337
|
+
self.transition(cycle_row, CLOSED)
|
|
338
|
+
self.ledger.update("cycle", cycle_row["id"], closed_at=now())
|
|
339
|
+
nxt = next(
|
|
340
|
+
(c for c in self.declared if c.ordinal > cycle_row["ordinal"]), None
|
|
341
|
+
)
|
|
342
|
+
if nxt is None:
|
|
343
|
+
self.ledger.update(
|
|
344
|
+
"run", self.run["id"], ended_at=now(), outcome="complete"
|
|
345
|
+
)
|
|
346
|
+
return None
|
|
347
|
+
return self.open_cycle(nxt.ordinal)
|
|
348
|
+
|
|
349
|
+
def record_commit(self, cycle_row, phase: str, sha: str, message: str, files: list[str]):
|
|
350
|
+
self.ledger.insert(
|
|
351
|
+
"commit_record",
|
|
352
|
+
run_id=self.run["id"],
|
|
353
|
+
cycle_id=cycle_row["id"],
|
|
354
|
+
phase=phase,
|
|
355
|
+
sha=sha,
|
|
356
|
+
message=message,
|
|
357
|
+
files=json.dumps(files),
|
|
358
|
+
at=now(),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def opening_action(self, cycle_row) -> tuple[Verb, str]:
|
|
362
|
+
"""What the agent must do first in a newly opened cycle, by kind."""
|
|
363
|
+
ordinal = cycle_row["ordinal"]
|
|
364
|
+
title = cycle_row["title"] or "untitled"
|
|
365
|
+
targets = json.loads(cycle_row["target_tests"])
|
|
366
|
+
if cycle_row["kind"] == REFACTOR:
|
|
367
|
+
return Verb.REFACTOR_OR_ADVANCE, (
|
|
368
|
+
f"Cycle {ordinal} ({title}) is behaviour-preserving: perform the"
|
|
369
|
+
" migration, then `tdd advance`. The existing suite is the guard."
|
|
370
|
+
)
|
|
371
|
+
if cycle_row["kind"] == PIN:
|
|
372
|
+
return Verb.WRITE_TEST, (
|
|
373
|
+
f"Cycle {ordinal} ({title}) is a pin cycle: write a characterisation"
|
|
374
|
+
" test that passes on arrival."
|
|
375
|
+
)
|
|
376
|
+
target = targets[0] if targets else "the declared test"
|
|
377
|
+
return Verb.WRITE_TEST, (
|
|
378
|
+
f"Cycle {ordinal} ({title}): write the failing test {target}."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def missing_annotations(self, cycle_row) -> list[str]:
|
|
382
|
+
if not self.annotation_keys:
|
|
383
|
+
return []
|
|
384
|
+
present = {
|
|
385
|
+
r["key"]
|
|
386
|
+
for r in self.ledger.all(
|
|
387
|
+
"SELECT key FROM annotation WHERE cycle_id = ?", (cycle_row["id"],)
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
return [k for k in self.annotation_keys if k not in present]
|
tddcli/render.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Projections over the ledger: the friction log (§8.5) and metrics (§11.3).
|
|
2
|
+
|
|
3
|
+
The friction log is rendered, never composed by hand — every observable fact comes
|
|
4
|
+
from recorded events, and only judgement fields come from annotations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
from .ledger import Ledger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_list(items) -> str:
|
|
16
|
+
return ", ".join(f"`{i}`" for i in items) if items else "none"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def friction_log(ledger: Ledger, run) -> str:
|
|
20
|
+
contract = ledger.one(
|
|
21
|
+
"SELECT * FROM plan_contract WHERE id = ?", (run["plan_contract_id"],)
|
|
22
|
+
)
|
|
23
|
+
cycles = ledger.cycles(run["id"])
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
a = lines.append
|
|
26
|
+
|
|
27
|
+
a(f"# Implementation Friction Log: {contract['plan_path']}")
|
|
28
|
+
a("")
|
|
29
|
+
a(f"- Run: {run['id']}")
|
|
30
|
+
a(f"- Executor: {run['executor_model']} (source: {run['executor_source']})")
|
|
31
|
+
a(f"- Plan blob: `{contract['git_blob_sha']}` ({contract['status']})")
|
|
32
|
+
a(f"- Started: {run['started_at']} Ended: {run['ended_at'] or '—'}"
|
|
33
|
+
f" Outcome: {run['outcome'] or 'live'}")
|
|
34
|
+
baselines = ledger.baselines(run["id"])
|
|
35
|
+
a("- Baseline failures at start: "
|
|
36
|
+
+ (", ".join(f"{k}={len(v)}" for k, v in baselines.items()) or "none"))
|
|
37
|
+
a("")
|
|
38
|
+
|
|
39
|
+
declared = json.loads(contract["declared_cycles"])
|
|
40
|
+
delivered = {c["ordinal"] for c in cycles if c["phase"] == "CLOSED"}
|
|
41
|
+
skipped = {c["ordinal"] for c in cycles if c["phase"] == "SKIPPED"}
|
|
42
|
+
a("## Plan fidelity")
|
|
43
|
+
a("")
|
|
44
|
+
a(f"- Declared cycles: {len(declared)}")
|
|
45
|
+
a(f"- Delivered: {len(delivered)} Skipped: {len(skipped)}")
|
|
46
|
+
missing = sorted({d['n'] for d in declared} - delivered - skipped)
|
|
47
|
+
a(f"- Never reached: {missing or 'none'}")
|
|
48
|
+
interventions = ledger.all(
|
|
49
|
+
"SELECT * FROM human_intervention WHERE run_id = ?", (run["id"],)
|
|
50
|
+
)
|
|
51
|
+
a(f"- Human interventions: {len(interventions)}")
|
|
52
|
+
for i in interventions:
|
|
53
|
+
a(f" - {i['at']}: {i['note']}")
|
|
54
|
+
a("")
|
|
55
|
+
|
|
56
|
+
events = defaultdict(list)
|
|
57
|
+
for e in ledger.all("SELECT * FROM integrity_event WHERE run_id = ?", (run["id"],)):
|
|
58
|
+
events[e["cycle_id"]].append(e)
|
|
59
|
+
|
|
60
|
+
for cycle in reversed(cycles):
|
|
61
|
+
a(f"### Cycle {cycle['ordinal']}"
|
|
62
|
+
+ (f": {cycle['title']}" if cycle["title"] else "")
|
|
63
|
+
+ f" _({cycle['kind']})_")
|
|
64
|
+
if cycle["phase"] == "SKIPPED":
|
|
65
|
+
a(f"- **Skipped:** {cycle['skip_reason']}")
|
|
66
|
+
a("")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
targets = json.loads(cycle["target_tests"])
|
|
70
|
+
a(f"- **Target:** {_fmt_list(targets)}")
|
|
71
|
+
a(f"- **Projects:** {_fmt_list(json.loads(cycle['projects']))}")
|
|
72
|
+
|
|
73
|
+
by_phase = defaultdict(list)
|
|
74
|
+
for inv in ledger.invocations(cycle["id"]):
|
|
75
|
+
by_phase[inv["phase_at"]].append(inv)
|
|
76
|
+
attempts = {p: len(v) for p, v in by_phase.items()}
|
|
77
|
+
a(f"- **Suite runs by phase:** {attempts or 'none'}")
|
|
78
|
+
|
|
79
|
+
first_test_phase = by_phase.get("AWAITING_TEST") or by_phase.get("AWAITING_PIN")
|
|
80
|
+
if first_test_phase:
|
|
81
|
+
outcome = first_test_phase[0]["target_outcome"]
|
|
82
|
+
expected = "passed" if cycle["kind"] == "pin" else "failed"
|
|
83
|
+
verdict = "as expected" if outcome == expected else f"**{outcome}**"
|
|
84
|
+
a(f"- **First run outcome:** {outcome} ({verdict})")
|
|
85
|
+
|
|
86
|
+
sens = ledger.completed_sensitivity(cycle["id"])
|
|
87
|
+
if sens:
|
|
88
|
+
a("- **Sensitivity check:** verified, restore byte-identical")
|
|
89
|
+
if sens["observed_failure"]:
|
|
90
|
+
snippet = sens["observed_failure"].strip().splitlines()
|
|
91
|
+
a(f" - observed: `{snippet[0][:160] if snippet else ''}`")
|
|
92
|
+
|
|
93
|
+
commits = ledger.all(
|
|
94
|
+
"SELECT * FROM commit_record WHERE cycle_id = ? ORDER BY id", (cycle["id"],)
|
|
95
|
+
)
|
|
96
|
+
if commits:
|
|
97
|
+
a("- **Commits:**")
|
|
98
|
+
for c in commits:
|
|
99
|
+
files = json.loads(c["files"])
|
|
100
|
+
a(f" - `{c['sha'][:9]}` [{c['phase']}] {c['message']} ({len(files)} files)")
|
|
101
|
+
else:
|
|
102
|
+
a("- **Commits:** none")
|
|
103
|
+
|
|
104
|
+
for e in events.get(cycle["id"], []):
|
|
105
|
+
a(f"- **Event — {e['kind']}:** {e['detail'][:300]}")
|
|
106
|
+
|
|
107
|
+
annotations = ledger.all(
|
|
108
|
+
"SELECT * FROM annotation WHERE cycle_id = ? ORDER BY id", (cycle["id"],)
|
|
109
|
+
)
|
|
110
|
+
for ann in annotations:
|
|
111
|
+
a(f"- **{ann['key']}:** {ann['value']}")
|
|
112
|
+
a("")
|
|
113
|
+
|
|
114
|
+
blockers = ledger.all("SELECT * FROM blocker WHERE run_id = ?", (run["id"],))
|
|
115
|
+
if blockers:
|
|
116
|
+
a("## Blockers")
|
|
117
|
+
a("")
|
|
118
|
+
for b in blockers:
|
|
119
|
+
a(f"- **{b['kind']}** (cycle {b['cycle_id']}): {b['detail']}")
|
|
120
|
+
a("")
|
|
121
|
+
return "\n".join(lines) + "\n"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
PHASE_SHORT = {
|
|
125
|
+
"AWAITING_TEST": "writing test",
|
|
126
|
+
"AWAITING_PIN": "writing pin",
|
|
127
|
+
"AWAITING_IMPL": "implementing",
|
|
128
|
+
"AWAITING_REFACTOR": "refactoring",
|
|
129
|
+
"SENSITIVITY_REQUIRED": "sensitivity check",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _elapsed(start: str, end: str | None) -> str:
|
|
134
|
+
from datetime import datetime, timezone
|
|
135
|
+
|
|
136
|
+
began = datetime.fromisoformat(start)
|
|
137
|
+
finished = datetime.fromisoformat(end) if end else datetime.now(timezone.utc)
|
|
138
|
+
total = int((finished - began).total_seconds())
|
|
139
|
+
hours, rem = divmod(total, 3600)
|
|
140
|
+
mins, secs = divmod(rem, 60)
|
|
141
|
+
return f"{hours}h{mins:02d}m" if hours else f"{mins}m{secs:02d}s"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def progress(ledger: Ledger, run) -> str:
|
|
145
|
+
"""A human's view of where the run is. Never consulted by an agent."""
|
|
146
|
+
contract = ledger.one(
|
|
147
|
+
"SELECT * FROM plan_contract WHERE id = ?", (run["plan_contract_id"],)
|
|
148
|
+
)
|
|
149
|
+
declared = json.loads(contract["declared_cycles"])
|
|
150
|
+
rows = {c["ordinal"]: c for c in ledger.cycles(run["id"])}
|
|
151
|
+
|
|
152
|
+
out: list[str] = []
|
|
153
|
+
a = out.append
|
|
154
|
+
name = contract["plan_path"].rsplit("/", 1)[-1].removesuffix(".md")
|
|
155
|
+
state = run["outcome"] or "running"
|
|
156
|
+
a(f"{name} · run {run['id']} · {run['executor_model']} ({run['executor_source']}) · {state}")
|
|
157
|
+
a(f"{len(declared)} cycles · elapsed {_elapsed(run['started_at'], run['ended_at'])}")
|
|
158
|
+
a("")
|
|
159
|
+
|
|
160
|
+
closed = skipped = 0
|
|
161
|
+
for decl in declared:
|
|
162
|
+
ordinal = decl["n"]
|
|
163
|
+
row = rows.get(ordinal)
|
|
164
|
+
title = (decl.get("title") or "").strip()[:46]
|
|
165
|
+
kind = decl["kind"]
|
|
166
|
+
|
|
167
|
+
if row is None:
|
|
168
|
+
a(f" {ordinal:>2} {kind:<8} {title}")
|
|
169
|
+
continue
|
|
170
|
+
if row["phase"] == "SKIPPED":
|
|
171
|
+
skipped += 1
|
|
172
|
+
a(f" ⊘ {ordinal:>2} {kind:<8} {title}")
|
|
173
|
+
a(f" skipped — {row['skip_reason']}")
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
runs = len(ledger.invocations(row["id"]))
|
|
177
|
+
commits = ledger.all(
|
|
178
|
+
"SELECT phase, sha FROM commit_record WHERE cycle_id = ? ORDER BY id",
|
|
179
|
+
(row["id"],),
|
|
180
|
+
)
|
|
181
|
+
events = ledger.all(
|
|
182
|
+
"SELECT kind FROM integrity_event WHERE cycle_id = ?", (row["id"],)
|
|
183
|
+
)
|
|
184
|
+
detail = f"{runs} suite run{'' if runs == 1 else 's'}"
|
|
185
|
+
if commits:
|
|
186
|
+
detail += " " + " ".join(f"{c['phase']}:{c['sha'][:7]}" for c in commits)
|
|
187
|
+
|
|
188
|
+
if row["phase"] == "CLOSED":
|
|
189
|
+
closed += 1
|
|
190
|
+
a(f" ✓ {ordinal:>2} {kind:<8} {title}")
|
|
191
|
+
else:
|
|
192
|
+
phase = PHASE_SHORT.get(row["phase"], row["phase"])
|
|
193
|
+
a(f" ▸ {ordinal:>2} {kind:<8} {title}")
|
|
194
|
+
a(f" NOW: {phase}")
|
|
195
|
+
a(f" {detail}")
|
|
196
|
+
for e in events:
|
|
197
|
+
a(f" ! {e['kind']}")
|
|
198
|
+
|
|
199
|
+
a("")
|
|
200
|
+
total_events = ledger.all(
|
|
201
|
+
"SELECT kind, COUNT(*) n FROM integrity_event WHERE run_id = ? GROUP BY kind",
|
|
202
|
+
(run["id"],),
|
|
203
|
+
)
|
|
204
|
+
summary = f"{closed}/{len(declared)} closed"
|
|
205
|
+
if skipped:
|
|
206
|
+
summary += f" · {skipped} skipped"
|
|
207
|
+
summary += (
|
|
208
|
+
" · no integrity events" if not total_events
|
|
209
|
+
else " · " + ", ".join(f"{e['kind']}×{e['n']}" for e in total_events)
|
|
210
|
+
)
|
|
211
|
+
blockers = ledger.all("SELECT kind, detail FROM blocker WHERE run_id = ?", (run["id"],))
|
|
212
|
+
a(summary)
|
|
213
|
+
for b in blockers:
|
|
214
|
+
a(f"BLOCKED ({b['kind']}): {b['detail']}")
|
|
215
|
+
return "\n".join(out) + "\n"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def metrics(ledger: Ledger, worktree: str) -> dict:
|
|
219
|
+
runs = ledger.all(
|
|
220
|
+
"SELECT * FROM run WHERE worktree_path = ? ORDER BY id", (worktree,)
|
|
221
|
+
)
|
|
222
|
+
out = {"runs": [], "note": (
|
|
223
|
+
"Cross-plan aggregates are not comparable: cycle difficulty varies too much."
|
|
224
|
+
" Compare runs of the same contract only (R11.1)."
|
|
225
|
+
)}
|
|
226
|
+
for run in runs:
|
|
227
|
+
cycles = ledger.cycles(run["id"])
|
|
228
|
+
# Pin cycles pass on arrival by design; refactor cycles have no test at all.
|
|
229
|
+
standard = [c for c in cycles if c["kind"] not in ("pin", "refactor")]
|
|
230
|
+
red_violations = ledger.all(
|
|
231
|
+
"SELECT * FROM integrity_event WHERE run_id = ? AND kind = 'red_first_violation'",
|
|
232
|
+
(run["id"],),
|
|
233
|
+
)
|
|
234
|
+
impl_attempts = [
|
|
235
|
+
len(ledger.invocations(c["id"], "AWAITING_IMPL")) for c in cycles
|
|
236
|
+
]
|
|
237
|
+
blockers = ledger.all(
|
|
238
|
+
"SELECT kind, COUNT(*) n FROM blocker WHERE run_id = ? GROUP BY kind",
|
|
239
|
+
(run["id"],),
|
|
240
|
+
)
|
|
241
|
+
by_project = defaultdict(int)
|
|
242
|
+
for c in cycles:
|
|
243
|
+
for p in json.loads(c["projects"]):
|
|
244
|
+
by_project[p] += 1
|
|
245
|
+
out["runs"].append({
|
|
246
|
+
"run": run["id"],
|
|
247
|
+
"plan_contract": run["plan_contract_id"],
|
|
248
|
+
"executor": run["executor_model"],
|
|
249
|
+
"executor_source": run["executor_source"],
|
|
250
|
+
"outcome": run["outcome"],
|
|
251
|
+
"cycles_declared": len(json.loads(
|
|
252
|
+
ledger.one("SELECT declared_cycles FROM plan_contract WHERE id = ?",
|
|
253
|
+
(run["plan_contract_id"],))["declared_cycles"]
|
|
254
|
+
)),
|
|
255
|
+
"cycles_closed": sum(1 for c in cycles if c["phase"] == "CLOSED"),
|
|
256
|
+
"cycles_skipped": sum(1 for c in cycles if c["phase"] == "SKIPPED"),
|
|
257
|
+
# R6.2 — pin cycles pass on arrival by design and are excluded.
|
|
258
|
+
"red_first_violation_rate": (
|
|
259
|
+
round(len(red_violations) / len(standard), 3) if standard else None
|
|
260
|
+
),
|
|
261
|
+
"impl_attempts_total": sum(impl_attempts),
|
|
262
|
+
"impl_attempts_max": max(impl_attempts, default=0),
|
|
263
|
+
"cycles_by_project": dict(by_project),
|
|
264
|
+
"blockers": {b["kind"]: b["n"] for b in blockers},
|
|
265
|
+
"human_interventions": len(ledger.all(
|
|
266
|
+
"SELECT id FROM human_intervention WHERE run_id = ?", (run["id"],)
|
|
267
|
+
)),
|
|
268
|
+
"integrity_events": {
|
|
269
|
+
r["kind"]: r["n"] for r in ledger.all(
|
|
270
|
+
"SELECT kind, COUNT(*) n FROM integrity_event WHERE run_id = ?"
|
|
271
|
+
" GROUP BY kind", (run["id"],)
|
|
272
|
+
)
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
return out
|