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.
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