devlyn-cli 2.10.0 → 2.10.1

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.
@@ -38,6 +38,7 @@ import tempfile
38
38
 
39
39
  VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
40
40
  VALID_TRIGGERS = {"build_gate", "verify"}
41
+ SPAWN_TRIGGERS = VALID_TRIGGERS | {"plan"}
41
42
  PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
42
43
  LEGAL_TRANSITIONS = {
43
44
  "plan": {"probe_derive", "implement", "final_report"},
@@ -68,6 +69,14 @@ VALIDATION_EXECUTION_RE = re.compile(
68
69
  )
69
70
  SURFACE_SKIP_REASON = "auto_surface_close_claude_unavailable"
70
71
  SURFACE_RECOVERY_REASON = "surface_close_rolled_back_adjudication_malformed"
72
+ PLAN_MAX_DISPATCHES = 2
73
+ PLAN_SPAWN_RECEIPT_FIELDS = (
74
+ "round", "started_at", "triggered_by", "engine", "model_requested", "prompt_sha256",
75
+ )
76
+ PLAN_COMPLETION_RECEIPT_FIELDS = (
77
+ "completed_at", "duration_ms", "verdict", "model_effective",
78
+ )
79
+ PLAN_RECEIPT_FIELDS = PLAN_SPAWN_RECEIPT_FIELDS + PLAN_COMPLETION_RECEIPT_FIELDS
71
80
 
72
81
 
73
82
  def reject_json_constant(token: str) -> None:
@@ -242,6 +251,9 @@ def validate_authorized_surface(raw: str) -> list[str]:
242
251
 
243
252
 
244
253
  def path_matches_surface(path: str, surface: list[str]) -> bool:
254
+ parsed = pathlib.PurePosixPath(path)
255
+ if parsed.is_absolute() or ".." in parsed.parts:
256
+ return False
245
257
  for entry in surface:
246
258
  if entry.endswith("/**"):
247
259
  prefix = entry[:-3].rstrip("/")
@@ -252,6 +264,63 @@ def path_matches_surface(path: str, surface: list[str]) -> bool:
252
264
  return False
253
265
 
254
266
 
267
+ def worktree_file_exists(work: pathlib.Path, path: str) -> bool:
268
+ parsed = pathlib.PurePosixPath(path)
269
+ if parsed.is_absolute() or ".." in parsed.parts:
270
+ return False
271
+ try:
272
+ resolved = (work / path).resolve()
273
+ resolved.relative_to(work.resolve())
274
+ return resolved.is_file()
275
+ except (OSError, RuntimeError, ValueError):
276
+ return False
277
+
278
+
279
+ def worktree_path_exists(work: pathlib.Path, path: str) -> bool:
280
+ parsed = pathlib.PurePosixPath(path)
281
+ if parsed.is_absolute() or ".." in parsed.parts:
282
+ return False
283
+ try:
284
+ resolved = (work / path).resolve()
285
+ resolved.relative_to(work.resolve())
286
+ return resolved.exists()
287
+ except (OSError, RuntimeError, ValueError):
288
+ return False
289
+
290
+
291
+ def resolve_na_surface_citation(
292
+ work: pathlib.Path, raw: str, parsed_path: str, parsed_line: int | None,
293
+ surface: list[str],
294
+ ) -> tuple[str, int | None]:
295
+ def proven(path: str) -> bool:
296
+ if path in surface and not path.endswith("/**"):
297
+ return True
298
+ return worktree_file_exists(work, path) and any(
299
+ entry.endswith("/**") and path_matches_surface(path, [entry])
300
+ for entry in surface
301
+ )
302
+
303
+ if proven(raw):
304
+ return raw, None
305
+ if worktree_path_exists(work, raw) and not path_matches_surface(raw, surface):
306
+ raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
307
+ if (
308
+ worktree_path_exists(work, parsed_path)
309
+ and not path_matches_surface(parsed_path, surface)
310
+ ):
311
+ raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
312
+ for split in range(len(raw) - 1, -1, -1):
313
+ if raw[split] != ":" or not proven(raw[:split]):
314
+ continue
315
+ suffix = raw[split:]
316
+ if not re.fullmatch(r":[1-9][0-9]*", suffix):
317
+ raise SystemExit(
318
+ f"BLOCKED:surface-close-adjudication-malformed: citation {raw!r}"
319
+ )
320
+ return raw[:split], int(suffix[1:])
321
+ return parsed_path, parsed_line
322
+
323
+
255
324
  def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
256
325
  surface: list[str]) -> list[str]:
257
326
  tracked, untracked = surface_delta_paths(work, devlyn, state)
@@ -342,14 +411,20 @@ def validate_surface_adjudication(
342
411
  raw_line = match.group("fired_line") or match.group("na_line")
343
412
  line_number = int(raw_line) if raw_line is not None else None
344
413
  citation = path if line_number is None else f"{path}:{line_number}"
414
+ if status == "N/A":
415
+ path, line_number = resolve_na_surface_citation(
416
+ work, citation, path, line_number, surface,
417
+ )
418
+ citation = path if line_number is None else f"{path}:{line_number}"
345
419
  if not path_matches_surface(path, surface):
346
420
  raise SystemExit(
347
421
  f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
348
422
  )
349
- cited = work / path
350
423
  try:
424
+ cited = (work / path).resolve()
425
+ cited.relative_to(work.resolve())
351
426
  cited_lines = cited.read_text(encoding="utf-8").splitlines()
352
- except (OSError, UnicodeError) as exc:
427
+ except (OSError, RuntimeError, UnicodeError, ValueError) as exc:
353
428
  raise SystemExit(
354
429
  f"BLOCKED:surface-close-adjudication-citation-missing: {citation}: {exc}"
355
430
  ) from exc
@@ -488,6 +563,60 @@ def require_surface_adjudication_malformed(
488
563
  )
489
564
 
490
565
 
566
+ CLAUDE_USAGE_COUNTERS = (
567
+ ("input_tokens", "inputTokens"),
568
+ ("output_tokens", "outputTokens"),
569
+ ("cache_read_input_tokens", "cacheReadInputTokens"),
570
+ ("cache_creation_input_tokens", "cacheCreationInputTokens"),
571
+ )
572
+
573
+
574
+ def select_claude_primary_model(evidence: dict) -> str:
575
+ model_usage = evidence.get("modelUsage")
576
+ models = (
577
+ [model for model in model_usage if isinstance(model, str) and model]
578
+ if isinstance(model_usage, dict)
579
+ else []
580
+ )
581
+ if len(models) == 1:
582
+ return models[0]
583
+ if not models:
584
+ raise ValueError("Claude JSON wrapper has no effective-model evidence")
585
+
586
+ usage = evidence.get("usage")
587
+ if not isinstance(usage, dict):
588
+ raise ValueError("multi-entry Claude JSON wrapper has malformed top-level usage")
589
+
590
+ def counters(container: dict, fields: tuple[str, ...]) -> tuple[int, ...]:
591
+ values = tuple(container.get(field) for field in fields)
592
+ if any(
593
+ not isinstance(value, int)
594
+ or isinstance(value, bool)
595
+ or value < 0
596
+ for value in values
597
+ ):
598
+ raise ValueError("multi-entry Claude JSON wrapper has malformed usage counters")
599
+ return values
600
+
601
+ primary_usage = counters(
602
+ usage, tuple(field[0] for field in CLAUDE_USAGE_COUNTERS)
603
+ )
604
+ matches = []
605
+ for model in models:
606
+ entry = model_usage[model]
607
+ if not isinstance(entry, dict):
608
+ raise ValueError("multi-entry Claude JSON wrapper has malformed modelUsage entry")
609
+ if counters(
610
+ entry, tuple(field[1] for field in CLAUDE_USAGE_COUNTERS)
611
+ ) == primary_usage:
612
+ matches.append(model)
613
+ if len(matches) == 1:
614
+ return matches[0]
615
+ raise ValueError(
616
+ "multi-entry Claude JSON wrapper has no unique primary usage match"
617
+ )
618
+
619
+
491
620
  def parse_effective_model(session_log: pathlib.Path) -> str:
492
621
  try:
493
622
  text = session_log.read_text(encoding="utf-8")
@@ -520,9 +649,8 @@ def parse_effective_model(session_log: pathlib.Path) -> str:
520
649
  evidence = loads_strict_json(text)
521
650
  except ValueError:
522
651
  evidence = None
523
- model_usage = evidence.get("modelUsage") if isinstance(evidence, dict) else None
524
- if isinstance(model_usage, dict):
525
- models.update(model for model in model_usage if isinstance(model, str) and model)
652
+ if isinstance(evidence, dict) and isinstance(evidence.get("modelUsage"), dict):
653
+ return select_claude_primary_model(evidence)
526
654
 
527
655
  if len(models) == 1:
528
656
  return next(iter(models))
@@ -1134,18 +1262,21 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
1134
1262
  (devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
1135
1263
 
1136
1264
 
1137
- def append_phase_history(entry: dict) -> None:
1265
+ def is_plan_dispatch_receipt(entry: dict) -> bool:
1266
+ return all(field in entry for field in PLAN_RECEIPT_FIELDS)
1267
+
1268
+
1269
+ def append_phase_history(entry: dict, phase: str) -> None:
1138
1270
  if entry.get("started_at") is None:
1139
1271
  return
1140
1272
  history = entry.get("history")
1141
1273
  if not isinstance(history, list):
1142
1274
  history = []
1143
- history.append({
1144
- "started_at": entry.get("started_at"),
1145
- "verdict": entry.get("verdict"),
1146
- "completed_at": entry.get("completed_at"),
1147
- "duration_ms": entry.get("duration_ms"),
1148
- })
1275
+ fields = (
1276
+ PLAN_RECEIPT_FIELDS if phase == "plan" and is_plan_dispatch_receipt(entry)
1277
+ else ("started_at", "verdict", "completed_at", "duration_ms")
1278
+ )
1279
+ history.append({field: entry.get(field) for field in fields})
1149
1280
  entry["history"] = history
1150
1281
 
1151
1282
 
@@ -1156,12 +1287,33 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1156
1287
  untracked_before: list[str] | None = None) -> None:
1157
1288
  # Merge, don't replace: a phase-gated large run's `exec` progress (or any
1158
1289
  # other field this script doesn't own) survives a fix-loop respawn.
1290
+ phases_value = state.get("phases")
1291
+ entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
1292
+ if phase == "plan":
1293
+ history = entry.get("history", []) if isinstance(entry, dict) else []
1294
+ if not isinstance(history, list):
1295
+ raise SystemExit("error: phases.plan.history must be an array")
1296
+ dispatch_count = len(history) + int(
1297
+ isinstance(entry, dict) and entry.get("started_at") is not None
1298
+ )
1299
+ if dispatch_count >= PLAN_MAX_DISPATCHES:
1300
+ raise SystemExit("BLOCKED:plan-respawn-exhausted")
1301
+ if round_ != dispatch_count:
1302
+ raise SystemExit(
1303
+ f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
1304
+ )
1305
+ if not isinstance(engine, str) or not engine:
1306
+ raise SystemExit("error: phases.plan spawn requires --engine")
1307
+ if not isinstance(model, str) or not model:
1308
+ raise SystemExit("error: phases.plan spawn requires --model")
1309
+ if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
1310
+ raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
1311
+ if dispatch_count > 0 and triggered_by is None:
1312
+ raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
1159
1313
  if phase == "surface_close" and engine != "claude":
1160
1314
  raise SystemExit("error: phases.surface_close spawn requires --engine claude")
1161
1315
  if phase == "surface_close" and not model:
1162
1316
  raise SystemExit("error: phases.surface_close spawn requires --model")
1163
- phases = state.setdefault("phases", {})
1164
- entry = phases.get(phase)
1165
1317
  if phase == "surface_close" and isinstance(entry, dict) and (
1166
1318
  entry.get("started_at") is not None or entry.get("skipped_reason") is not None
1167
1319
  ):
@@ -1175,8 +1327,12 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1175
1327
  raise SystemExit("error: phases.surface_close spawn requires --prompt-sha256")
1176
1328
  if untracked_before is None:
1177
1329
  raise SystemExit("error: phases.surface_close spawn requires --untracked-before-json")
1330
+ elif phase == "plan":
1331
+ if input_patch_sha256 is not None or untracked_before is not None:
1332
+ raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
1178
1333
  elif input_patch_sha256 is not None or prompt_sha256 is not None or untracked_before is not None:
1179
1334
  raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
1335
+ phases = state.setdefault("phases", {})
1180
1336
  if not isinstance(entry, dict):
1181
1337
  entry = {}
1182
1338
  phases[phase] = entry
@@ -1184,7 +1340,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1184
1340
  raise SystemExit(
1185
1341
  f"error: phases.{phase} has an open span — complete it before respawn"
1186
1342
  )
1187
- append_phase_history(entry)
1343
+ append_phase_history(entry, phase)
1188
1344
  entry["started_at"] = now_iso()
1189
1345
  entry["completed_at"] = None
1190
1346
  entry["duration_ms"] = None
@@ -1205,9 +1361,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1205
1361
  entry["model_effective"] = None
1206
1362
  if pre_sha is not None:
1207
1363
  entry["pre_sha"] = pre_sha
1364
+ if phase in {"plan", "surface_close"}:
1365
+ entry["prompt_sha256"] = prompt_sha256
1208
1366
  if phase == "surface_close":
1209
1367
  entry["input_patch_sha256"] = input_patch_sha256
1210
- entry["prompt_sha256"] = prompt_sha256
1211
1368
  entry["untracked_before"] = untracked_before
1212
1369
 
1213
1370
 
@@ -1243,6 +1400,16 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1243
1400
  entry = phases.get(phase)
1244
1401
  if not isinstance(entry, dict) or not entry.get("started_at"):
1245
1402
  raise SystemExit(f"error: phases.{phase} was never spawned (no started_at) — cannot complete")
1403
+ if phase == "plan" and entry.get("prompt_sha256") is not None:
1404
+ missing = [field for field in PLAN_SPAWN_RECEIPT_FIELDS if field not in entry]
1405
+ if missing:
1406
+ raise SystemExit(
1407
+ "error: phases.plan spawn receipt is incomplete: " + ",".join(missing)
1408
+ )
1409
+ if engine is not None and engine != entry["engine"]:
1410
+ raise SystemExit("error: phases.plan completion cannot replace spawn engine")
1411
+ if model is not None and model != entry["model_requested"]:
1412
+ raise SystemExit("error: phases.plan completion cannot replace requested model")
1246
1413
  started = parse_iso(entry["started_at"])
1247
1414
  now = now_ms()
1248
1415
  entry["completed_at"] = now_iso(now)
@@ -1268,11 +1435,11 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1268
1435
  artifacts["findings_file"] = findings_file
1269
1436
  if log_file is not None:
1270
1437
  artifacts["log_file"] = log_file
1271
- if engine is not None:
1438
+ if engine is not None and phase != "plan":
1272
1439
  entry["engine"] = engine
1273
- if model is not None:
1440
+ if model is not None and phase != "plan":
1274
1441
  entry["model_requested"] = model
1275
- else:
1442
+ elif phase != "plan":
1276
1443
  entry.setdefault("model_requested", None)
1277
1444
  entry.pop("model", None)
1278
1445
 
@@ -1385,6 +1552,194 @@ def self_test() -> int:
1385
1552
  state_path = devlyn / "pipeline.state.json"
1386
1553
  write_state(state_path, {"phases": {}})
1387
1554
 
1555
+ # Iter-0089 P-0089-1/2/6: PLAN authorization is a CLI-level,
1556
+ # state-derived ledger contract. Rejections must leave the actual
1557
+ # state file byte-identical, not merely preserve an in-memory copy.
1558
+ script = str(pathlib.Path(__file__).resolve())
1559
+ digest0 = hashlib.sha256(b"plan prompt round 0").hexdigest()
1560
+ digest1 = hashlib.sha256(b"plan prompt round 1").hexdigest()
1561
+
1562
+ def plan_cli(*event_args: str) -> subprocess.CompletedProcess[str]:
1563
+ return subprocess.run(
1564
+ [
1565
+ sys.executable, script, "--devlyn-dir", str(devlyn),
1566
+ "--phase", "plan", *event_args,
1567
+ ],
1568
+ capture_output=True, text=True, check=False,
1569
+ )
1570
+
1571
+ def assert_plan_rejected_unchanged(
1572
+ expected_stderr: str, *event_args: str,
1573
+ ) -> None:
1574
+ before = state_path.read_bytes()
1575
+ before_hash = hashlib.sha256(before).hexdigest()
1576
+ result = plan_cli(*event_args)
1577
+ assert result.returncode != 0, result.stdout
1578
+ assert result.stderr.strip() == expected_stderr, result.stderr
1579
+ after = state_path.read_bytes()
1580
+ assert after == before
1581
+ assert hashlib.sha256(after).hexdigest() == before_hash
1582
+
1583
+ required_spawn = (
1584
+ "spawn", "--round", "0", "--engine", "claude",
1585
+ "--model", "plan-test-model", "--prompt-sha256", digest0,
1586
+ )
1587
+ assert_plan_rejected_unchanged(
1588
+ "error: phases.plan spawn requires --engine",
1589
+ "spawn", "--round", "0", "--model", "plan-test-model",
1590
+ "--prompt-sha256", digest0,
1591
+ )
1592
+ assert_plan_rejected_unchanged(
1593
+ "error: phases.plan spawn requires --model",
1594
+ "spawn", "--round", "0", "--engine", "claude",
1595
+ "--prompt-sha256", digest0,
1596
+ )
1597
+ assert_plan_rejected_unchanged(
1598
+ "error: phases.plan spawn requires --prompt-sha256",
1599
+ "spawn", "--round", "0", "--engine", "claude",
1600
+ "--model", "plan-test-model",
1601
+ )
1602
+ assert_plan_rejected_unchanged(
1603
+ "error: phases.plan spawn requires --prompt-sha256",
1604
+ "spawn", "--round", "0", "--engine", "claude",
1605
+ "--model", "plan-test-model", "--prompt-sha256", "ABC",
1606
+ )
1607
+ assert_plan_rejected_unchanged(
1608
+ "BLOCKED:plan-round-nonmonotonic: expected=0 supplied=1",
1609
+ "spawn", "--round", "1", "--engine", "claude",
1610
+ "--model", "plan-test-model", "--prompt-sha256", digest0,
1611
+ )
1612
+
1613
+ result = plan_cli(*required_spawn)
1614
+ assert result.returncode == 0, result.stderr
1615
+ plan0 = read_state(state_path)["phases"]["plan"]
1616
+ assert {field: plan0[field] for field in PLAN_SPAWN_RECEIPT_FIELDS} == {
1617
+ "round": 0,
1618
+ "started_at": plan0["started_at"],
1619
+ "triggered_by": None,
1620
+ "engine": "claude",
1621
+ "model_requested": "plan-test-model",
1622
+ "prompt_sha256": digest0,
1623
+ }
1624
+ result = plan_cli("complete", "--verdict", "NEEDS_WORK")
1625
+ assert result.returncode == 0, result.stderr
1626
+ completed0 = read_state(state_path)["phases"]["plan"]
1627
+ assert all(field in completed0 for field in PLAN_RECEIPT_FIELDS)
1628
+ assert completed0["model_effective"] is None
1629
+ assert completed0["completed_at"] is not None
1630
+ assert completed0["duration_ms"] >= 0
1631
+ assert_plan_rejected_unchanged(
1632
+ "BLOCKED:plan-round-nonmonotonic: expected=1 supplied=0",
1633
+ "spawn", "--round", "0", "--triggered-by", "plan",
1634
+ "--engine", "claude", "--model", "plan-test-model",
1635
+ "--prompt-sha256", digest1,
1636
+ )
1637
+ assert_plan_rejected_unchanged(
1638
+ "BLOCKED:plan-round-nonmonotonic: expected=1 supplied=2",
1639
+ "spawn", "--round", "2", "--triggered-by", "plan",
1640
+ "--engine", "claude", "--model", "plan-test-model",
1641
+ "--prompt-sha256", digest1,
1642
+ )
1643
+ assert_plan_rejected_unchanged(
1644
+ "error: phases.plan re-spawn requires --triggered-by",
1645
+ "spawn", "--round", "1", "--engine", "claude",
1646
+ "--model", "plan-test-model", "--prompt-sha256", digest1,
1647
+ )
1648
+ result = plan_cli(
1649
+ "spawn", "--round", "1", "--triggered-by", "plan",
1650
+ "--engine", "claude", "--model", "plan-test-model",
1651
+ "--prompt-sha256", digest1,
1652
+ )
1653
+ assert result.returncode == 0, result.stderr
1654
+ plan1 = read_state(state_path)["phases"]["plan"]
1655
+ assert len(plan1["history"]) == 1
1656
+ assert set(plan1["history"][0]) == set(PLAN_RECEIPT_FIELDS)
1657
+ assert plan1["history"][0]["prompt_sha256"] == digest0
1658
+ assert plan1["prompt_sha256"] == digest1
1659
+ guard_error = (
1660
+ "error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
1661
+ "--phase plan transition --verdict <verdict> --next-phase <phase>"
1662
+ )
1663
+ assert_plan_rejected_unchanged(
1664
+ guard_error, "complete", "--verdict", "PASS",
1665
+ )
1666
+ assert_plan_rejected_unchanged(
1667
+ guard_error, "complete", "--verdict", "PASS_WITH_ISSUES",
1668
+ )
1669
+ result = plan_cli(
1670
+ "transition", "--verdict", "PASS", "--next-phase", "implement",
1671
+ "--next-round", "0", "--next-engine", "claude",
1672
+ )
1673
+ assert result.returncode == 0, result.stderr
1674
+ for supplied_round in (0, 1, 2):
1675
+ assert_plan_rejected_unchanged(
1676
+ "BLOCKED:plan-respawn-exhausted",
1677
+ "spawn", "--round", str(supplied_round),
1678
+ "--triggered-by", "plan", "--engine", "claude",
1679
+ "--model", "plan-test-model", "--prompt-sha256", digest1,
1680
+ )
1681
+ old_receipt = {
1682
+ "started_at": "2026-01-01T00:00:00.000Z",
1683
+ "verdict": "PASS",
1684
+ "completed_at": "2026-01-01T00:00:01.000Z",
1685
+ "duration_ms": 1000,
1686
+ }
1687
+ assert not is_plan_dispatch_receipt(old_receipt)
1688
+ assert is_plan_dispatch_receipt(read_state(state_path)["phases"]["plan"])
1689
+ write_state(state_path, {
1690
+ "phases": {
1691
+ "plan": {
1692
+ "started_at": "2026-01-01T00:00:00.000Z",
1693
+ "completed_at": None,
1694
+ "duration_ms": None,
1695
+ "round": 0,
1696
+ "triggered_by": None,
1697
+ "verdict": None,
1698
+ },
1699
+ "implement": None,
1700
+ }
1701
+ })
1702
+ result = plan_cli("complete", "--verdict", "BLOCKED")
1703
+ assert result.returncode == 0, result.stderr
1704
+ assert read_state(state_path)["phases"]["plan"]["verdict"] == "BLOCKED"
1705
+ write_state(state_path, {
1706
+ "phases": {
1707
+ "plan": {
1708
+ "started_at": "2026-01-01T00:00:00.000Z",
1709
+ "completed_at": None,
1710
+ "duration_ms": None,
1711
+ "round": 0,
1712
+ "triggered_by": None,
1713
+ "verdict": None,
1714
+ },
1715
+ "implement": None,
1716
+ }
1717
+ })
1718
+ result = plan_cli(
1719
+ "complete", "--verdict", "PASS", "--engine-session-log",
1720
+ str(devlyn / "missing-session.jsonl"),
1721
+ )
1722
+ assert result.returncode == 1, result.stderr
1723
+ assert "BLOCKED:model-attestation-failed" in result.stderr
1724
+ assert guard_error not in result.stderr
1725
+ attestation_blocked = read_state(state_path)["phases"]["plan"]
1726
+ assert attestation_blocked["verdict"] == "BLOCKED"
1727
+ assert attestation_blocked["completed_at"] is not None
1728
+ assert attestation_blocked["model_effective"] is None
1729
+ legacy_state = {"phases": {"plan": old_receipt | {"round": 0}}}
1730
+ write_state(state_path, legacy_state)
1731
+ result = plan_cli(
1732
+ "spawn", "--round", "1", "--triggered-by", "plan",
1733
+ "--engine", "claude", "--model", "plan-test-model",
1734
+ "--prompt-sha256", digest1,
1735
+ )
1736
+ assert result.returncode == 0, result.stderr
1737
+ archived_legacy = read_state(state_path)["phases"]["plan"]["history"][0]
1738
+ assert archived_legacy == old_receipt
1739
+ print("PASS iter-0089 PLAN ledger: P-0089-1/2/6")
1740
+
1741
+ write_state(state_path, {"phases": {}})
1742
+
1388
1743
  # Round 0: spawn -> complete.
1389
1744
  state = read_state(state_path)
1390
1745
  do_spawn(state, "implement", 0, None, None, "claude", None)
@@ -1657,7 +2012,10 @@ def self_test() -> int:
1657
2012
  # flow does.
1658
2013
  write_state(state_path, {"phases": {}})
1659
2014
  state = read_state(state_path)
1660
- do_spawn(state, "plan", 0, None, None, None, None)
2015
+ do_spawn(
2016
+ state, "plan", 0, None, None, "claude", "plan-test-model",
2017
+ prompt_sha256=digest0,
2018
+ )
1661
2019
  write_state(state_path, state)
1662
2020
  state = read_state(state_path)
1663
2021
  try:
@@ -1782,14 +2140,24 @@ def self_test() -> int:
1782
2140
  subprocess.run(["git", "config", "user.name", "self-test"], cwd=work, check=True)
1783
2141
  (work / "allowed.txt").write_text("base\n", encoding="utf-8")
1784
2142
  (work / "blocked.txt").write_text("base\n", encoding="utf-8")
2143
+ (work / "blocked:literal.txt:1").write_text("base\n", encoding="utf-8")
2144
+ (work / "blocked:raw.txt").write_text("base\n", encoding="utf-8")
2145
+ (work / "branchbase").write_text("line\n" * 10, encoding="utf-8")
2146
+ (work / "branchbase:7").write_text("base\n", encoding="utf-8")
2147
+ (work / "branchbase:dir").mkdir()
2148
+ (work / "branchbase:dir" / "marker").write_text("base\n", encoding="utf-8")
2149
+ (work / "exact:1").write_text("base\n", encoding="utf-8")
1785
2150
  (work / "schedule").mkdir()
1786
2151
  (work / "schedule" / "__init__.py").write_text("line\n" * 700, encoding="utf-8")
1787
2152
  (work / "test_schedule.py").write_text("line\n", encoding="utf-8")
1788
2153
  (work / "tests").mkdir()
1789
2154
  (work / "tests" / "cli.test.js").write_text("line\n" * 170, encoding="utf-8")
2155
+ (work / "tests" / "literal:1-3").write_text("line\n", encoding="utf-8")
1790
2156
  subprocess.run(
1791
- ["git", "add", "--", "allowed.txt", "blocked.txt", "schedule/__init__.py",
1792
- "test_schedule.py", "tests/cli.test.js"],
2157
+ ["git", "add", "--", "allowed.txt", "blocked.txt", "blocked:literal.txt:1",
2158
+ "blocked:raw.txt", "branchbase", "branchbase:7", "branchbase:dir/marker",
2159
+ "exact:1", "schedule/__init__.py", "test_schedule.py", "tests/cli.test.js",
2160
+ "tests/literal:1-3"],
1793
2161
  cwd=work, check=True,
1794
2162
  )
1795
2163
  subprocess.run(["git", "commit", "-qm", "base"], cwd=work, check=True)
@@ -1846,8 +2214,53 @@ def self_test() -> int:
1846
2214
  validate_surface_prompt(work_devlyn, surface_state)
1847
2215
  ensure_surface_clean_baseline(work, work_devlyn, surface_state)
1848
2216
  surface = validate_authorized_surface(
1849
- '["allowed.txt", "schedule/**", "test_schedule.py", "tests/**"]'
2217
+ '["allowed.txt", "branchbase", "exact-link", "exact:1", "schedule/**", '
2218
+ '"test_schedule.py", "tests/**", "x.ts"]'
1850
2219
  )
2220
+ outside = devlyn / "outside.txt"
2221
+ outside.write_text("outside\n", encoding="utf-8")
2222
+ (work / "outside-link").symlink_to(outside)
2223
+ assert worktree_file_exists(work, "allowed.txt")
2224
+ assert not worktree_file_exists(work, str(outside))
2225
+ assert not worktree_file_exists(work, "../outside.txt")
2226
+ assert not worktree_file_exists(work, "outside-link")
2227
+ assert worktree_path_exists(work, "branchbase:dir")
2228
+ assert not worktree_path_exists(work, str(outside))
2229
+ assert not worktree_path_exists(work, "../outside.txt")
2230
+ assert not worktree_path_exists(work, "outside-link")
2231
+ (work / "outside-link").unlink()
2232
+ for link, target in (
2233
+ ("exact-link", "allowed.txt"),
2234
+ ("tests/inside-link", "cli.test.js"),
2235
+ ):
2236
+ (work / link).symlink_to(target)
2237
+ output = work_devlyn / "surface-close.stdout"
2238
+ output.write_text(
2239
+ f"UVR-STALE: N/A {link} — contained symlink\n"
2240
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2241
+ encoding="utf-8",
2242
+ )
2243
+ validate_surface_adjudication(
2244
+ work, work_devlyn, surface_state, surface,
2245
+ )
2246
+ (work / link).unlink()
2247
+ for link in ("exact-link", "tests/outside-link"):
2248
+ (work / link).symlink_to(outside)
2249
+ output = work_devlyn / "surface-close.stdout"
2250
+ output.write_text(
2251
+ f"UVR-STALE: N/A {link} — escaping symlink\n"
2252
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2253
+ encoding="utf-8",
2254
+ )
2255
+ try:
2256
+ validate_surface_adjudication(
2257
+ work, work_devlyn, surface_state, surface,
2258
+ )
2259
+ except SystemExit as exc:
2260
+ assert "citation-missing" in str(exc), (link, exc)
2261
+ else:
2262
+ raise AssertionError(f"SURFACE_CLOSE followed escaping symlink: {link}")
2263
+ (work / link).unlink()
1851
2264
  entry = surface_entry(surface_state)
1852
2265
  assert entry["prompt_sha256"] == file_sha256(prompt)
1853
2266
  assert entry["model_requested"] == "sonnet"
@@ -1873,6 +2286,18 @@ def self_test() -> int:
1873
2286
  encoding="utf-8",
1874
2287
  )
1875
2288
  validate_surface_adjudication(work, work_devlyn, surface_state, surface)
2289
+ output.write_text(
2290
+ "UVR-STALE: N/A exact:1 — exact colon path is unchanged\n"
2291
+ "PATH-TEST: N/A exact:1:1 — exact colon path line is covered\nPASS\n",
2292
+ encoding="utf-8",
2293
+ )
2294
+ validate_surface_adjudication(work, work_devlyn, surface_state, surface)
2295
+ output.write_text(
2296
+ "UVR-STALE: N/A tests/literal:1-3 — literal range-shaped path is unchanged\n"
2297
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2298
+ encoding="utf-8",
2299
+ )
2300
+ validate_surface_adjudication(work, work_devlyn, surface_state, surface)
1876
2301
 
1877
2302
  output.write_text(
1878
2303
  "PATH-TEST review confirms every success/failure path the goal specifies (chaining in both positions, exact-nth-run cancellation via both `run_pending()` and `run_all()`, first-limit-wins with `.until()` in both directions, `next_run`/`idle_seconds` reflecting removal, validation errors, repeated-call override, per-job independence) is already covered by the added tests — no gap found.\n\n"
@@ -1924,6 +2349,81 @@ def self_test() -> int:
1924
2349
  "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
1925
2350
  "citation-missing",
1926
2351
  ),
2352
+ (
2353
+ "UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
2354
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2355
+ "adjudication-malformed",
2356
+ ),
2357
+ (
2358
+ "UVR-STALE: N/A allowed.txt:1, allowed.txt:1 — production list citation\n"
2359
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2360
+ "adjudication-malformed",
2361
+ ),
2362
+ (
2363
+ "UVR-STALE: N/A tests/cli.test.js:1-3 — glob range citation\n"
2364
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2365
+ "adjudication-malformed",
2366
+ ),
2367
+ (
2368
+ "UVR-STALE: N/A x.ts:12:34 — doubled line citation\n"
2369
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2370
+ "adjudication-malformed",
2371
+ ),
2372
+ (
2373
+ "UVR-STALE: N/A allowed.txt:0 — zero line citation\n"
2374
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2375
+ "adjudication-malformed",
2376
+ ),
2377
+ (
2378
+ "UVR-STALE: N/A allowed.txt:012 — leading-zero line citation\n"
2379
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2380
+ "adjudication-malformed",
2381
+ ),
2382
+ (
2383
+ "UVR-STALE: N/A allowed.txt: — empty line citation\n"
2384
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2385
+ "adjudication-malformed",
2386
+ ),
2387
+ (
2388
+ "UVR-STALE: N/A blocked.txt:1 — blocked parsed path\n"
2389
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2390
+ "out-of-surface",
2391
+ ),
2392
+ (
2393
+ "UVR-STALE: N/A blocked.txt:1-3 — blocked range citation\n"
2394
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2395
+ "out-of-surface",
2396
+ ),
2397
+ (
2398
+ "UVR-STALE: N/A blocked:literal.txt:1 — blocked raw colon path\n"
2399
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2400
+ "out-of-surface",
2401
+ ),
2402
+ (
2403
+ "UVR-STALE: N/A blocked:raw.txt:1 — blocked parsed colon path\n"
2404
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2405
+ "out-of-surface",
2406
+ ),
2407
+ (
2408
+ "UVR-STALE: N/A branchbase:7 — existing unauthorized raw path\n"
2409
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2410
+ "out-of-surface",
2411
+ ),
2412
+ (
2413
+ "UVR-STALE: N/A branchbase:dir:1 — existing unauthorized parsed directory\n"
2414
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2415
+ "out-of-surface",
2416
+ ),
2417
+ (
2418
+ "UVR-STALE: N/A tests/../blocked.txt:1 — wildcard traversal\n"
2419
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2420
+ "out-of-surface",
2421
+ ),
2422
+ (
2423
+ "UVR-STALE: N/A tests/ghost.txt:1 — nonexistent glob descendant\n"
2424
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2425
+ "citation-missing",
2426
+ ),
1927
2427
  )
1928
2428
  for raw_output, marker in rejected_outputs:
1929
2429
  output.write_text(raw_output, encoding="utf-8")
@@ -1934,6 +2434,14 @@ def self_test() -> int:
1934
2434
  else:
1935
2435
  raise AssertionError(f"SURFACE_CLOSE accepted invalid adjudication: {marker}")
1936
2436
 
2437
+ output.write_text(
2438
+ "UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
2439
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
2440
+ encoding="utf-8",
2441
+ )
2442
+ require_surface_adjudication_malformed(
2443
+ work, work_devlyn, surface_state, surface,
2444
+ )
1937
2445
  output.write_text(
1938
2446
  "UVR-STALE: N/A allowed.txt:1 — no stale interface text\n"
1939
2447
  "PATH-TEST: N/A allowed.txt:1 — requested path already covered\nPASS\n",
@@ -2069,10 +2577,162 @@ def self_test() -> int:
2069
2577
  }) + "\n", encoding="utf-8")
2070
2578
  assert parse_effective_model(rollout_log) == "gpt-5.6-terra"
2071
2579
  claude_log = devlyn / "claude-result.json"
2072
- claude_log.write_text(json.dumps({
2073
- "modelUsage": {"claude-alpha-1": {"inputTokens": 1}},
2074
- }) + "\n", encoding="utf-8")
2075
- assert parse_effective_model(claude_log) == "claude-alpha-1"
2580
+
2581
+ def claude_wrapper(primary_usage, entries):
2582
+ wrapper = {"modelUsage": {}}
2583
+ if primary_usage is not None:
2584
+ wrapper["usage"] = dict(zip((
2585
+ "input_tokens", "output_tokens", "cache_read_input_tokens",
2586
+ "cache_creation_input_tokens",
2587
+ ), primary_usage))
2588
+ for model, entry_usage, metadata in entries:
2589
+ entry = dict(zip((
2590
+ "inputTokens", "outputTokens", "cacheReadInputTokens",
2591
+ "cacheCreationInputTokens",
2592
+ ), entry_usage))
2593
+ entry.update(metadata)
2594
+ wrapper["modelUsage"][model] = entry
2595
+ return wrapper
2596
+
2597
+ oversized_counter = 10 ** 400
2598
+ claude_selector_rows = (
2599
+ (
2600
+ "singleton",
2601
+ {"modelUsage": {"claude-alpha-1": {"inputTokens": 1}}},
2602
+ "claude-alpha-1",
2603
+ ),
2604
+ (
2605
+ "opus-primary",
2606
+ claude_wrapper(
2607
+ (2854, 9927, 1095180, 73889),
2608
+ (
2609
+ ("claude-" "haiku-4-5-20251001", (4106, 14, 0, 0), {
2610
+ "costUSD": 0.004176,
2611
+ }),
2612
+ ("claude-" "opus-5[1m]", (2854, 9927, 1095180, 73889), {
2613
+ "costUSD": 1.5489249999999999,
2614
+ "canonicalModel": "claude-" "opus-5",
2615
+ }),
2616
+ ),
2617
+ ),
2618
+ "claude-" "opus-5[1m]",
2619
+ ),
2620
+ (
2621
+ "fable-primary",
2622
+ claude_wrapper(
2623
+ (8068, 26740, 403950, 76656),
2624
+ (
2625
+ ("claude-" "haiku-4-5-20251001", (2246, 22, 0, 0), {
2626
+ "costUSD": 0.002356,
2627
+ }),
2628
+ ("claude-" "fable-5", (8068, 26740, 403950, 76656), {
2629
+ "costUSD": 3.35475,
2630
+ "canonicalModel": "claude-" "fable-5",
2631
+ }),
2632
+ ),
2633
+ ),
2634
+ "claude-" "fable-5",
2635
+ ),
2636
+ (
2637
+ "rank-confound",
2638
+ claude_wrapper(
2639
+ (3, 5, 7, 11),
2640
+ (
2641
+ ("larger-auxiliary", (300000, 500000, 700000, 1100000), {
2642
+ "costUSD": 999.0,
2643
+ }),
2644
+ ("expected-primary", (3, 5, 7, 11), {"costUSD": 0.01}),
2645
+ ),
2646
+ ),
2647
+ "expected-primary",
2648
+ ),
2649
+ (
2650
+ "oversized-integer",
2651
+ claude_wrapper(
2652
+ (oversized_counter, 5, 7, 11),
2653
+ (
2654
+ ("oversized-primary", (oversized_counter, 5, 7, 11), {}),
2655
+ ("auxiliary", (3, 5, 7, 11), {}),
2656
+ ),
2657
+ ),
2658
+ "oversized-primary",
2659
+ ),
2660
+ (
2661
+ "zero-match",
2662
+ claude_wrapper(
2663
+ (1, 2, 3, 4),
2664
+ (("model-a", (10, 2, 3, 4), {}), ("model-b", (1, 20, 3, 4), {})),
2665
+ ),
2666
+ ValueError,
2667
+ ),
2668
+ (
2669
+ "duplicate-match",
2670
+ claude_wrapper(
2671
+ (1, 2, 3, 4),
2672
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (1, 2, 3, 4), {})),
2673
+ ),
2674
+ ValueError,
2675
+ ),
2676
+ (
2677
+ "missing-top-level-usage",
2678
+ claude_wrapper(
2679
+ None,
2680
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
2681
+ ),
2682
+ ValueError,
2683
+ ),
2684
+ (
2685
+ "malformed-top-level-float",
2686
+ claude_wrapper(
2687
+ (1.0, 2, 3, 4),
2688
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
2689
+ ),
2690
+ ValueError,
2691
+ ),
2692
+ (
2693
+ "malformed-top-level-negative",
2694
+ claude_wrapper(
2695
+ (-1, 2, 3, 4),
2696
+ (("model-a", (-1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
2697
+ ),
2698
+ ValueError,
2699
+ ),
2700
+ (
2701
+ "malformed-entry-counter",
2702
+ claude_wrapper(
2703
+ (1, 2, 3, 4),
2704
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (True, 20, 30, 40), {})),
2705
+ ),
2706
+ ValueError,
2707
+ ),
2708
+ )
2709
+ for row_name, wrapper, expected in claude_selector_rows:
2710
+ claude_log.write_text(json.dumps(wrapper) + "\n", encoding="utf-8")
2711
+ if expected is ValueError:
2712
+ try:
2713
+ parse_effective_model(claude_log)
2714
+ except ValueError:
2715
+ pass
2716
+ else:
2717
+ raise AssertionError(f"Claude selector accepted {row_name}")
2718
+ else:
2719
+ assert parse_effective_model(claude_log) == expected, row_name
2720
+
2721
+ claude_log.write_text(
2722
+ json.dumps(claude_selector_rows[-1][1]) + "\n", encoding="utf-8"
2723
+ )
2724
+ write_state(state_path, {"phases": {}})
2725
+ state = read_state(state_path)
2726
+ do_spawn(
2727
+ state, "plan", 0, None, None, "claude", "claude-default",
2728
+ prompt_sha256=digest0,
2729
+ )
2730
+ malformed = do_complete(
2731
+ state, "plan", "PASS", None, None, None, None, None, str(claude_log)
2732
+ )
2733
+ assert malformed and "model-attestation-failed" in malformed
2734
+ assert state["phases"]["plan"]["model_effective"] is None
2735
+ assert state["phases"]["plan"]["verdict"] == "BLOCKED"
2076
2736
 
2077
2737
  # Requested/effective drift is a persisted, fail-closed attestation.
2078
2738
  write_state(state_path, {"phases": {}})
@@ -2087,6 +2747,21 @@ def self_test() -> int:
2087
2747
  assert mismatched["model_effective"] == "gpt-5.6-terra"
2088
2748
  assert mismatched["verdict"] == "BLOCKED"
2089
2749
 
2750
+ claude_log.write_text(
2751
+ json.dumps(claude_selector_rows[1][1]) + "\n", encoding="utf-8"
2752
+ )
2753
+ write_state(state_path, {"phases": {}})
2754
+ state = read_state(state_path)
2755
+ do_spawn(state, "build_gate", 0, None, None, "claude", "claude-" "opus-5")
2756
+ mismatch = do_complete(
2757
+ state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
2758
+ )
2759
+ mismatched = state["phases"]["build_gate"]
2760
+ assert mismatch and "model-attestation-mismatch" in mismatch
2761
+ assert mismatched["model_requested"] == "claude-" "opus-5"
2762
+ assert mismatched["model_effective"] == "claude-" "opus-5[1m]"
2763
+ assert mismatched["verdict"] == "BLOCKED"
2764
+
2090
2765
  # A retained mutation-worker session makes the completion flag
2091
2766
  # mandatory; without a retained file, null remains legal.
2092
2767
  retained_log = devlyn / "implement.worker-session.0.jsonl"
@@ -2128,14 +2803,20 @@ def self_test() -> int:
2128
2803
  # Supplied evidence must parse and never silently record null.
2129
2804
  write_state(state_path, {"phases": {}})
2130
2805
  state = read_state(state_path)
2131
- do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
2806
+ do_spawn(
2807
+ state, "plan", 0, None, None, "claude", "claude-default",
2808
+ prompt_sha256=digest0,
2809
+ )
2132
2810
  assert do_complete(state, "plan", "PASS", None, None, None, None, None) is None
2133
2811
  assert state["phases"]["plan"]["model_effective"] is None
2134
2812
  invalid_log = devlyn / "invalid-session.log"
2135
2813
  invalid_log.write_text("no model evidence\n", encoding="utf-8")
2136
2814
  write_state(state_path, {"phases": {}})
2137
2815
  state = read_state(state_path)
2138
- do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
2816
+ do_spawn(
2817
+ state, "plan", 0, None, None, "claude", "claude-default",
2818
+ prompt_sha256=digest0,
2819
+ )
2139
2820
  invalid = do_complete(
2140
2821
  state, "plan", "PASS", None, None, None, None, None, str(invalid_log)
2141
2822
  )
@@ -2506,7 +3187,7 @@ def main() -> int:
2506
3187
 
2507
3188
  spawn_p = sub.add_parser("spawn")
2508
3189
  spawn_p.add_argument("--round", type=int, required=True)
2509
- spawn_p.add_argument("--triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
3190
+ spawn_p.add_argument("--triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
2510
3191
  spawn_p.add_argument("--pre-sha", default=None)
2511
3192
  spawn_p.add_argument("--input-patch-sha256", default=None)
2512
3193
  spawn_p.add_argument("--prompt-sha256", default=None)
@@ -2533,7 +3214,7 @@ def main() -> int:
2533
3214
  transition_p.add_argument("--engine-session-log", default=None)
2534
3215
  transition_p.add_argument("--next-phase", choices=sorted(PHASE_NAMES), required=True)
2535
3216
  transition_p.add_argument("--next-round", type=int, required=True)
2536
- transition_p.add_argument("--next-triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
3217
+ transition_p.add_argument("--next-triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
2537
3218
  transition_p.add_argument("--next-pre-sha", default=None)
2538
3219
  transition_p.add_argument("--next-input-patch-sha256", default=None)
2539
3220
  transition_p.add_argument("--next-prompt-sha256", default=None)
@@ -2560,7 +3241,6 @@ def main() -> int:
2560
3241
  }
2561
3242
  if not args.phase or args.event not in {"spawn", "complete", "transition", "durability-enforce", *surface_events}:
2562
3243
  ap.error("--phase and a phase event are required unless --self-test")
2563
-
2564
3244
  devlyn = pathlib.Path(args.devlyn_dir)
2565
3245
  if not devlyn.is_dir():
2566
3246
  sys.stderr.write(f"error: {devlyn} is not a directory\n")
@@ -2701,6 +3381,14 @@ def main() -> int:
2701
3381
  state, args.phase, args.verdict, args.post_sha, args.findings_file,
2702
3382
  args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
2703
3383
  )
3384
+ if (
3385
+ args.phase == "plan"
3386
+ and state["phases"]["plan"]["verdict"] in {"PASS", "PASS_WITH_ISSUES"}
3387
+ ):
3388
+ raise SystemExit(
3389
+ "error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
3390
+ "--phase plan transition --verdict <verdict> --next-phase <phase>"
3391
+ )
2704
3392
 
2705
3393
  if args.event not in {"spawn", "transition"}:
2706
3394
  write_state(state_path, state)