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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
- package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
- package/bin/devlyn.js +53 -8
- package/config/skills/_shared/adapters/grok.md +34 -12
- package/config/skills/_shared/collect-codex-findings.py +228 -52
- package/config/skills/_shared/expected.schema.json +7 -0
- package/config/skills/_shared/grok-anchor-guard.py +61 -0
- package/config/skills/_shared/phase-prompt-render.py +181 -0
- package/config/skills/_shared/spec-verify-check.py +308 -31
- package/config/skills/_shared/state-phase-write.py +720 -32
- package/config/skills/_shared/verify-merge-findings.py +170 -0
- package/config/skills/devlyn:engines/SKILL.md +1 -1
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:resolve/SKILL.md +7 -6
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +5 -1
- package/config/skills/devlyn:resolve/references/state-schema.md +2 -2
- package/package.json +1 -1
- package/scripts/lint-fixtures.sh +4 -1
- package/scripts/lint-skills.sh +138 -10
|
@@ -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
|
-
|
|
524
|
-
|
|
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
|
|
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
|
-
|
|
1144
|
-
"
|
|
1145
|
-
"
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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(
|
|
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", "
|
|
1792
|
-
"
|
|
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", "
|
|
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
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|