devlyn-cli 2.10.0 → 3.0.0
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 +4 -9
- package/CLAUDE.md +11 -15
- package/README.md +31 -7
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
- 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 +59 -9
- package/config/skills/_shared/.ruff.toml +2 -0
- package/config/skills/_shared/adapters/claude.md +9 -1
- package/config/skills/_shared/adapters/codex.md +5 -0
- package/config/skills/_shared/adapters/grok.md +37 -13
- package/config/skills/_shared/archive_run.py +738 -56
- package/config/skills/_shared/codex-config.md +19 -4
- package/config/skills/_shared/codex-monitored.sh +81 -12
- package/config/skills/_shared/collect-codex-findings.py +419 -60
- package/config/skills/_shared/engine-preflight.md +8 -7
- package/config/skills/_shared/expected.schema.json +68 -0
- package/config/skills/_shared/finish-gate.py +12 -11
- package/config/skills/_shared/grok-anchor-guard.py +78 -0
- package/config/skills/_shared/invocation-receipt.py +1041 -0
- package/config/skills/_shared/judge-output-parser.py +200 -0
- package/config/skills/_shared/judge-role-evidence.py +273 -0
- package/config/skills/_shared/phase-prompt-render.py +181 -0
- package/config/skills/_shared/process-evidence.py +909 -0
- package/config/skills/_shared/resolve-bootstrap.py +776 -84
- package/config/skills/_shared/resolve-stop-hook.py +33 -1
- package/config/skills/_shared/role-config.py +552 -0
- package/config/skills/_shared/run-bounded.py +1 -1
- package/config/skills/_shared/spec-verify-check.py +1037 -192
- package/config/skills/_shared/state-phase-write.py +2551 -83
- package/config/skills/_shared/terminal-claim-check.py +15 -0
- package/config/skills/_shared/verify-merge-findings.py +1127 -50
- package/config/skills/devlyn:engines/SKILL.md +8 -16
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
- package/config/skills/devlyn:queue/SKILL.md +7 -4
- package/config/skills/devlyn:resolve/SKILL.md +94 -35
- package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +33 -4
- package/config/skills/devlyn:resolve/references/phases/implement.md +4 -3
- 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 +120 -80
- package/config/skills/devlyn:resolve/references/state-schema.md +23 -9
- package/package.json +5 -1
- package/scripts/lint-fixtures.sh +4 -1
- package/scripts/lint-skills.sh +447 -290
|
@@ -27,10 +27,12 @@ import copy
|
|
|
27
27
|
import datetime
|
|
28
28
|
import difflib
|
|
29
29
|
import hashlib
|
|
30
|
+
import importlib.util
|
|
30
31
|
import json
|
|
31
32
|
import os
|
|
32
33
|
import pathlib
|
|
33
34
|
import re
|
|
35
|
+
import runpy
|
|
34
36
|
import shutil
|
|
35
37
|
import subprocess
|
|
36
38
|
import sys
|
|
@@ -38,6 +40,7 @@ import tempfile
|
|
|
38
40
|
|
|
39
41
|
VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
|
|
40
42
|
VALID_TRIGGERS = {"build_gate", "verify"}
|
|
43
|
+
SPAWN_TRIGGERS = VALID_TRIGGERS | {"plan"}
|
|
41
44
|
PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
|
|
42
45
|
LEGAL_TRANSITIONS = {
|
|
43
46
|
"plan": {"probe_derive", "implement", "final_report"},
|
|
@@ -50,11 +53,12 @@ LEGAL_TRANSITIONS = {
|
|
|
50
53
|
"final_report": set(),
|
|
51
54
|
}
|
|
52
55
|
WORKER_SESSION_ARTIFACT_PHASES = {
|
|
56
|
+
"plan": "plan",
|
|
53
57
|
"implement": "implement",
|
|
54
58
|
"surface_close": "surface-close",
|
|
59
|
+
"build_gate": "build_gate",
|
|
55
60
|
"cleanup": "cleanup",
|
|
56
61
|
}
|
|
57
|
-
MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
|
|
58
62
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
59
63
|
SURFACE_ROW_RE = re.compile(
|
|
60
64
|
r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
|
|
@@ -68,14 +72,37 @@ VALIDATION_EXECUTION_RE = re.compile(
|
|
|
68
72
|
)
|
|
69
73
|
SURFACE_SKIP_REASON = "auto_surface_close_claude_unavailable"
|
|
70
74
|
SURFACE_RECOVERY_REASON = "surface_close_rolled_back_adjudication_malformed"
|
|
75
|
+
PLAN_MAX_DISPATCHES = 2
|
|
76
|
+
PLAN_SPAWN_RECEIPT_FIELDS = (
|
|
77
|
+
"round", "started_at", "triggered_by", "engine", "model_requested", "prompt_sha256",
|
|
78
|
+
)
|
|
79
|
+
PLAN_COMPLETION_RECEIPT_FIELDS = (
|
|
80
|
+
"completed_at", "duration_ms", "verdict", "model_effective", "output_sha256",
|
|
81
|
+
)
|
|
82
|
+
PLAN_RECEIPT_FIELDS = PLAN_SPAWN_RECEIPT_FIELDS + PLAN_COMPLETION_RECEIPT_FIELDS
|
|
83
|
+
_PROCESS_EVIDENCE_MODULE = None
|
|
84
|
+
_INVOCATION_RECEIPT_MODULE = None
|
|
71
85
|
|
|
72
86
|
|
|
73
87
|
def reject_json_constant(token: str) -> None:
|
|
74
88
|
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
75
89
|
|
|
76
90
|
|
|
91
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
92
|
+
result = {}
|
|
93
|
+
for key, value in pairs:
|
|
94
|
+
if key in result:
|
|
95
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
96
|
+
result[key] = value
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
77
100
|
def loads_strict_json(text: str):
|
|
78
|
-
return json.loads(
|
|
101
|
+
return json.loads(
|
|
102
|
+
text,
|
|
103
|
+
parse_constant=reject_json_constant,
|
|
104
|
+
object_pairs_hook=reject_duplicate_keys,
|
|
105
|
+
)
|
|
79
106
|
|
|
80
107
|
|
|
81
108
|
def now_ms() -> datetime.datetime:
|
|
@@ -120,6 +147,236 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
|
|
|
120
147
|
raise
|
|
121
148
|
|
|
122
149
|
|
|
150
|
+
def validate_plan_output(state: dict, devlyn: pathlib.Path | None, phase: str) -> None:
|
|
151
|
+
plan = (state.get("phases") or {}).get("plan")
|
|
152
|
+
if not isinstance(plan, dict) or plan.get("completed_at") is None:
|
|
153
|
+
return
|
|
154
|
+
expected = plan.get("output_sha256")
|
|
155
|
+
if expected is None and state.get("version") != "3.0":
|
|
156
|
+
return
|
|
157
|
+
if (
|
|
158
|
+
expected is None and "output_sha256" in plan and plan.get("verdict") == "BLOCKED"
|
|
159
|
+
and devlyn is not None and not os.path.lexists(devlyn / "plan.md")
|
|
160
|
+
):
|
|
161
|
+
if phase != "final_report":
|
|
162
|
+
raise SystemExit("BLOCKED:plan-output-missing: only final_report is allowed")
|
|
163
|
+
return
|
|
164
|
+
if not isinstance(expected, str) or SHA256_RE.fullmatch(expected) is None:
|
|
165
|
+
raise SystemExit("BLOCKED:plan-integrity-invalid: phases.plan.output_sha256 is missing")
|
|
166
|
+
if devlyn is None:
|
|
167
|
+
raise SystemExit("BLOCKED:plan-integrity-invalid: .devlyn is required to rehash PLAN output")
|
|
168
|
+
plan_path = devlyn / "plan.md"
|
|
169
|
+
try:
|
|
170
|
+
actual = hashlib.sha256(plan_path.read_bytes()).hexdigest()
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise SystemExit(f"BLOCKED:plan-integrity-invalid: cannot read {plan_path}: {exc}") from exc
|
|
173
|
+
if actual != expected:
|
|
174
|
+
raise SystemExit(
|
|
175
|
+
"BLOCKED:plan-integrity-mismatch: "
|
|
176
|
+
f"expected={expected} actual={actual} path={plan_path}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def bind_plan_output(state: dict, devlyn: pathlib.Path | None) -> None:
|
|
181
|
+
if devlyn is None:
|
|
182
|
+
raise SystemExit("BLOCKED:plan-integrity-invalid: .devlyn is required to bind PLAN output")
|
|
183
|
+
plan_path = devlyn / "plan.md"
|
|
184
|
+
plan = state["phases"]["plan"]
|
|
185
|
+
if (
|
|
186
|
+
plan.get("verdict") == "BLOCKED" and plan.get("output_sha256") is None
|
|
187
|
+
and not os.path.lexists(plan_path)
|
|
188
|
+
):
|
|
189
|
+
digest = None
|
|
190
|
+
else:
|
|
191
|
+
try:
|
|
192
|
+
digest = hashlib.sha256(plan_path.read_bytes()).hexdigest()
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
raise SystemExit(f"BLOCKED:plan-integrity-invalid: cannot read {plan_path}: {exc}") from exc
|
|
195
|
+
plan["output_sha256"] = digest
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def final_report_digest(state: dict, devlyn: pathlib.Path | None, log_file: str | None) -> str:
|
|
199
|
+
try:
|
|
200
|
+
if devlyn is None or not log_file:
|
|
201
|
+
raise ValueError("--log-file must name .devlyn/final-report.md")
|
|
202
|
+
path = devlyn / "final-report.md"
|
|
203
|
+
supplied = pathlib.Path(log_file)
|
|
204
|
+
if supplied.parent.resolve() / supplied.name != devlyn.resolve() / path.name:
|
|
205
|
+
raise ValueError("--log-file must name the canonical final-report.md")
|
|
206
|
+
if path.is_symlink() or not path.is_file():
|
|
207
|
+
raise ValueError("final-report.md must be a nonsymlink regular file")
|
|
208
|
+
raw = path.read_bytes()
|
|
209
|
+
lines = raw.decode("utf-8").splitlines()
|
|
210
|
+
run_id = state.get("run_id")
|
|
211
|
+
prefix = "<!-- devlyn:final-report run_id="
|
|
212
|
+
if (
|
|
213
|
+
not isinstance(run_id, str) or not run_id or not lines
|
|
214
|
+
or lines[0] != f"{prefix}{run_id} -->"
|
|
215
|
+
or sum(line.startswith(prefix) for line in lines) != 1
|
|
216
|
+
):
|
|
217
|
+
raise ValueError("first line must uniquely identify the current state.run_id")
|
|
218
|
+
if not any(line.strip() for line in lines[1:]):
|
|
219
|
+
raise ValueError("final-report.md body must not be empty")
|
|
220
|
+
return hashlib.sha256(raw).hexdigest()
|
|
221
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
222
|
+
raise SystemExit(f"BLOCKED:final-report-invalid: {exc}") from exc
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def process_evidence_module():
|
|
226
|
+
global _PROCESS_EVIDENCE_MODULE
|
|
227
|
+
if _PROCESS_EVIDENCE_MODULE is None:
|
|
228
|
+
module_path = pathlib.Path(__file__).with_name("process-evidence.py")
|
|
229
|
+
spec = importlib.util.spec_from_file_location("devlyn_process_evidence", module_path)
|
|
230
|
+
if spec is None or spec.loader is None:
|
|
231
|
+
raise SystemExit(f"BLOCKED:process-evidence-invalid: cannot load {module_path}")
|
|
232
|
+
module = importlib.util.module_from_spec(spec)
|
|
233
|
+
previous_bytecode_setting = sys.dont_write_bytecode
|
|
234
|
+
sys.dont_write_bytecode = True
|
|
235
|
+
try:
|
|
236
|
+
spec.loader.exec_module(module)
|
|
237
|
+
finally:
|
|
238
|
+
sys.dont_write_bytecode = previous_bytecode_setting
|
|
239
|
+
_PROCESS_EVIDENCE_MODULE = module
|
|
240
|
+
return _PROCESS_EVIDENCE_MODULE
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def invocation_receipt_module():
|
|
244
|
+
global _INVOCATION_RECEIPT_MODULE
|
|
245
|
+
if _INVOCATION_RECEIPT_MODULE is None:
|
|
246
|
+
module_path = pathlib.Path(__file__).with_name("invocation-receipt.py")
|
|
247
|
+
spec = importlib.util.spec_from_file_location("devlyn_invocation_receipt", module_path)
|
|
248
|
+
if spec is None or spec.loader is None:
|
|
249
|
+
raise SystemExit(f"BLOCKED:invocation-receipt-invalid: cannot load {module_path}")
|
|
250
|
+
module = importlib.util.module_from_spec(spec)
|
|
251
|
+
previous_bytecode_setting = sys.dont_write_bytecode
|
|
252
|
+
sys.dont_write_bytecode = True
|
|
253
|
+
try:
|
|
254
|
+
spec.loader.exec_module(module)
|
|
255
|
+
finally:
|
|
256
|
+
sys.dont_write_bytecode = previous_bytecode_setting
|
|
257
|
+
_INVOCATION_RECEIPT_MODULE = module
|
|
258
|
+
return _INVOCATION_RECEIPT_MODULE
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def bind_process_evidence(
|
|
262
|
+
state: dict, phase: str, verdict: str | None,
|
|
263
|
+
devlyn: pathlib.Path | None, work: pathlib.Path | None,
|
|
264
|
+
) -> None:
|
|
265
|
+
if phase == "implement" and verdict not in {"PASS", "PASS_WITH_ISSUES"}:
|
|
266
|
+
return
|
|
267
|
+
if phase not in {"implement", "build_gate"}:
|
|
268
|
+
return
|
|
269
|
+
source = state.get("source")
|
|
270
|
+
if work is None or devlyn is None:
|
|
271
|
+
if isinstance(source, dict) and source.get("type") == "spec":
|
|
272
|
+
raise SystemExit(
|
|
273
|
+
"BLOCKED:process-evidence-invalid: worktree is required for spec evidence validation"
|
|
274
|
+
)
|
|
275
|
+
state.setdefault("process_evidence", None)
|
|
276
|
+
return
|
|
277
|
+
runner = process_evidence_module()
|
|
278
|
+
try:
|
|
279
|
+
results_path = devlyn / "spec-verify.results.json"
|
|
280
|
+
if phase == "implement":
|
|
281
|
+
obligations = runner.declared_obligations(work, state, phase)
|
|
282
|
+
if not obligations:
|
|
283
|
+
state.setdefault("process_evidence", None)
|
|
284
|
+
return
|
|
285
|
+
round_ = runner.phase_round(state, phase)
|
|
286
|
+
manifest_path = runner.manifest_relative_path(state, phase)
|
|
287
|
+
carrier = runner.validate_manifest(
|
|
288
|
+
work, manifest_path, state.get("run_id"), phase, round_, obligations,
|
|
289
|
+
)
|
|
290
|
+
elif verdict == "BLOCKED" and not os.path.lexists(results_path):
|
|
291
|
+
round_ = runner.phase_round(state, phase)
|
|
292
|
+
carrier = runner.validate_manifest(
|
|
293
|
+
work, runner.manifest_relative_path(state, phase),
|
|
294
|
+
state.get("run_id"), phase, round_, require_expectations=False,
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
if not results_path.is_file():
|
|
298
|
+
raise runner.EvidenceError(
|
|
299
|
+
"spec-verify.results.json is missing for BUILD_GATE completion"
|
|
300
|
+
)
|
|
301
|
+
results = loads_strict_json(results_path.read_text(encoding="utf-8"))
|
|
302
|
+
if not isinstance(results, dict):
|
|
303
|
+
raise runner.EvidenceError(
|
|
304
|
+
"spec-verify.results.json must contain a JSON object"
|
|
305
|
+
)
|
|
306
|
+
commands = results.get("commands")
|
|
307
|
+
if not isinstance(commands, list):
|
|
308
|
+
raise runner.EvidenceError(
|
|
309
|
+
"spec-verify.results.json commands must be an array"
|
|
310
|
+
)
|
|
311
|
+
carrier = results.get("process_evidence")
|
|
312
|
+
if carrier is None:
|
|
313
|
+
if commands:
|
|
314
|
+
raise runner.EvidenceError(
|
|
315
|
+
"BUILD_GATE commands exist without a process-evidence carrier"
|
|
316
|
+
)
|
|
317
|
+
manifest_path = work / runner.manifest_relative_path(state, phase)
|
|
318
|
+
if manifest_path.exists():
|
|
319
|
+
raise runner.EvidenceError(
|
|
320
|
+
"BUILD_GATE manifest exists without a process-evidence carrier"
|
|
321
|
+
)
|
|
322
|
+
if runner.mechanical_evidence_required(work, state):
|
|
323
|
+
raise runner.EvidenceError(
|
|
324
|
+
"required BUILD_GATE evidence has no process-evidence carrier"
|
|
325
|
+
)
|
|
326
|
+
state.setdefault("process_evidence", None)
|
|
327
|
+
return
|
|
328
|
+
round_ = runner.phase_round(state, phase)
|
|
329
|
+
manifest = carrier.get("manifest") if isinstance(carrier, dict) else None
|
|
330
|
+
if (
|
|
331
|
+
not isinstance(carrier, dict)
|
|
332
|
+
or carrier.get("phase") != phase
|
|
333
|
+
or carrier.get("round") != round_
|
|
334
|
+
or not isinstance(manifest, dict)
|
|
335
|
+
or manifest.get("path") != runner.manifest_relative_path(state, phase)
|
|
336
|
+
):
|
|
337
|
+
raise runner.EvidenceError(
|
|
338
|
+
"BUILD_GATE process-evidence carrier does not match the active run/round"
|
|
339
|
+
)
|
|
340
|
+
outcome = runner.validate_summary_commands(work, commands, carrier)
|
|
341
|
+
if outcome["verdict"] == "BLOCKED" and verdict != "BLOCKED":
|
|
342
|
+
denial = outcome["capability_denials"][0]
|
|
343
|
+
raise runner.EvidenceError(
|
|
344
|
+
"BUILD_GATE capability denial requires BLOCKED verdict: "
|
|
345
|
+
f"{denial['id']}:{denial['operation']}"
|
|
346
|
+
)
|
|
347
|
+
if (
|
|
348
|
+
outcome["verdict"] == "NEEDS_WORK"
|
|
349
|
+
and verdict in {"PASS", "PASS_WITH_ISSUES"}
|
|
350
|
+
):
|
|
351
|
+
raise runner.EvidenceError(
|
|
352
|
+
"BUILD_GATE process evidence mismatch cannot complete as "
|
|
353
|
+
f"{verdict}: {','.join(outcome['failed_ids'])}"
|
|
354
|
+
)
|
|
355
|
+
except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
|
|
356
|
+
raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
|
|
357
|
+
existing = state.get("process_evidence")
|
|
358
|
+
if existing is None:
|
|
359
|
+
existing = []
|
|
360
|
+
if not isinstance(existing, list):
|
|
361
|
+
raise SystemExit("BLOCKED:process-evidence-invalid: state.process_evidence must be null or an array")
|
|
362
|
+
try:
|
|
363
|
+
for prior in existing:
|
|
364
|
+
runner.validate_bound_carrier(work, prior)
|
|
365
|
+
except runner.EvidenceError as exc:
|
|
366
|
+
raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
|
|
367
|
+
if any(
|
|
368
|
+
isinstance(item, dict)
|
|
369
|
+
and item.get("phase") == carrier["phase"]
|
|
370
|
+
and item.get("round") == carrier["round"]
|
|
371
|
+
for item in existing
|
|
372
|
+
):
|
|
373
|
+
raise SystemExit(
|
|
374
|
+
"BLOCKED:process-evidence-invalid: duplicate state carrier for "
|
|
375
|
+
f"{phase} round {carrier['round']}"
|
|
376
|
+
)
|
|
377
|
+
state["process_evidence"] = [*existing, carrier]
|
|
378
|
+
|
|
379
|
+
|
|
123
380
|
def parse_string_list(raw: str, label: str) -> list[str]:
|
|
124
381
|
try:
|
|
125
382
|
value = loads_strict_json(raw)
|
|
@@ -226,6 +483,24 @@ def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, stat
|
|
|
226
483
|
raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
|
|
227
484
|
|
|
228
485
|
|
|
486
|
+
def validate_surface_brace_glob(entry: str) -> str | None:
|
|
487
|
+
if "{" not in entry and "}" not in entry:
|
|
488
|
+
return None
|
|
489
|
+
brace = re.search(r"\{([^{}]*)\}", entry)
|
|
490
|
+
alternatives = brace.group(1).split(",") if brace else []
|
|
491
|
+
if (
|
|
492
|
+
entry.count("{") != 1
|
|
493
|
+
or entry.count("}") != 1
|
|
494
|
+
or len(alternatives) < 2
|
|
495
|
+
or any(not value or any(char in value for char in "{},/*") for value in alternatives)
|
|
496
|
+
):
|
|
497
|
+
return (
|
|
498
|
+
f"unsupported brace glob {entry!r}; supported form is {{alt1,alt2,...}} "
|
|
499
|
+
"with at least two non-empty plain alternatives"
|
|
500
|
+
)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
229
504
|
def validate_authorized_surface(raw: str) -> list[str]:
|
|
230
505
|
surface = parse_string_list(raw, "--authorized-surface-json")
|
|
231
506
|
if not surface:
|
|
@@ -238,24 +513,103 @@ def validate_authorized_surface(raw: str) -> list[str]:
|
|
|
238
513
|
or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
|
|
239
514
|
):
|
|
240
515
|
raise SystemExit(f"error: invalid authorized_surface entry: {entry!r}")
|
|
516
|
+
brace_error = validate_surface_brace_glob(entry)
|
|
517
|
+
if brace_error is not None:
|
|
518
|
+
raise SystemExit(f"error: {brace_error}")
|
|
241
519
|
return surface
|
|
242
520
|
|
|
243
521
|
|
|
244
522
|
def path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
245
523
|
for entry in surface:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
524
|
+
brace_error = validate_surface_brace_glob(entry)
|
|
525
|
+
if brace_error is not None:
|
|
526
|
+
raise ValueError(brace_error)
|
|
527
|
+
if "{" in entry:
|
|
528
|
+
brace = re.search(r"\{([^{}]*)\}", entry)
|
|
529
|
+
assert brace is not None
|
|
530
|
+
alternatives = brace.group(1).split(",")
|
|
531
|
+
entries = tuple(
|
|
532
|
+
entry[:brace.start()] + value + entry[brace.end():]
|
|
533
|
+
for value in alternatives
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
entries = (entry,)
|
|
537
|
+
for expanded in entries:
|
|
538
|
+
if expanded.endswith("/**"):
|
|
539
|
+
prefix = expanded[:-3].rstrip("/")
|
|
540
|
+
if path == prefix or path.startswith(f"{prefix}/"):
|
|
541
|
+
return True
|
|
542
|
+
elif path == expanded:
|
|
249
543
|
return True
|
|
250
|
-
elif path == entry:
|
|
251
|
-
return True
|
|
252
544
|
return False
|
|
253
545
|
|
|
254
546
|
|
|
547
|
+
def safe_path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
548
|
+
parsed = pathlib.PurePosixPath(path)
|
|
549
|
+
return not (parsed.is_absolute() or ".." in parsed.parts) and path_matches_surface(path, surface)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def worktree_file_exists(work: pathlib.Path, path: str) -> bool:
|
|
553
|
+
parsed = pathlib.PurePosixPath(path)
|
|
554
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
555
|
+
return False
|
|
556
|
+
try:
|
|
557
|
+
resolved = (work / path).resolve()
|
|
558
|
+
resolved.relative_to(work.resolve())
|
|
559
|
+
return resolved.is_file()
|
|
560
|
+
except (OSError, RuntimeError, ValueError):
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def worktree_path_exists(work: pathlib.Path, path: str) -> bool:
|
|
565
|
+
parsed = pathlib.PurePosixPath(path)
|
|
566
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
567
|
+
return False
|
|
568
|
+
try:
|
|
569
|
+
resolved = (work / path).resolve()
|
|
570
|
+
resolved.relative_to(work.resolve())
|
|
571
|
+
return resolved.exists()
|
|
572
|
+
except (OSError, RuntimeError, ValueError):
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def resolve_na_surface_citation(
|
|
577
|
+
work: pathlib.Path, raw: str, parsed_path: str, parsed_line: int | None,
|
|
578
|
+
surface: list[str],
|
|
579
|
+
) -> tuple[str, int | None]:
|
|
580
|
+
def proven(path: str) -> bool:
|
|
581
|
+
if path in surface and not path.endswith("/**"):
|
|
582
|
+
return True
|
|
583
|
+
return worktree_file_exists(work, path) and any(
|
|
584
|
+
entry.endswith("/**") and safe_path_matches_surface(path, [entry])
|
|
585
|
+
for entry in surface
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
if proven(raw):
|
|
589
|
+
return raw, None
|
|
590
|
+
if worktree_path_exists(work, raw) and not safe_path_matches_surface(raw, surface):
|
|
591
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
|
|
592
|
+
if (
|
|
593
|
+
worktree_path_exists(work, parsed_path)
|
|
594
|
+
and not safe_path_matches_surface(parsed_path, surface)
|
|
595
|
+
):
|
|
596
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
|
|
597
|
+
for split in range(len(raw) - 1, -1, -1):
|
|
598
|
+
if raw[split] != ":" or not proven(raw[:split]):
|
|
599
|
+
continue
|
|
600
|
+
suffix = raw[split:]
|
|
601
|
+
if not re.fullmatch(r":[1-9][0-9]*", suffix):
|
|
602
|
+
raise SystemExit(
|
|
603
|
+
f"BLOCKED:surface-close-adjudication-malformed: citation {raw!r}"
|
|
604
|
+
)
|
|
605
|
+
return raw[:split], int(suffix[1:])
|
|
606
|
+
return parsed_path, parsed_line
|
|
607
|
+
|
|
608
|
+
|
|
255
609
|
def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
|
|
256
610
|
surface: list[str]) -> list[str]:
|
|
257
611
|
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
258
|
-
return sorted(path for path in set(tracked + untracked) if not
|
|
612
|
+
return sorted(path for path in set(tracked + untracked) if not safe_path_matches_surface(path, surface))
|
|
259
613
|
|
|
260
614
|
|
|
261
615
|
def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
|
|
@@ -342,14 +696,20 @@ def validate_surface_adjudication(
|
|
|
342
696
|
raw_line = match.group("fired_line") or match.group("na_line")
|
|
343
697
|
line_number = int(raw_line) if raw_line is not None else None
|
|
344
698
|
citation = path if line_number is None else f"{path}:{line_number}"
|
|
345
|
-
if
|
|
699
|
+
if status == "N/A":
|
|
700
|
+
path, line_number = resolve_na_surface_citation(
|
|
701
|
+
work, citation, path, line_number, surface,
|
|
702
|
+
)
|
|
703
|
+
citation = path if line_number is None else f"{path}:{line_number}"
|
|
704
|
+
if not safe_path_matches_surface(path, surface):
|
|
346
705
|
raise SystemExit(
|
|
347
706
|
f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
|
|
348
707
|
)
|
|
349
|
-
cited = work / path
|
|
350
708
|
try:
|
|
709
|
+
cited = (work / path).resolve()
|
|
710
|
+
cited.relative_to(work.resolve())
|
|
351
711
|
cited_lines = cited.read_text(encoding="utf-8").splitlines()
|
|
352
|
-
except (OSError, UnicodeError) as exc:
|
|
712
|
+
except (OSError, RuntimeError, UnicodeError, ValueError) as exc:
|
|
353
713
|
raise SystemExit(
|
|
354
714
|
f"BLOCKED:surface-close-adjudication-citation-missing: {citation}: {exc}"
|
|
355
715
|
) from exc
|
|
@@ -464,7 +824,7 @@ def validate_surface_write_audit(
|
|
|
464
824
|
"BLOCKED:surface-close-write-audit-violation: "
|
|
465
825
|
f"line {line_number}: {raw_target!r}"
|
|
466
826
|
) from exc
|
|
467
|
-
if not
|
|
827
|
+
if not safe_path_matches_surface(relative, surface):
|
|
468
828
|
raise SystemExit(
|
|
469
829
|
"BLOCKED:surface-close-write-audit-violation: "
|
|
470
830
|
f"line {line_number}: {relative!r}"
|
|
@@ -488,16 +848,66 @@ def require_surface_adjudication_malformed(
|
|
|
488
848
|
)
|
|
489
849
|
|
|
490
850
|
|
|
851
|
+
CLAUDE_USAGE_COUNTERS = (
|
|
852
|
+
("input_tokens", "inputTokens"),
|
|
853
|
+
("output_tokens", "outputTokens"),
|
|
854
|
+
("cache_read_input_tokens", "cacheReadInputTokens"),
|
|
855
|
+
("cache_creation_input_tokens", "cacheCreationInputTokens"),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def select_claude_primary_model(evidence: dict) -> str:
|
|
860
|
+
model_usage = evidence.get("modelUsage")
|
|
861
|
+
models = (
|
|
862
|
+
[model for model in model_usage if isinstance(model, str) and model]
|
|
863
|
+
if isinstance(model_usage, dict)
|
|
864
|
+
else []
|
|
865
|
+
)
|
|
866
|
+
if len(models) == 1:
|
|
867
|
+
return models[0]
|
|
868
|
+
if not models:
|
|
869
|
+
raise ValueError("Claude JSON wrapper has no effective-model evidence")
|
|
870
|
+
|
|
871
|
+
usage = evidence.get("usage")
|
|
872
|
+
if not isinstance(usage, dict):
|
|
873
|
+
raise ValueError("multi-entry Claude JSON wrapper has malformed top-level usage")
|
|
874
|
+
|
|
875
|
+
def counters(container: dict, fields: tuple[str, ...]) -> tuple[int, ...]:
|
|
876
|
+
values = tuple(container.get(field) for field in fields)
|
|
877
|
+
if any(
|
|
878
|
+
not isinstance(value, int)
|
|
879
|
+
or isinstance(value, bool)
|
|
880
|
+
or value < 0
|
|
881
|
+
for value in values
|
|
882
|
+
):
|
|
883
|
+
raise ValueError("multi-entry Claude JSON wrapper has malformed usage counters")
|
|
884
|
+
return values
|
|
885
|
+
|
|
886
|
+
primary_usage = counters(
|
|
887
|
+
usage, tuple(field[0] for field in CLAUDE_USAGE_COUNTERS)
|
|
888
|
+
)
|
|
889
|
+
matches = []
|
|
890
|
+
for model in models:
|
|
891
|
+
entry = model_usage[model]
|
|
892
|
+
if not isinstance(entry, dict):
|
|
893
|
+
raise ValueError("multi-entry Claude JSON wrapper has malformed modelUsage entry")
|
|
894
|
+
if counters(
|
|
895
|
+
entry, tuple(field[1] for field in CLAUDE_USAGE_COUNTERS)
|
|
896
|
+
) == primary_usage:
|
|
897
|
+
matches.append(model)
|
|
898
|
+
if len(matches) == 1:
|
|
899
|
+
return matches[0]
|
|
900
|
+
raise ValueError(
|
|
901
|
+
"multi-entry Claude JSON wrapper has no unique primary usage match"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
|
|
491
905
|
def parse_effective_model(session_log: pathlib.Path) -> str:
|
|
492
906
|
try:
|
|
493
907
|
text = session_log.read_text(encoding="utf-8")
|
|
494
908
|
except (OSError, UnicodeError) as exc:
|
|
495
909
|
raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
|
|
496
910
|
|
|
497
|
-
header = MODEL_HEADER_RE.search(text)
|
|
498
|
-
if header:
|
|
499
|
-
return header.group(1)
|
|
500
|
-
|
|
501
911
|
models: set[str] = set()
|
|
502
912
|
if session_log.suffix == ".jsonl":
|
|
503
913
|
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
@@ -520,9 +930,8 @@ def parse_effective_model(session_log: pathlib.Path) -> str:
|
|
|
520
930
|
evidence = loads_strict_json(text)
|
|
521
931
|
except ValueError:
|
|
522
932
|
evidence = None
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
models.update(model for model in model_usage if isinstance(model, str) and model)
|
|
933
|
+
if isinstance(evidence, dict) and isinstance(evidence.get("modelUsage"), dict):
|
|
934
|
+
return select_claude_primary_model(evidence)
|
|
526
935
|
|
|
527
936
|
if len(models) == 1:
|
|
528
937
|
return next(iter(models))
|
|
@@ -1126,42 +1535,135 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
|
1126
1535
|
# verify-merge-findings.py (iter-0060 R0 finding).
|
|
1127
1536
|
# verify*.jsonl (not just *.findings.jsonl): judge-specific files like
|
|
1128
1537
|
# verify.findings.judge-codex.jsonl end in .judge-<engine>.jsonl.
|
|
1129
|
-
#
|
|
1130
|
-
#
|
|
1131
|
-
for pattern in ("verify*.jsonl", "*-judge
|
|
1538
|
+
# The judge suffixes cover harness-owned stdout/stderr captures and the
|
|
1539
|
+
# collector-written pair-judge.summary.json, without matching prompts.
|
|
1540
|
+
for pattern in ("verify*.jsonl", "*-judge.stdout", "*-judge.stderr", "*-judge.summary.json"):
|
|
1132
1541
|
for path in devlyn.glob(pattern):
|
|
1133
1542
|
path.unlink()
|
|
1134
1543
|
(devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
|
|
1135
1544
|
|
|
1136
1545
|
|
|
1137
|
-
def
|
|
1546
|
+
def is_plan_dispatch_receipt(entry: dict) -> bool:
|
|
1547
|
+
return all(field in entry for field in PLAN_RECEIPT_FIELDS)
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def append_phase_history(entry: dict, phase: str) -> None:
|
|
1138
1551
|
if entry.get("started_at") is None:
|
|
1139
1552
|
return
|
|
1140
1553
|
history = entry.get("history")
|
|
1141
1554
|
if not isinstance(history, list):
|
|
1142
1555
|
history = []
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1556
|
+
if phase == "plan" and is_plan_dispatch_receipt(entry):
|
|
1557
|
+
fields = PLAN_RECEIPT_FIELDS + (
|
|
1558
|
+
("invocation_receipt",) if "invocation_receipt" in entry else ()
|
|
1559
|
+
)
|
|
1560
|
+
elif phase in WORKER_SESSION_ARTIFACT_PHASES and "invocation_receipt" in entry:
|
|
1561
|
+
fields = (
|
|
1562
|
+
"started_at", "verdict", "completed_at", "duration_ms",
|
|
1563
|
+
"invocation_receipt",
|
|
1564
|
+
) + (("role_argv",) if "role_argv" in entry else ())
|
|
1565
|
+
elif phase == "verify":
|
|
1566
|
+
fields = ("started_at", "verdict", "completed_at", "duration_ms", "round", "engine", "role_evidence")
|
|
1567
|
+
else:
|
|
1568
|
+
fields = ("started_at", "verdict", "completed_at", "duration_ms")
|
|
1569
|
+
history.append({field: entry.get(field) for field in fields})
|
|
1149
1570
|
entry["history"] = history
|
|
1150
1571
|
|
|
1151
1572
|
|
|
1573
|
+
def role_config_module():
|
|
1574
|
+
return runpy.run_path(pathlib.Path(__file__).with_name("role-config.py"))
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def bind_worker_role_argv(state, phase, entry, devlyn, receipt):
|
|
1578
|
+
if phase not in {"implement", "cleanup"}:
|
|
1579
|
+
return None
|
|
1580
|
+
helper = role_config_module()
|
|
1581
|
+
resolution = helper["snapshot"](state)
|
|
1582
|
+
effort = resolution["roles"]["worker"]["effort_requested"] if resolution else None
|
|
1583
|
+
if effort is None:
|
|
1584
|
+
return None
|
|
1585
|
+
path = devlyn / f"{phase}.argv.{entry['round']}.json"
|
|
1586
|
+
if path.is_symlink() or not path.is_file():
|
|
1587
|
+
raise ValueError("explicit worker effort requires the retained native argv array")
|
|
1588
|
+
raw = path.read_bytes()
|
|
1589
|
+
argv = helper["loads"](raw)
|
|
1590
|
+
if not isinstance(argv, list) or any(not isinstance(arg, str) for arg in argv):
|
|
1591
|
+
raise ValueError("worker argv must be an array of strings")
|
|
1592
|
+
if hashlib.sha256(json.dumps(argv, separators=(",", ":")).encode()).hexdigest() != receipt["argv_sha256"]:
|
|
1593
|
+
raise ValueError("worker argv differs from the actual invocation receipt")
|
|
1594
|
+
values = [argv[i + 1] for i, arg in enumerate(argv[:-1]) if arg in {"-c", "--config"}]
|
|
1595
|
+
efforts = [value.split("=", 1)[1].strip('"') for value in values if value.startswith("model_reasoning_effort=")]
|
|
1596
|
+
if efforts != [effort]:
|
|
1597
|
+
raise ValueError("worker effort differs from the frozen explicit selection")
|
|
1598
|
+
return {"path": ".devlyn/" + path.name, "sha256": hashlib.sha256(raw).hexdigest(),
|
|
1599
|
+
"bytes": len(raw), "effort_requested": effort, "identity_basis": "invocation-argv"}
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def freeze_roles(state: dict, work: pathlib.Path, default_engine: str) -> dict:
|
|
1603
|
+
helper = role_config_module()
|
|
1604
|
+
existing = helper["snapshot"](state)
|
|
1605
|
+
if existing is not None:
|
|
1606
|
+
return existing
|
|
1607
|
+
if any(isinstance(entry, dict) and entry.get("started_at") for entry in state.get("phases", {}).values()):
|
|
1608
|
+
raise ValueError("BLOCKED:invalid-engine-config: roles must be frozen before phase dispatch")
|
|
1609
|
+
resolved = helper["resolve"](
|
|
1610
|
+
work, default_engine,
|
|
1611
|
+
flag_engine=state.get("engine") if state.get("engine_source") == "flag" else None,
|
|
1612
|
+
run_input=state.get("role_config_input"), no_pair=state.get("role_no_pair", False),
|
|
1613
|
+
)
|
|
1614
|
+
state["role_resolution"] = resolved
|
|
1615
|
+
state["engine"], state["engine_source"] = resolved["legacy_engine"], resolved["legacy_source"]
|
|
1616
|
+
return resolved
|
|
1617
|
+
|
|
1618
|
+
|
|
1152
1619
|
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
1153
1620
|
pre_sha: str | None, engine: str | None, model: str | None, *,
|
|
1154
1621
|
input_patch_sha256: str | None = None,
|
|
1155
1622
|
prompt_sha256: str | None = None,
|
|
1156
|
-
untracked_before: list[str] | None = None
|
|
1623
|
+
untracked_before: list[str] | None = None,
|
|
1624
|
+
devlyn: pathlib.Path | None = None) -> None:
|
|
1625
|
+
if phase in {"implement", "cleanup", "verify"} and "role_resolution" in state:
|
|
1626
|
+
resolution = role_config_module()["snapshot"](state)
|
|
1627
|
+
selected = resolution["roles"]["primary_judge" if phase == "verify" else "worker"]
|
|
1628
|
+
if engine is not None and engine != selected["engine"]:
|
|
1629
|
+
raise SystemExit("BLOCKED:role-selection-mismatch: phase engine differs from frozen selection")
|
|
1630
|
+
engine = selected["engine"]
|
|
1631
|
+
wanted_model = selected["model_requested"]
|
|
1632
|
+
if wanted_model is not None and model is not None and wanted_model != model:
|
|
1633
|
+
raise SystemExit("BLOCKED:role-selection-mismatch: phase model differs from frozen selection")
|
|
1634
|
+
model = wanted_model or model
|
|
1157
1635
|
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
1158
1636
|
# other field this script doesn't own) survives a fix-loop respawn.
|
|
1637
|
+
phases_value = state.get("phases")
|
|
1638
|
+
entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
|
|
1639
|
+
validate_plan_output(state, devlyn, phase)
|
|
1640
|
+
if phase == "plan":
|
|
1641
|
+
history = entry.get("history", []) if isinstance(entry, dict) else []
|
|
1642
|
+
if not isinstance(history, list):
|
|
1643
|
+
raise SystemExit("error: phases.plan.history must be an array")
|
|
1644
|
+
dispatch_count = len(history) + int(
|
|
1645
|
+
isinstance(entry, dict) and entry.get("started_at") is not None
|
|
1646
|
+
)
|
|
1647
|
+
if dispatch_count >= PLAN_MAX_DISPATCHES:
|
|
1648
|
+
raise SystemExit("BLOCKED:plan-respawn-exhausted")
|
|
1649
|
+
if round_ != dispatch_count:
|
|
1650
|
+
raise SystemExit(
|
|
1651
|
+
f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
|
|
1652
|
+
)
|
|
1653
|
+
if not isinstance(engine, str) or not engine:
|
|
1654
|
+
raise SystemExit("error: phases.plan spawn requires --engine")
|
|
1655
|
+
if not isinstance(model, str) or not model:
|
|
1656
|
+
raise SystemExit("error: phases.plan spawn requires --model")
|
|
1657
|
+
if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
|
|
1658
|
+
raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
|
|
1659
|
+
if dispatch_count > 0 and triggered_by is None:
|
|
1660
|
+
raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
|
|
1159
1661
|
if phase == "surface_close" and engine != "claude":
|
|
1160
1662
|
raise SystemExit("error: phases.surface_close spawn requires --engine claude")
|
|
1161
|
-
if phase == "surface_close" and not
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1663
|
+
if phase == "surface_close" and model is not None and (
|
|
1664
|
+
not isinstance(model, str) or not model.strip() or model != model.strip()
|
|
1665
|
+
):
|
|
1666
|
+
raise SystemExit("error: phases.surface_close explicit --model must be a nonempty exact string")
|
|
1165
1667
|
if phase == "surface_close" and isinstance(entry, dict) and (
|
|
1166
1668
|
entry.get("started_at") is not None or entry.get("skipped_reason") is not None
|
|
1167
1669
|
):
|
|
@@ -1175,8 +1677,42 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1175
1677
|
raise SystemExit("error: phases.surface_close spawn requires --prompt-sha256")
|
|
1176
1678
|
if untracked_before is None:
|
|
1177
1679
|
raise SystemExit("error: phases.surface_close spawn requires --untracked-before-json")
|
|
1178
|
-
elif
|
|
1179
|
-
|
|
1680
|
+
elif phase == "plan":
|
|
1681
|
+
if input_patch_sha256 is not None or untracked_before is not None:
|
|
1682
|
+
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
1683
|
+
else:
|
|
1684
|
+
if input_patch_sha256 is not None or untracked_before is not None:
|
|
1685
|
+
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
1686
|
+
if prompt_sha256 is not None and SHA256_RE.fullmatch(prompt_sha256) is None:
|
|
1687
|
+
raise SystemExit("error: --prompt-sha256 must be a lowercase SHA-256 digest")
|
|
1688
|
+
requested_engine = (
|
|
1689
|
+
engine if isinstance(engine, str) and engine else
|
|
1690
|
+
entry.get("engine") if isinstance(entry, dict) else
|
|
1691
|
+
state.get("engine")
|
|
1692
|
+
)
|
|
1693
|
+
if (
|
|
1694
|
+
state.get("version") == "3.0"
|
|
1695
|
+
and requested_engine == "codex"
|
|
1696
|
+
and phase in WORKER_SESSION_ARTIFACT_PHASES
|
|
1697
|
+
and prompt_sha256 is None
|
|
1698
|
+
):
|
|
1699
|
+
raise SystemExit(
|
|
1700
|
+
f"error: schema-v3 Codex phases.{phase} spawn requires --prompt-sha256"
|
|
1701
|
+
)
|
|
1702
|
+
requested_model = (
|
|
1703
|
+
model if isinstance(model, str) and model else
|
|
1704
|
+
entry.get("model_requested") if isinstance(entry, dict) else None
|
|
1705
|
+
)
|
|
1706
|
+
if (
|
|
1707
|
+
state.get("version") == "3.0"
|
|
1708
|
+
and requested_engine == "codex"
|
|
1709
|
+
and phase in WORKER_SESSION_ARTIFACT_PHASES
|
|
1710
|
+
and not requested_model
|
|
1711
|
+
):
|
|
1712
|
+
raise SystemExit(
|
|
1713
|
+
f"error: schema-v3 Codex phases.{phase} spawn requires --model"
|
|
1714
|
+
)
|
|
1715
|
+
phases = state.setdefault("phases", {})
|
|
1180
1716
|
if not isinstance(entry, dict):
|
|
1181
1717
|
entry = {}
|
|
1182
1718
|
phases[phase] = entry
|
|
@@ -1184,7 +1720,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1184
1720
|
raise SystemExit(
|
|
1185
1721
|
f"error: phases.{phase} has an open span — complete it before respawn"
|
|
1186
1722
|
)
|
|
1187
|
-
append_phase_history(entry)
|
|
1723
|
+
append_phase_history(entry, phase)
|
|
1724
|
+
if phase in WORKER_SESSION_ARTIFACT_PHASES:
|
|
1725
|
+
entry.pop("invocation_receipt", None)
|
|
1726
|
+
entry.pop("role_argv", None)
|
|
1188
1727
|
entry["started_at"] = now_iso()
|
|
1189
1728
|
entry["completed_at"] = None
|
|
1190
1729
|
entry["duration_ms"] = None
|
|
@@ -1195,8 +1734,16 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1195
1734
|
entry["sub_verdicts"] = None
|
|
1196
1735
|
if phase == "verify":
|
|
1197
1736
|
entry["judge_durations_ms"] = None
|
|
1737
|
+
entry.pop("role_evidence", None)
|
|
1198
1738
|
if engine is not None:
|
|
1199
1739
|
entry["engine"] = engine
|
|
1740
|
+
elif (
|
|
1741
|
+
state.get("version") == "3.0"
|
|
1742
|
+
and "engine" not in entry
|
|
1743
|
+
and isinstance(state.get("engine"), str)
|
|
1744
|
+
and state["engine"]
|
|
1745
|
+
):
|
|
1746
|
+
entry["engine"] = state["engine"]
|
|
1200
1747
|
if model is not None:
|
|
1201
1748
|
entry["model_requested"] = model
|
|
1202
1749
|
else:
|
|
@@ -1205,9 +1752,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1205
1752
|
entry["model_effective"] = None
|
|
1206
1753
|
if pre_sha is not None:
|
|
1207
1754
|
entry["pre_sha"] = pre_sha
|
|
1755
|
+
if prompt_sha256 is not None:
|
|
1756
|
+
entry["prompt_sha256"] = prompt_sha256
|
|
1208
1757
|
if phase == "surface_close":
|
|
1209
1758
|
entry["input_patch_sha256"] = input_patch_sha256
|
|
1210
|
-
entry["prompt_sha256"] = prompt_sha256
|
|
1211
1759
|
entry["untracked_before"] = untracked_before
|
|
1212
1760
|
|
|
1213
1761
|
|
|
@@ -1238,21 +1786,49 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1238
1786
|
post_sha: str | None, findings_file: str | None, log_file: str | None,
|
|
1239
1787
|
engine: str | None, model: str | None,
|
|
1240
1788
|
engine_session_log: str | None = None,
|
|
1241
|
-
devlyn: pathlib.Path | None = None
|
|
1789
|
+
devlyn: pathlib.Path | None = None,
|
|
1790
|
+
work: pathlib.Path | None = None) -> str | None:
|
|
1242
1791
|
phases = state.setdefault("phases", {})
|
|
1243
1792
|
entry = phases.get(phase)
|
|
1244
1793
|
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
1245
1794
|
raise SystemExit(f"error: phases.{phase} was never spawned (no started_at) — cannot complete")
|
|
1795
|
+
if phase == "verify" and verdict is not None:
|
|
1796
|
+
raise SystemExit(
|
|
1797
|
+
"error: phases.verify.verdict is owned by verify-merge-findings.py "
|
|
1798
|
+
"--write-state; do not pass --verdict to complete for this phase"
|
|
1799
|
+
)
|
|
1800
|
+
if entry.get("completed_at") is not None:
|
|
1801
|
+
raise SystemExit(
|
|
1802
|
+
f"error: phases.{phase} already completed — respawn before completing again"
|
|
1803
|
+
)
|
|
1804
|
+
if phase != "plan":
|
|
1805
|
+
validate_plan_output(state, devlyn, phase)
|
|
1806
|
+
if phase == "final_report":
|
|
1807
|
+
report_digest = final_report_digest(state, devlyn, log_file)
|
|
1808
|
+
log_file = ".devlyn/final-report.md"
|
|
1809
|
+
if phase == "plan" and entry.get("prompt_sha256") is not None:
|
|
1810
|
+
missing = [field for field in PLAN_SPAWN_RECEIPT_FIELDS if field not in entry]
|
|
1811
|
+
if missing:
|
|
1812
|
+
raise SystemExit(
|
|
1813
|
+
"error: phases.plan spawn receipt is incomplete: " + ",".join(missing)
|
|
1814
|
+
)
|
|
1815
|
+
if engine is not None and engine != entry["engine"]:
|
|
1816
|
+
raise SystemExit("error: phases.plan completion cannot replace spawn engine")
|
|
1817
|
+
if model is not None and model != entry["model_requested"]:
|
|
1818
|
+
raise SystemExit("error: phases.plan completion cannot replace requested model")
|
|
1819
|
+
if state.get("version") == "3.0" and phase != "plan":
|
|
1820
|
+
if engine is not None and engine != entry.get("engine"):
|
|
1821
|
+
raise SystemExit(f"error: phases.{phase} completion cannot replace spawn engine")
|
|
1822
|
+
if model is not None and model != entry.get("model_requested"):
|
|
1823
|
+
raise SystemExit(
|
|
1824
|
+
f"error: phases.{phase} completion cannot replace requested model"
|
|
1825
|
+
)
|
|
1826
|
+
bind_process_evidence(state, phase, verdict, devlyn, work)
|
|
1246
1827
|
started = parse_iso(entry["started_at"])
|
|
1247
1828
|
now = now_ms()
|
|
1248
1829
|
entry["completed_at"] = now_iso(now)
|
|
1249
1830
|
entry["duration_ms"] = max(0, round((now - started).total_seconds() * 1000))
|
|
1250
1831
|
if phase == "verify":
|
|
1251
|
-
if verdict is not None:
|
|
1252
|
-
raise SystemExit(
|
|
1253
|
-
"error: phases.verify.verdict is owned by verify-merge-findings.py "
|
|
1254
|
-
"--write-state; do not pass --verdict to complete for this phase"
|
|
1255
|
-
)
|
|
1256
1832
|
if entry.get("verdict") is None:
|
|
1257
1833
|
raise SystemExit(
|
|
1258
1834
|
"error: phases.verify.verdict is still null — run "
|
|
@@ -1262,17 +1838,24 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1262
1838
|
entry["verdict"] = verdict
|
|
1263
1839
|
else:
|
|
1264
1840
|
raise SystemExit(f"error: --verdict is required to complete phases.{phase}")
|
|
1841
|
+
if phase == "plan":
|
|
1842
|
+
if state.get("version") == "3.0":
|
|
1843
|
+
bind_plan_output(state, devlyn)
|
|
1844
|
+
else:
|
|
1845
|
+
entry.setdefault("output_sha256", None)
|
|
1846
|
+
if phase == "final_report":
|
|
1847
|
+
entry["output_sha256"] = report_digest
|
|
1265
1848
|
if findings_file is not None or log_file is not None:
|
|
1266
1849
|
artifacts = entry.setdefault("artifacts", {"findings_file": None, "log_file": None})
|
|
1267
1850
|
if findings_file is not None:
|
|
1268
1851
|
artifacts["findings_file"] = findings_file
|
|
1269
1852
|
if log_file is not None:
|
|
1270
1853
|
artifacts["log_file"] = log_file
|
|
1271
|
-
if engine is not None:
|
|
1854
|
+
if engine is not None and phase != "plan":
|
|
1272
1855
|
entry["engine"] = engine
|
|
1273
|
-
if model is not None:
|
|
1856
|
+
if model is not None and phase != "plan":
|
|
1274
1857
|
entry["model_requested"] = model
|
|
1275
|
-
|
|
1858
|
+
elif phase != "plan":
|
|
1276
1859
|
entry.setdefault("model_requested", None)
|
|
1277
1860
|
entry.pop("model", None)
|
|
1278
1861
|
|
|
@@ -1284,13 +1867,74 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1284
1867
|
retained_session = candidate
|
|
1285
1868
|
|
|
1286
1869
|
attestation_error = None
|
|
1287
|
-
if
|
|
1870
|
+
if phase == "surface_close":
|
|
1871
|
+
canonical_result = (devlyn or pathlib.Path(".devlyn")) / "surface-close.output.json"
|
|
1872
|
+
if engine_session_log is None or pathlib.Path(engine_session_log).resolve() != canonical_result.resolve():
|
|
1873
|
+
attestation_error = (
|
|
1874
|
+
"BLOCKED:model-attestation-failed: SURFACE_CLOSE requires canonical native JSON "
|
|
1875
|
+
f"via --engine-session-log {canonical_result}"
|
|
1876
|
+
)
|
|
1877
|
+
else:
|
|
1878
|
+
# Select the Claude JSON parser even if the caller used a .jsonl symlink alias.
|
|
1879
|
+
engine_session_log = str(canonical_result)
|
|
1880
|
+
schema_v3_worker = (
|
|
1881
|
+
state.get("version") == "3.0"
|
|
1882
|
+
and artifact_phase is not None
|
|
1883
|
+
and entry.get("engine") == "codex"
|
|
1884
|
+
)
|
|
1885
|
+
if schema_v3_worker and retained_session is None:
|
|
1886
|
+
expected_session = (
|
|
1887
|
+
devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
|
|
1888
|
+
if devlyn is not None else
|
|
1889
|
+
pathlib.Path(".devlyn") / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
|
|
1890
|
+
)
|
|
1891
|
+
attestation_error = (
|
|
1892
|
+
"BLOCKED:model-attestation-failed: canonical retained worker session is missing: "
|
|
1893
|
+
f"{expected_session}"
|
|
1894
|
+
)
|
|
1895
|
+
if (
|
|
1896
|
+
attestation_error is None
|
|
1897
|
+
and schema_v3_worker
|
|
1898
|
+
and engine_session_log is not None
|
|
1899
|
+
and pathlib.Path(engine_session_log).resolve() != retained_session.resolve()
|
|
1900
|
+
):
|
|
1901
|
+
attestation_error = (
|
|
1902
|
+
"BLOCKED:model-attestation-failed: --engine-session-log must be the canonical "
|
|
1903
|
+
f"phase/round session {retained_session}"
|
|
1904
|
+
)
|
|
1905
|
+
if attestation_error is not None:
|
|
1906
|
+
entry["model_effective"] = None
|
|
1907
|
+
elif engine_session_log is None:
|
|
1288
1908
|
entry["model_effective"] = None
|
|
1289
1909
|
if retained_session is not None:
|
|
1290
1910
|
attestation_error = (
|
|
1291
1911
|
"BLOCKED:model-attestation-failed: --engine-session-log is required because "
|
|
1292
1912
|
f"retained worker session exists: {retained_session}"
|
|
1293
1913
|
)
|
|
1914
|
+
elif schema_v3_worker:
|
|
1915
|
+
receipt_path = devlyn / f"{artifact_phase}.invocation.{entry.get('round')}.json"
|
|
1916
|
+
try:
|
|
1917
|
+
receipt = invocation_receipt_module().validate_receipt(
|
|
1918
|
+
work.resolve(), receipt_path,
|
|
1919
|
+
run_id=state.get("run_id"),
|
|
1920
|
+
phase=phase,
|
|
1921
|
+
round_=entry.get("round"),
|
|
1922
|
+
model=entry.get("model_requested"),
|
|
1923
|
+
prompt_sha256=entry.get("prompt_sha256"),
|
|
1924
|
+
session_path=retained_session,
|
|
1925
|
+
)
|
|
1926
|
+
if receipt["exit_code"] != 0:
|
|
1927
|
+
raise invocation_receipt_module().ReceiptError(
|
|
1928
|
+
f"Codex invocation exited {receipt['exit_code']}"
|
|
1929
|
+
)
|
|
1930
|
+
role_argv = bind_worker_role_argv(state, phase, entry, devlyn, receipt)
|
|
1931
|
+
if role_argv is not None:
|
|
1932
|
+
entry["role_argv"] = role_argv
|
|
1933
|
+
entry["model_effective"] = None # The receipt binds argv, not an observed model.
|
|
1934
|
+
entry["invocation_receipt"] = receipt
|
|
1935
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
1936
|
+
entry["model_effective"] = None
|
|
1937
|
+
attestation_error = f"BLOCKED:invocation-receipt-invalid: {exc}"
|
|
1294
1938
|
else:
|
|
1295
1939
|
try:
|
|
1296
1940
|
entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
|
|
@@ -1333,6 +1977,7 @@ def do_transition(
|
|
|
1333
1977
|
next_prompt_sha256: str | None = None,
|
|
1334
1978
|
next_untracked_before: list[str] | None = None,
|
|
1335
1979
|
between=None,
|
|
1980
|
+
work: pathlib.Path | None = None,
|
|
1336
1981
|
) -> dict:
|
|
1337
1982
|
"""Validate complete + caller-selected spawn against a copy of state.
|
|
1338
1983
|
|
|
@@ -1346,7 +1991,7 @@ def do_transition(
|
|
|
1346
1991
|
candidate = copy.deepcopy(state)
|
|
1347
1992
|
attestation_error = do_complete(
|
|
1348
1993
|
candidate, phase, verdict, post_sha, findings_file, log_file,
|
|
1349
|
-
engine, model, engine_session_log, devlyn,
|
|
1994
|
+
engine, model, engine_session_log, devlyn, work,
|
|
1350
1995
|
)
|
|
1351
1996
|
if attestation_error is not None:
|
|
1352
1997
|
raise SystemExit(attestation_error)
|
|
@@ -1358,6 +2003,7 @@ def do_transition(
|
|
|
1358
2003
|
input_patch_sha256=next_input_patch_sha256,
|
|
1359
2004
|
prompt_sha256=next_prompt_sha256,
|
|
1360
2005
|
untracked_before=next_untracked_before,
|
|
2006
|
+
devlyn=devlyn,
|
|
1361
2007
|
)
|
|
1362
2008
|
return candidate
|
|
1363
2009
|
|
|
@@ -1377,14 +2023,387 @@ def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str |
|
|
|
1377
2023
|
return None
|
|
1378
2024
|
|
|
1379
2025
|
|
|
2026
|
+
def final_report_self_test() -> None:
|
|
2027
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2028
|
+
work = pathlib.Path(tmp)
|
|
2029
|
+
devlyn = work / ".devlyn"
|
|
2030
|
+
devlyn.mkdir()
|
|
2031
|
+
state_path = devlyn / "pipeline.state.json"
|
|
2032
|
+
report = devlyn / "final-report.md"
|
|
2033
|
+
script = pathlib.Path(__file__).resolve()
|
|
2034
|
+
archive = script.with_name("archive_run.py")
|
|
2035
|
+
run_id = "rs-final-report-0126"
|
|
2036
|
+
write_state(state_path, {"version": "3.0", "run_id": run_id, "engine": "claude", "phases": {}})
|
|
2037
|
+
command = [sys.executable, str(script), "--devlyn-dir", ".devlyn", "--phase", "final_report"]
|
|
2038
|
+
spawned = subprocess.run(command + ["spawn", "--round", "0"], cwd=work, capture_output=True, text=True)
|
|
2039
|
+
assert spawned.returncode == 0, spawned.stderr
|
|
2040
|
+
before = state_path.read_bytes()
|
|
2041
|
+
marker = f"<!-- devlyn:final-report run_id={run_id} -->\n"
|
|
2042
|
+
valid = (marker + "# Report\nPASS_WITH_ISSUES — retained LOW finding.\n").encode("utf-8")
|
|
2043
|
+
outside = work / "other.md"
|
|
2044
|
+
outside.write_bytes(valid)
|
|
2045
|
+
cases = (
|
|
2046
|
+
"missing-argument", "missing-file", "empty", "blank-body", "stale",
|
|
2047
|
+
"missing-marker", "duplicate-marker", "invalid-utf8", "directory", "symlink", "wrong-path",
|
|
2048
|
+
)
|
|
2049
|
+
for case in cases:
|
|
2050
|
+
if report.is_dir() and not report.is_symlink():
|
|
2051
|
+
report.rmdir()
|
|
2052
|
+
else:
|
|
2053
|
+
report.unlink(missing_ok=True)
|
|
2054
|
+
args = ["--log-file", ".devlyn/final-report.md"]
|
|
2055
|
+
if case == "missing-argument":
|
|
2056
|
+
args = []
|
|
2057
|
+
elif case == "empty":
|
|
2058
|
+
report.write_bytes(b"")
|
|
2059
|
+
elif case == "blank-body":
|
|
2060
|
+
report.write_text(marker + " \t\n", encoding="utf-8")
|
|
2061
|
+
elif case == "stale":
|
|
2062
|
+
report.write_bytes(valid.replace(run_id.encode(), b"another-run"))
|
|
2063
|
+
elif case == "missing-marker":
|
|
2064
|
+
report.write_bytes(b"# Report\nBLOCKED\n")
|
|
2065
|
+
elif case == "duplicate-marker":
|
|
2066
|
+
report.write_bytes(valid + marker.encode())
|
|
2067
|
+
elif case == "invalid-utf8":
|
|
2068
|
+
report.write_bytes(marker.encode() + b"\xff")
|
|
2069
|
+
elif case == "directory":
|
|
2070
|
+
report.mkdir()
|
|
2071
|
+
elif case == "symlink":
|
|
2072
|
+
report.symlink_to(outside)
|
|
2073
|
+
elif case == "wrong-path":
|
|
2074
|
+
report.write_bytes(valid)
|
|
2075
|
+
args = ["--log-file", str(outside)]
|
|
2076
|
+
result = subprocess.run(command + ["complete", "--verdict", "PASS_WITH_ISSUES", *args],
|
|
2077
|
+
cwd=work, capture_output=True, text=True)
|
|
2078
|
+
assert result.returncode == 1 and "BLOCKED:final-report-invalid" in result.stderr, (case, result)
|
|
2079
|
+
assert state_path.read_bytes() == before, case
|
|
2080
|
+
report.write_bytes(valid)
|
|
2081
|
+
result = subprocess.run(command + ["complete", "--verdict", "PASS_WITH_ISSUES", "--log-file", ".devlyn/final-report.md"],
|
|
2082
|
+
cwd=work, capture_output=True, text=True)
|
|
2083
|
+
assert result.returncode == 0, result.stderr
|
|
2084
|
+
completed_bytes = state_path.read_bytes()
|
|
2085
|
+
completed = read_state(state_path)
|
|
2086
|
+
final = completed["phases"]["final_report"]
|
|
2087
|
+
assert final["completed_at"] is not None and final["verdict"] == "PASS_WITH_ISSUES"
|
|
2088
|
+
assert final["artifacts"]["log_file"] == ".devlyn/final-report.md"
|
|
2089
|
+
assert final["output_sha256"] == hashlib.sha256(valid).hexdigest()
|
|
2090
|
+
archive_command = [sys.executable, str(archive), "--devlyn-dir", ".devlyn"]
|
|
2091
|
+
for case in ("missing", "altered", "null-digest", "malformed-digest", "wrong-path", "symlink"):
|
|
2092
|
+
candidate = copy.deepcopy(completed)
|
|
2093
|
+
report.unlink(missing_ok=True)
|
|
2094
|
+
report.write_bytes(valid)
|
|
2095
|
+
if case == "missing":
|
|
2096
|
+
report.unlink()
|
|
2097
|
+
elif case == "altered":
|
|
2098
|
+
report.write_bytes(valid + b"changed after completion\n")
|
|
2099
|
+
elif case in {"null-digest", "malformed-digest"}:
|
|
2100
|
+
candidate["phases"]["final_report"]["output_sha256"] = None if case == "null-digest" else "invalid"
|
|
2101
|
+
elif case == "wrong-path":
|
|
2102
|
+
candidate["phases"]["final_report"]["artifacts"]["log_file"] = str(outside)
|
|
2103
|
+
else:
|
|
2104
|
+
report.unlink()
|
|
2105
|
+
report.symlink_to(outside)
|
|
2106
|
+
write_state(state_path, candidate)
|
|
2107
|
+
before_archive = state_path.read_bytes()
|
|
2108
|
+
result = subprocess.run(archive_command, cwd=work, capture_output=True, text=True)
|
|
2109
|
+
assert result.returncode == 1 and "error: archive blocked:" in result.stderr, (case, result)
|
|
2110
|
+
assert state_path.read_bytes() == before_archive, case
|
|
2111
|
+
assert not (devlyn / "runs").exists(), case
|
|
2112
|
+
if case != "missing":
|
|
2113
|
+
assert report.is_symlink() if case == "symlink" else report.read_bytes() == (
|
|
2114
|
+
valid + b"changed after completion\n" if case == "altered" else valid
|
|
2115
|
+
), case
|
|
2116
|
+
report.unlink()
|
|
2117
|
+
report.write_bytes(valid)
|
|
2118
|
+
state_path.write_bytes(completed_bytes)
|
|
2119
|
+
result = subprocess.run(archive_command, cwd=work, capture_output=True, text=True)
|
|
2120
|
+
assert result.returncode == 0, result.stderr
|
|
2121
|
+
target = devlyn / "runs" / run_id
|
|
2122
|
+
assert (target / "final-report.md").read_bytes() == valid
|
|
2123
|
+
assert (target / "pipeline.state.json").read_bytes() == completed_bytes
|
|
2124
|
+
assert not state_path.exists() and not report.exists()
|
|
2125
|
+
assert outside.read_bytes() == valid
|
|
2126
|
+
print("PASS iter-0126 final report: 11 invalid CLI completions preserve state; exact binding/archive; 6 archive refusals preserve artifacts")
|
|
2127
|
+
|
|
2128
|
+
|
|
1380
2129
|
def self_test() -> int:
|
|
1381
2130
|
import time
|
|
1382
2131
|
|
|
2132
|
+
final_report_self_test()
|
|
2133
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2134
|
+
work = pathlib.Path(tmp); devlyn = work / ".devlyn"; devlyn.mkdir()
|
|
2135
|
+
helper = role_config_module()
|
|
2136
|
+
config = {"roles": {"worker": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"},
|
|
2137
|
+
"primary_judge": {"engine": "claude"}}}
|
|
2138
|
+
(devlyn / "engines.json").write_bytes(helper["encoded"](config))
|
|
2139
|
+
state = {"version": "3.0", "engine": "codex", "engine_source": "default", "phases": {}}
|
|
2140
|
+
frozen = freeze_roles(state, work, "codex")
|
|
2141
|
+
(devlyn / "engines.json").write_text('{"executor":"claude"}')
|
|
2142
|
+
assert freeze_roles(state, work, "codex") == frozen
|
|
2143
|
+
do_spawn(state, "verify", 0, None, None, None, None, devlyn=devlyn)
|
|
2144
|
+
assert state["engine"] == "codex" and state["phases"]["verify"]["engine"] == "claude"
|
|
2145
|
+
for phase in ("implement", "cleanup"):
|
|
2146
|
+
before = copy.deepcopy(state)
|
|
2147
|
+
try:
|
|
2148
|
+
do_spawn(state, phase, 0, None, None, "claude", None, devlyn=devlyn)
|
|
2149
|
+
except SystemExit:
|
|
2150
|
+
pass
|
|
2151
|
+
else:
|
|
2152
|
+
raise AssertionError("wrong worker engine accepted")
|
|
2153
|
+
assert state == before
|
|
2154
|
+
argv = ["--json", "-m", "gpt-6-astra", "-c", "model_reasoning_effort=high", "task"]
|
|
2155
|
+
receipt = {"argv_sha256": hashlib.sha256(json.dumps(argv, separators=(",", ":")).encode()).hexdigest()}
|
|
2156
|
+
path = devlyn / "implement.argv.0.json"
|
|
2157
|
+
path.write_text(json.dumps(argv))
|
|
2158
|
+
binding = bind_worker_role_argv(state, "implement", {"round": 0}, devlyn, receipt)
|
|
2159
|
+
assert binding["effort_requested"] == "high"
|
|
2160
|
+
path.write_text(json.dumps([*argv, "extra"]))
|
|
2161
|
+
try:
|
|
2162
|
+
bind_worker_role_argv(state, "implement", {"round": 0}, devlyn, receipt)
|
|
2163
|
+
except ValueError:
|
|
2164
|
+
pass
|
|
2165
|
+
else:
|
|
2166
|
+
raise AssertionError("modified worker argv accepted")
|
|
2167
|
+
assert bind_worker_role_argv(state, "build_gate", {}, devlyn, receipt) is None
|
|
2168
|
+
print("PASS explicit roles: frozen primary/worker routing and actual worker argv binding")
|
|
2169
|
+
|
|
2170
|
+
try:
|
|
2171
|
+
loads_strict_json('{"process_evidence":null,"process_evidence":[]}')
|
|
2172
|
+
except ValueError as exc:
|
|
2173
|
+
assert "duplicate JSON key" in str(exc)
|
|
2174
|
+
else:
|
|
2175
|
+
raise AssertionError("duplicate pipeline state key was accepted")
|
|
2176
|
+
|
|
1383
2177
|
with tempfile.TemporaryDirectory() as tmp:
|
|
1384
2178
|
devlyn = pathlib.Path(tmp)
|
|
1385
2179
|
state_path = devlyn / "pipeline.state.json"
|
|
1386
2180
|
write_state(state_path, {"phases": {}})
|
|
1387
2181
|
|
|
2182
|
+
# Iter-0089 P-0089-1/2/6: PLAN authorization is a CLI-level,
|
|
2183
|
+
# state-derived ledger contract. Rejections must leave the actual
|
|
2184
|
+
# state file byte-identical, not merely preserve an in-memory copy.
|
|
2185
|
+
script = str(pathlib.Path(__file__).resolve())
|
|
2186
|
+
digest0 = hashlib.sha256(b"plan prompt round 0").hexdigest()
|
|
2187
|
+
digest1 = hashlib.sha256(b"plan prompt round 1").hexdigest()
|
|
2188
|
+
|
|
2189
|
+
def plan_cli(*event_args: str) -> subprocess.CompletedProcess[str]:
|
|
2190
|
+
return subprocess.run(
|
|
2191
|
+
[
|
|
2192
|
+
sys.executable, script, "--devlyn-dir", str(devlyn),
|
|
2193
|
+
"--phase", "plan", *event_args,
|
|
2194
|
+
],
|
|
2195
|
+
capture_output=True, text=True, check=False,
|
|
2196
|
+
)
|
|
2197
|
+
|
|
2198
|
+
def assert_plan_rejected_unchanged(
|
|
2199
|
+
expected_stderr: str, *event_args: str,
|
|
2200
|
+
) -> None:
|
|
2201
|
+
before = state_path.read_bytes()
|
|
2202
|
+
before_hash = hashlib.sha256(before).hexdigest()
|
|
2203
|
+
result = plan_cli(*event_args)
|
|
2204
|
+
assert result.returncode != 0, result.stdout
|
|
2205
|
+
assert result.stderr.strip() == expected_stderr, result.stderr
|
|
2206
|
+
after = state_path.read_bytes()
|
|
2207
|
+
assert after == before
|
|
2208
|
+
assert hashlib.sha256(after).hexdigest() == before_hash
|
|
2209
|
+
|
|
2210
|
+
required_spawn = (
|
|
2211
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
2212
|
+
"--model", "plan-test-model", "--prompt-sha256", digest0,
|
|
2213
|
+
)
|
|
2214
|
+
assert_plan_rejected_unchanged(
|
|
2215
|
+
"error: phases.plan spawn requires --engine",
|
|
2216
|
+
"spawn", "--round", "0", "--model", "plan-test-model",
|
|
2217
|
+
"--prompt-sha256", digest0,
|
|
2218
|
+
)
|
|
2219
|
+
assert_plan_rejected_unchanged(
|
|
2220
|
+
"error: phases.plan spawn requires --model",
|
|
2221
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
2222
|
+
"--prompt-sha256", digest0,
|
|
2223
|
+
)
|
|
2224
|
+
assert_plan_rejected_unchanged(
|
|
2225
|
+
"error: phases.plan spawn requires --prompt-sha256",
|
|
2226
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
2227
|
+
"--model", "plan-test-model",
|
|
2228
|
+
)
|
|
2229
|
+
assert_plan_rejected_unchanged(
|
|
2230
|
+
"error: phases.plan spawn requires --prompt-sha256",
|
|
2231
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
2232
|
+
"--model", "plan-test-model", "--prompt-sha256", "ABC",
|
|
2233
|
+
)
|
|
2234
|
+
assert_plan_rejected_unchanged(
|
|
2235
|
+
"BLOCKED:plan-round-nonmonotonic: expected=0 supplied=1",
|
|
2236
|
+
"spawn", "--round", "1", "--engine", "claude",
|
|
2237
|
+
"--model", "plan-test-model", "--prompt-sha256", digest0,
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
result = plan_cli(*required_spawn)
|
|
2241
|
+
assert result.returncode == 0, result.stderr
|
|
2242
|
+
plan0 = read_state(state_path)["phases"]["plan"]
|
|
2243
|
+
assert {field: plan0[field] for field in PLAN_SPAWN_RECEIPT_FIELDS} == {
|
|
2244
|
+
"round": 0,
|
|
2245
|
+
"started_at": plan0["started_at"],
|
|
2246
|
+
"triggered_by": None,
|
|
2247
|
+
"engine": "claude",
|
|
2248
|
+
"model_requested": "plan-test-model",
|
|
2249
|
+
"prompt_sha256": digest0,
|
|
2250
|
+
}
|
|
2251
|
+
result = plan_cli("complete", "--verdict", "NEEDS_WORK")
|
|
2252
|
+
assert result.returncode == 0, result.stderr
|
|
2253
|
+
completed0 = read_state(state_path)["phases"]["plan"]
|
|
2254
|
+
assert all(field in completed0 for field in PLAN_RECEIPT_FIELDS)
|
|
2255
|
+
assert completed0["model_effective"] is None
|
|
2256
|
+
assert completed0["completed_at"] is not None
|
|
2257
|
+
assert completed0["duration_ms"] >= 0
|
|
2258
|
+
assert_plan_rejected_unchanged(
|
|
2259
|
+
"BLOCKED:plan-round-nonmonotonic: expected=1 supplied=0",
|
|
2260
|
+
"spawn", "--round", "0", "--triggered-by", "plan",
|
|
2261
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
2262
|
+
"--prompt-sha256", digest1,
|
|
2263
|
+
)
|
|
2264
|
+
assert_plan_rejected_unchanged(
|
|
2265
|
+
"BLOCKED:plan-round-nonmonotonic: expected=1 supplied=2",
|
|
2266
|
+
"spawn", "--round", "2", "--triggered-by", "plan",
|
|
2267
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
2268
|
+
"--prompt-sha256", digest1,
|
|
2269
|
+
)
|
|
2270
|
+
assert_plan_rejected_unchanged(
|
|
2271
|
+
"error: phases.plan re-spawn requires --triggered-by",
|
|
2272
|
+
"spawn", "--round", "1", "--engine", "claude",
|
|
2273
|
+
"--model", "plan-test-model", "--prompt-sha256", digest1,
|
|
2274
|
+
)
|
|
2275
|
+
result = plan_cli(
|
|
2276
|
+
"spawn", "--round", "1", "--triggered-by", "plan",
|
|
2277
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
2278
|
+
"--prompt-sha256", digest1,
|
|
2279
|
+
)
|
|
2280
|
+
assert result.returncode == 0, result.stderr
|
|
2281
|
+
plan1 = read_state(state_path)["phases"]["plan"]
|
|
2282
|
+
assert len(plan1["history"]) == 1
|
|
2283
|
+
assert set(plan1["history"][0]) == set(PLAN_RECEIPT_FIELDS)
|
|
2284
|
+
assert plan1["history"][0]["prompt_sha256"] == digest0
|
|
2285
|
+
assert plan1["prompt_sha256"] == digest1
|
|
2286
|
+
guard_error = (
|
|
2287
|
+
"error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
|
|
2288
|
+
"--phase plan transition --verdict <verdict> --next-phase <phase>"
|
|
2289
|
+
)
|
|
2290
|
+
assert_plan_rejected_unchanged(
|
|
2291
|
+
guard_error, "complete", "--verdict", "PASS",
|
|
2292
|
+
)
|
|
2293
|
+
assert_plan_rejected_unchanged(
|
|
2294
|
+
guard_error, "complete", "--verdict", "PASS_WITH_ISSUES",
|
|
2295
|
+
)
|
|
2296
|
+
result = plan_cli(
|
|
2297
|
+
"transition", "--verdict", "PASS", "--next-phase", "implement",
|
|
2298
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
2299
|
+
)
|
|
2300
|
+
assert result.returncode == 0, result.stderr
|
|
2301
|
+
for supplied_round in (0, 1, 2):
|
|
2302
|
+
assert_plan_rejected_unchanged(
|
|
2303
|
+
"BLOCKED:plan-respawn-exhausted",
|
|
2304
|
+
"spawn", "--round", str(supplied_round),
|
|
2305
|
+
"--triggered-by", "plan", "--engine", "claude",
|
|
2306
|
+
"--model", "plan-test-model", "--prompt-sha256", digest1,
|
|
2307
|
+
)
|
|
2308
|
+
old_receipt = {
|
|
2309
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
2310
|
+
"verdict": "PASS",
|
|
2311
|
+
"completed_at": "2026-01-01T00:00:01.000Z",
|
|
2312
|
+
"duration_ms": 1000,
|
|
2313
|
+
}
|
|
2314
|
+
assert not is_plan_dispatch_receipt(old_receipt)
|
|
2315
|
+
assert is_plan_dispatch_receipt(read_state(state_path)["phases"]["plan"])
|
|
2316
|
+
|
|
2317
|
+
# Iter-0112: schema-v3 PLAN output is an immutable authority receipt.
|
|
2318
|
+
plan_output = devlyn / "plan.md"
|
|
2319
|
+
plan_output.write_text("# Authorized plan\n- config/skills/x.py\n", encoding="utf-8")
|
|
2320
|
+
write_state(state_path, {"version": "3.0", "phases": {}})
|
|
2321
|
+
result = plan_cli(*required_spawn)
|
|
2322
|
+
assert result.returncode == 0, result.stderr
|
|
2323
|
+
result = plan_cli(
|
|
2324
|
+
"transition", "--verdict", "PASS", "--next-phase", "implement",
|
|
2325
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
2326
|
+
)
|
|
2327
|
+
assert result.returncode == 0, result.stderr
|
|
2328
|
+
sealed_plan_state = read_state(state_path)
|
|
2329
|
+
assert sealed_plan_state["phases"]["plan"]["output_sha256"] == hashlib.sha256(
|
|
2330
|
+
plan_output.read_bytes()
|
|
2331
|
+
).hexdigest()
|
|
2332
|
+
plan_output.write_text(
|
|
2333
|
+
"# Authorized plan\n- config/skills/x.py\n- unapproved.py\n",
|
|
2334
|
+
encoding="utf-8",
|
|
2335
|
+
)
|
|
2336
|
+
before = state_path.read_bytes()
|
|
2337
|
+
blocked_plan_mutation = subprocess.run(
|
|
2338
|
+
[
|
|
2339
|
+
sys.executable, script, "--devlyn-dir", str(devlyn),
|
|
2340
|
+
"--phase", "build_gate", "spawn", "--round", "0",
|
|
2341
|
+
],
|
|
2342
|
+
capture_output=True, text=True, check=False,
|
|
2343
|
+
)
|
|
2344
|
+
assert blocked_plan_mutation.returncode != 0
|
|
2345
|
+
assert "BLOCKED:plan-integrity-mismatch" in blocked_plan_mutation.stderr
|
|
2346
|
+
assert state_path.read_bytes() == before
|
|
2347
|
+
assert_plan_rejected_unchanged(
|
|
2348
|
+
"error: phases.plan already completed — respawn before completing again",
|
|
2349
|
+
"complete", "--verdict", "NEEDS_WORK",
|
|
2350
|
+
)
|
|
2351
|
+
print("PASS iter-0112 PLAN output digest blocks mid-flight widening")
|
|
2352
|
+
|
|
2353
|
+
write_state(state_path, {
|
|
2354
|
+
"phases": {
|
|
2355
|
+
"plan": {
|
|
2356
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
2357
|
+
"completed_at": None,
|
|
2358
|
+
"duration_ms": None,
|
|
2359
|
+
"round": 0,
|
|
2360
|
+
"triggered_by": None,
|
|
2361
|
+
"verdict": None,
|
|
2362
|
+
},
|
|
2363
|
+
"implement": None,
|
|
2364
|
+
}
|
|
2365
|
+
})
|
|
2366
|
+
result = plan_cli("complete", "--verdict", "BLOCKED")
|
|
2367
|
+
assert result.returncode == 0, result.stderr
|
|
2368
|
+
assert read_state(state_path)["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
2369
|
+
write_state(state_path, {
|
|
2370
|
+
"phases": {
|
|
2371
|
+
"plan": {
|
|
2372
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
2373
|
+
"completed_at": None,
|
|
2374
|
+
"duration_ms": None,
|
|
2375
|
+
"round": 0,
|
|
2376
|
+
"triggered_by": None,
|
|
2377
|
+
"verdict": None,
|
|
2378
|
+
},
|
|
2379
|
+
"implement": None,
|
|
2380
|
+
}
|
|
2381
|
+
})
|
|
2382
|
+
result = plan_cli(
|
|
2383
|
+
"complete", "--verdict", "PASS", "--engine-session-log",
|
|
2384
|
+
str(devlyn / "missing-session.jsonl"),
|
|
2385
|
+
)
|
|
2386
|
+
assert result.returncode == 1, result.stderr
|
|
2387
|
+
assert "BLOCKED:model-attestation-failed" in result.stderr
|
|
2388
|
+
assert guard_error not in result.stderr
|
|
2389
|
+
attestation_blocked = read_state(state_path)["phases"]["plan"]
|
|
2390
|
+
assert attestation_blocked["verdict"] == "BLOCKED"
|
|
2391
|
+
assert attestation_blocked["completed_at"] is not None
|
|
2392
|
+
assert attestation_blocked["model_effective"] is None
|
|
2393
|
+
legacy_state = {"phases": {"plan": old_receipt | {"round": 0}}}
|
|
2394
|
+
write_state(state_path, legacy_state)
|
|
2395
|
+
result = plan_cli(
|
|
2396
|
+
"spawn", "--round", "1", "--triggered-by", "plan",
|
|
2397
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
2398
|
+
"--prompt-sha256", digest1,
|
|
2399
|
+
)
|
|
2400
|
+
assert result.returncode == 0, result.stderr
|
|
2401
|
+
archived_legacy = read_state(state_path)["phases"]["plan"]["history"][0]
|
|
2402
|
+
assert archived_legacy == old_receipt
|
|
2403
|
+
print("PASS iter-0089 PLAN ledger: P-0089-1/2/6")
|
|
2404
|
+
|
|
2405
|
+
write_state(state_path, {"phases": {}})
|
|
2406
|
+
|
|
1388
2407
|
# Round 0: spawn -> complete.
|
|
1389
2408
|
state = read_state(state_path)
|
|
1390
2409
|
do_spawn(state, "implement", 0, None, None, "claude", None)
|
|
@@ -1430,15 +2449,552 @@ def self_test() -> int:
|
|
|
1430
2449
|
write_state(state_path, state)
|
|
1431
2450
|
final = read_state(state_path)["phases"]["implement"]
|
|
1432
2451
|
assert final["verdict"] == "PASS"
|
|
2452
|
+
assert read_state(state_path)["process_evidence"] is None
|
|
1433
2453
|
assert parse_iso(final["started_at"]) == parse_iso(respawned["started_at"])
|
|
1434
2454
|
assert parse_iso(final["completed_at"]) >= parse_iso(final["started_at"])
|
|
1435
2455
|
assert final["artifacts"]["findings_file"] == ".devlyn/x.jsonl"
|
|
1436
2456
|
|
|
1437
|
-
#
|
|
1438
|
-
|
|
1439
|
-
state
|
|
1440
|
-
|
|
1441
|
-
|
|
2457
|
+
# Iter-0111 R2: successful IMPLEMENT completion and transition both
|
|
2458
|
+
# validate declared evidence before any lifecycle mutation, then bind
|
|
2459
|
+
# the exact manifest and stream digests into state. Legacy runs with no
|
|
2460
|
+
# declaration remain legal through the null carrier asserted above.
|
|
2461
|
+
evidence_work = devlyn / "process-evidence-state"
|
|
2462
|
+
evidence_devlyn = evidence_work / ".devlyn"
|
|
2463
|
+
evidence_spec_dir = evidence_work / "docs" / "evidence"
|
|
2464
|
+
evidence_devlyn.mkdir(parents=True)
|
|
2465
|
+
evidence_spec_dir.mkdir(parents=True)
|
|
2466
|
+
(evidence_spec_dir / "spec.md").write_text("# Evidence fixture\n", encoding="utf-8")
|
|
2467
|
+
(evidence_spec_dir / "spec.expected.json").write_text(json.dumps({
|
|
2468
|
+
"verification_commands": [{"cmd": "printf build-gate"}],
|
|
2469
|
+
"process_evidence": [{
|
|
2470
|
+
"id": "red-first",
|
|
2471
|
+
"phase": "implement",
|
|
2472
|
+
"cmd": "printf red-before-fix >&2; exit 7",
|
|
2473
|
+
"exit_code": 7,
|
|
2474
|
+
"stdout_contains": ["red-before-fix"],
|
|
2475
|
+
}],
|
|
2476
|
+
}) + "\n", encoding="utf-8")
|
|
2477
|
+
evidence_state = {
|
|
2478
|
+
"run_id": "rs-state-evidence",
|
|
2479
|
+
"source": {"type": "spec", "spec_path": "docs/evidence/spec.md"},
|
|
2480
|
+
"phases": {"implement": None, "build_gate": None},
|
|
2481
|
+
}
|
|
2482
|
+
do_spawn(evidence_state, "implement", 0, None, None, "codex", None)
|
|
2483
|
+
evidence_state_path = evidence_devlyn / "pipeline.state.json"
|
|
2484
|
+
write_state(evidence_state_path, evidence_state)
|
|
2485
|
+
evidence_script = str(pathlib.Path(__file__).resolve())
|
|
2486
|
+
|
|
2487
|
+
def evidence_state_cli(event: str, *event_args: str) -> subprocess.CompletedProcess[str]:
|
|
2488
|
+
return subprocess.run(
|
|
2489
|
+
[
|
|
2490
|
+
sys.executable, evidence_script, "--devlyn-dir", ".devlyn",
|
|
2491
|
+
"--phase", "implement", event, *event_args,
|
|
2492
|
+
],
|
|
2493
|
+
cwd=evidence_work, capture_output=True, text=True, check=False,
|
|
2494
|
+
)
|
|
2495
|
+
|
|
2496
|
+
for event_args in (
|
|
2497
|
+
("complete", "--verdict", "PASS"),
|
|
2498
|
+
(
|
|
2499
|
+
"transition", "--verdict", "PASS", "--next-phase", "build_gate",
|
|
2500
|
+
"--next-round", "0",
|
|
2501
|
+
),
|
|
2502
|
+
):
|
|
2503
|
+
before = evidence_state_path.read_bytes()
|
|
2504
|
+
result = evidence_state_cli(event_args[0], *event_args[1:])
|
|
2505
|
+
assert result.returncode != 0
|
|
2506
|
+
assert "BLOCKED:process-evidence-invalid" in result.stderr
|
|
2507
|
+
assert "missing" in result.stderr
|
|
2508
|
+
assert evidence_state_path.read_bytes() == before
|
|
2509
|
+
|
|
2510
|
+
runner_script = str(pathlib.Path(__file__).with_name("process-evidence.py").resolve())
|
|
2511
|
+
captured = subprocess.run(
|
|
2512
|
+
[
|
|
2513
|
+
sys.executable, runner_script, "--devlyn-dir", ".devlyn", "run",
|
|
2514
|
+
"--phase", "implement", "--id", "red-first",
|
|
2515
|
+
],
|
|
2516
|
+
cwd=evidence_work, capture_output=True, text=True, check=False,
|
|
2517
|
+
)
|
|
2518
|
+
assert captured.returncode == 0, captured.stderr
|
|
2519
|
+
manifest_rel = loads_strict_json(captured.stdout)["manifest_path"]
|
|
2520
|
+
manifest = evidence_work / manifest_rel
|
|
2521
|
+
stderr_path = manifest.parent / "red-first.stderr"
|
|
2522
|
+
original_stderr = stderr_path.read_bytes()
|
|
2523
|
+
stderr_path.write_bytes(b"altered")
|
|
2524
|
+
before = evidence_state_path.read_bytes()
|
|
2525
|
+
altered = evidence_state_cli("complete", "--verdict", "PASS")
|
|
2526
|
+
assert altered.returncode != 0
|
|
2527
|
+
assert "digest or byte count mismatch" in altered.stderr
|
|
2528
|
+
assert evidence_state_path.read_bytes() == before
|
|
2529
|
+
stderr_path.write_bytes(original_stderr)
|
|
2530
|
+
transitioned = evidence_state_cli(
|
|
2531
|
+
"transition", "--verdict", "PASS", "--next-phase", "build_gate",
|
|
2532
|
+
"--next-round", "0",
|
|
2533
|
+
)
|
|
2534
|
+
assert transitioned.returncode == 0, transitioned.stderr
|
|
2535
|
+
sealed_state = read_state(evidence_state_path)
|
|
2536
|
+
carrier = sealed_state["process_evidence"][0]
|
|
2537
|
+
assert carrier["phase"] == "implement" and carrier["round"] == 0
|
|
2538
|
+
assert carrier["manifest"]["path"] == manifest_rel
|
|
2539
|
+
assert carrier["manifest"]["sha256"] == hashlib.sha256(manifest.read_bytes()).hexdigest()
|
|
2540
|
+
assert sealed_state["phases"]["implement"]["verdict"] == "PASS"
|
|
2541
|
+
assert sealed_state["phases"]["build_gate"]["started_at"] is not None
|
|
2542
|
+
print("PASS iter-0111 process evidence: completion/transition gate and state digest binding")
|
|
2543
|
+
|
|
2544
|
+
# Gate-discovered iter-0111 regression: BUILD_GATE MECHANICAL emitted
|
|
2545
|
+
# sealed process evidence, but completion left it outside the state
|
|
2546
|
+
# binding and archive_run.py correctly rejected the orphaned files.
|
|
2547
|
+
def build_gate_state_cli(
|
|
2548
|
+
event: str, *event_args: str,
|
|
2549
|
+
) -> subprocess.CompletedProcess[str]:
|
|
2550
|
+
return subprocess.run(
|
|
2551
|
+
[
|
|
2552
|
+
sys.executable, evidence_script, "--devlyn-dir", ".devlyn",
|
|
2553
|
+
"--phase", "build_gate", event, *event_args,
|
|
2554
|
+
],
|
|
2555
|
+
cwd=evidence_work, capture_output=True, text=True, check=False,
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2558
|
+
results_path = evidence_devlyn / "spec-verify.results.json"
|
|
2559
|
+
results_path.write_text(json.dumps({
|
|
2560
|
+
"commands": [{"pass": True}], "process_evidence": None,
|
|
2561
|
+
}) + "\n", encoding="utf-8")
|
|
2562
|
+
before = evidence_state_path.read_bytes()
|
|
2563
|
+
missing = build_gate_state_cli("complete", "--verdict", "PASS")
|
|
2564
|
+
assert missing.returncode != 0
|
|
2565
|
+
assert "commands exist without a process-evidence carrier" in missing.stderr
|
|
2566
|
+
assert evidence_state_path.read_bytes() == before
|
|
2567
|
+
results_path.write_text(json.dumps({
|
|
2568
|
+
"commands": [], "process_evidence": None,
|
|
2569
|
+
}) + "\n", encoding="utf-8")
|
|
2570
|
+
removed = build_gate_state_cli("complete", "--verdict", "PASS")
|
|
2571
|
+
assert removed.returncode != 0
|
|
2572
|
+
assert "required BUILD_GATE evidence has no process-evidence carrier" in removed.stderr
|
|
2573
|
+
assert evidence_state_path.read_bytes() == before
|
|
2574
|
+
|
|
2575
|
+
runner = process_evidence_module()
|
|
2576
|
+
|
|
2577
|
+
def write_build_gate_results() -> dict:
|
|
2578
|
+
current = read_state(evidence_state_path)
|
|
2579
|
+
round_ = runner.phase_round(current, "build_gate")
|
|
2580
|
+
obligation = runner.normalize_obligation({
|
|
2581
|
+
"id": "verification-command-0001",
|
|
2582
|
+
"phase": "build_gate",
|
|
2583
|
+
"argv": [sys.executable, "-c", "print('sealed build gate')"],
|
|
2584
|
+
})
|
|
2585
|
+
relative = runner.manifest_relative_path(current, "build_gate")
|
|
2586
|
+
runner.capture_process(
|
|
2587
|
+
evidence_work, evidence_work / relative, current["run_id"],
|
|
2588
|
+
"build_gate", round_, obligation,
|
|
2589
|
+
)
|
|
2590
|
+
build_carrier = runner.validate_manifest(
|
|
2591
|
+
evidence_work, relative, current["run_id"], "build_gate", round_,
|
|
2592
|
+
[obligation], require_expectations=False,
|
|
2593
|
+
)
|
|
2594
|
+
results_path.write_text(json.dumps({
|
|
2595
|
+
"commands": runner.bound_carrier_summary_commands(
|
|
2596
|
+
evidence_work, build_carrier,
|
|
2597
|
+
),
|
|
2598
|
+
"process_evidence": build_carrier,
|
|
2599
|
+
}) + "\n", encoding="utf-8")
|
|
2600
|
+
return build_carrier
|
|
2601
|
+
|
|
2602
|
+
build_carrier_0 = write_build_gate_results()
|
|
2603
|
+
completed = build_gate_state_cli("complete", "--verdict", "PASS")
|
|
2604
|
+
assert completed.returncode == 0, completed.stderr
|
|
2605
|
+
completed_state = read_state(evidence_state_path)
|
|
2606
|
+
assert completed_state["process_evidence"] == [carrier, build_carrier_0]
|
|
2607
|
+
|
|
2608
|
+
spawned = build_gate_state_cli(
|
|
2609
|
+
"spawn", "--round", "1", "--triggered-by", "build_gate",
|
|
2610
|
+
)
|
|
2611
|
+
assert spawned.returncode == 0, spawned.stderr
|
|
2612
|
+
build_carrier_1 = write_build_gate_results()
|
|
2613
|
+
transitioned = build_gate_state_cli(
|
|
2614
|
+
"transition", "--verdict", "PASS", "--next-phase", "cleanup",
|
|
2615
|
+
"--next-round", "0",
|
|
2616
|
+
)
|
|
2617
|
+
assert transitioned.returncode == 0, transitioned.stderr
|
|
2618
|
+
build_bound_state = read_state(evidence_state_path)
|
|
2619
|
+
assert build_bound_state["process_evidence"] == [
|
|
2620
|
+
carrier, build_carrier_0, build_carrier_1,
|
|
2621
|
+
]
|
|
2622
|
+
assert build_bound_state["phases"]["cleanup"]["started_at"] is not None
|
|
2623
|
+
print("PASS iter-0111 BUILD_GATE evidence: completion/transition state binding")
|
|
2624
|
+
|
|
2625
|
+
# Iter-0112 audit counterexample: a valid carrier previously authenticated
|
|
2626
|
+
# bytes only, so a failed entry or capability denial could still be completed
|
|
2627
|
+
# with caller-supplied PASS.
|
|
2628
|
+
build_bound_state["phases"]["build_gate"] = None
|
|
2629
|
+
do_spawn(build_bound_state, "build_gate", 2, "build_gate", None, None, None)
|
|
2630
|
+
write_state(evidence_state_path, build_bound_state)
|
|
2631
|
+
mismatch = runner.normalize_obligation({
|
|
2632
|
+
"id": "verification-command-0001",
|
|
2633
|
+
"phase": "build_gate",
|
|
2634
|
+
"argv": [sys.executable, "-c", "raise SystemExit(7)"],
|
|
2635
|
+
})
|
|
2636
|
+
mismatch_rel = runner.manifest_relative_path(build_bound_state, "build_gate")
|
|
2637
|
+
runner.capture_process(
|
|
2638
|
+
evidence_work, evidence_work / mismatch_rel, build_bound_state["run_id"],
|
|
2639
|
+
"build_gate", 2, mismatch,
|
|
2640
|
+
)
|
|
2641
|
+
mismatch_carrier = runner.validate_manifest(
|
|
2642
|
+
evidence_work, mismatch_rel, build_bound_state["run_id"], "build_gate", 2,
|
|
2643
|
+
[mismatch], require_expectations=False,
|
|
2644
|
+
)
|
|
2645
|
+
mismatch_commands = runner.bound_carrier_summary_commands(
|
|
2646
|
+
evidence_work, mismatch_carrier,
|
|
2647
|
+
)
|
|
2648
|
+
mismatch_commands[0]["pass"] = True
|
|
2649
|
+
results_path.write_text(json.dumps({
|
|
2650
|
+
"commands": mismatch_commands, "process_evidence": mismatch_carrier,
|
|
2651
|
+
}) + "\n", encoding="utf-8")
|
|
2652
|
+
before = evidence_state_path.read_bytes()
|
|
2653
|
+
laundered = build_gate_state_cli("complete", "--verdict", "PASS")
|
|
2654
|
+
assert laundered.returncode != 0
|
|
2655
|
+
assert "command 0 disagrees with sealed process evidence" in laundered.stderr
|
|
2656
|
+
assert evidence_state_path.read_bytes() == before
|
|
2657
|
+
results_path.write_text(json.dumps({
|
|
2658
|
+
"commands": [], "process_evidence": None,
|
|
2659
|
+
}) + "\n", encoding="utf-8")
|
|
2660
|
+
removed_after_capture = build_gate_state_cli("complete", "--verdict", "PASS")
|
|
2661
|
+
assert removed_after_capture.returncode != 0
|
|
2662
|
+
assert "manifest exists without a process-evidence carrier" in removed_after_capture.stderr
|
|
2663
|
+
assert evidence_state_path.read_bytes() == before
|
|
2664
|
+
results_path.write_text(json.dumps({
|
|
2665
|
+
"commands": runner.bound_carrier_summary_commands(
|
|
2666
|
+
evidence_work, mismatch_carrier,
|
|
2667
|
+
),
|
|
2668
|
+
"process_evidence": mismatch_carrier,
|
|
2669
|
+
}) + "\n", encoding="utf-8")
|
|
2670
|
+
failed = build_gate_state_cli("complete", "--verdict", "FAIL")
|
|
2671
|
+
assert failed.returncode == 0, failed.stderr
|
|
2672
|
+
|
|
2673
|
+
failed_state = read_state(evidence_state_path)
|
|
2674
|
+
do_spawn(failed_state, "build_gate", 3, "build_gate", None, None, None)
|
|
2675
|
+
write_state(evidence_state_path, failed_state)
|
|
2676
|
+
denied = runner.normalize_obligation({
|
|
2677
|
+
"id": "verification-command-0001",
|
|
2678
|
+
"phase": "build_gate",
|
|
2679
|
+
"cmd": "python3 -m pytest",
|
|
2680
|
+
})
|
|
2681
|
+
denied_rel = runner.manifest_relative_path(failed_state, "build_gate")
|
|
2682
|
+
runner.record_capability_denial(
|
|
2683
|
+
evidence_work, evidence_work / denied_rel, failed_state["run_id"],
|
|
2684
|
+
"build_gate", 3, denied, "subprocess", b"parent denied",
|
|
2685
|
+
)
|
|
2686
|
+
denied_carrier = runner.validate_manifest(
|
|
2687
|
+
evidence_work, denied_rel, failed_state["run_id"], "build_gate", 3,
|
|
2688
|
+
[denied], require_expectations=False,
|
|
2689
|
+
)
|
|
2690
|
+
denied_commands = runner.bound_carrier_summary_commands(
|
|
2691
|
+
evidence_work, denied_carrier,
|
|
2692
|
+
)
|
|
2693
|
+
denied_commands[0]["pass"] = True
|
|
2694
|
+
results_path.write_text(json.dumps({
|
|
2695
|
+
"commands": denied_commands, "process_evidence": denied_carrier,
|
|
2696
|
+
}) + "\n", encoding="utf-8")
|
|
2697
|
+
before = evidence_state_path.read_bytes()
|
|
2698
|
+
denial_laundered = build_gate_state_cli("complete", "--verdict", "PASS")
|
|
2699
|
+
assert denial_laundered.returncode != 0
|
|
2700
|
+
assert "command 0 disagrees with sealed process evidence" in denial_laundered.stderr
|
|
2701
|
+
assert evidence_state_path.read_bytes() == before
|
|
2702
|
+
results_path.write_text(json.dumps({
|
|
2703
|
+
"commands": runner.bound_carrier_summary_commands(
|
|
2704
|
+
evidence_work, denied_carrier,
|
|
2705
|
+
),
|
|
2706
|
+
"process_evidence": denied_carrier,
|
|
2707
|
+
}) + "\n", encoding="utf-8")
|
|
2708
|
+
blocked = build_gate_state_cli("complete", "--verdict", "BLOCKED")
|
|
2709
|
+
assert blocked.returncode == 0, blocked.stderr
|
|
2710
|
+
print("PASS iter-0112 BUILD_GATE sealed outcome owns the verdict floor")
|
|
2711
|
+
|
|
2712
|
+
def test_interrupted_build_gate() -> None:
|
|
2713
|
+
# Iter-0119 R1-R5: interrupted observations bind only to BLOCKED,
|
|
2714
|
+
# without changing their bytes or bypassing archive/receipt guards.
|
|
2715
|
+
work = (devlyn / "interrupted-build-gate").resolve()
|
|
2716
|
+
active = work / ".devlyn"
|
|
2717
|
+
active.mkdir(parents=True)
|
|
2718
|
+
state_file = active / "pipeline.state.json"
|
|
2719
|
+
summary = active / "spec-verify.results.json"
|
|
2720
|
+
runner = process_evidence_module()
|
|
2721
|
+
archive_spec = importlib.util.spec_from_file_location(
|
|
2722
|
+
"interrupted_archive", pathlib.Path(__file__).with_name("archive_run.py"),
|
|
2723
|
+
)
|
|
2724
|
+
assert archive_spec is not None and archive_spec.loader is not None
|
|
2725
|
+
archive = importlib.util.module_from_spec(archive_spec)
|
|
2726
|
+
archive_spec.loader.exec_module(archive)
|
|
2727
|
+
fixture = {
|
|
2728
|
+
"version": "3.0", "run_id": "rs-interrupted-build-gate",
|
|
2729
|
+
"source": {"type": "spec", "spec_path": "spec.md"},
|
|
2730
|
+
"phases": {"build_gate": None, "final_report": None},
|
|
2731
|
+
"process_evidence": None,
|
|
2732
|
+
}
|
|
2733
|
+
do_spawn(fixture, "build_gate", 0, None, None, "claude", None)
|
|
2734
|
+
write_state(state_file, fixture)
|
|
2735
|
+
open_bytes = state_file.read_bytes()
|
|
2736
|
+
relative = runner.manifest_relative_path(fixture, "build_gate")
|
|
2737
|
+
manifest = work / relative
|
|
2738
|
+
obligations = [
|
|
2739
|
+
runner.normalize_obligation({
|
|
2740
|
+
"id": f"verification-command-{index:04d}", "phase": "build_gate",
|
|
2741
|
+
"argv": [sys.executable, "-c", (
|
|
2742
|
+
"import os; os.write(1, b'observed\\x00\\xff\\n'); "
|
|
2743
|
+
f"os.write(2, b'diagnostic\\r\\n'); raise SystemExit({exit_code})"
|
|
2744
|
+
)],
|
|
2745
|
+
})
|
|
2746
|
+
for index, exit_code in enumerate((0, 7, 0), 1)
|
|
2747
|
+
]
|
|
2748
|
+
(work / "spec.md").write_text("# Interrupted build fixture\n", encoding="utf-8")
|
|
2749
|
+
(work / "spec.expected.json").write_text(json.dumps({
|
|
2750
|
+
"process_evidence": obligations,
|
|
2751
|
+
}), encoding="utf-8")
|
|
2752
|
+
assert runner.declared_obligations(work, fixture, "build_gate") == obligations
|
|
2753
|
+
for obligation in obligations[:2]:
|
|
2754
|
+
runner.capture_process(
|
|
2755
|
+
work, manifest, fixture["run_id"], "build_gate", 0, obligation,
|
|
2756
|
+
)
|
|
2757
|
+
manifest_bytes = manifest.read_bytes()
|
|
2758
|
+
document = loads_strict_json(manifest_bytes.decode("utf-8"))
|
|
2759
|
+
assert [entry["expectation_met"] for entry in document["entries"]] == [True, False]
|
|
2760
|
+
assert [entry["outcome"] for entry in document["entries"]] == [
|
|
2761
|
+
{"kind": "exit", "exit_code": code, "signal": None} for code in (0, 7)
|
|
2762
|
+
]
|
|
2763
|
+
observed = {path: path.read_bytes() for path in manifest.parent.iterdir()}
|
|
2764
|
+
assert set(observed) == {manifest} | {
|
|
2765
|
+
manifest.parent / f"verification-command-{index:04d}.{stream}"
|
|
2766
|
+
for index in (1, 2) for stream in ("stdout", "stderr")
|
|
2767
|
+
}
|
|
2768
|
+
carrier = runner.validate_manifest(
|
|
2769
|
+
work, relative, fixture["run_id"], "build_gate", 0,
|
|
2770
|
+
require_expectations=False,
|
|
2771
|
+
)
|
|
2772
|
+
cli_env = {key: value for key, value in os.environ.items()
|
|
2773
|
+
if not key.startswith("DEVLYN_INVOCATION_")}
|
|
2774
|
+
cli_env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
2775
|
+
|
|
2776
|
+
def cli(*args: str) -> subprocess.CompletedProcess[str]:
|
|
2777
|
+
return subprocess.run(
|
|
2778
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
2779
|
+
"--devlyn-dir", ".devlyn", "--phase", "build_gate", *args],
|
|
2780
|
+
cwd=work, env=cli_env, capture_output=True, text=True, check=False,
|
|
2781
|
+
)
|
|
2782
|
+
|
|
2783
|
+
def rejected(*args: str, error: str = "BLOCKED:process-evidence-invalid") -> None:
|
|
2784
|
+
before = state_file.read_bytes()
|
|
2785
|
+
result = cli(*args)
|
|
2786
|
+
assert result.returncode != 0, result.stdout
|
|
2787
|
+
assert error in result.stderr, result.stderr
|
|
2788
|
+
assert state_file.read_bytes() == before
|
|
2789
|
+
|
|
2790
|
+
def archived(state: dict) -> None:
|
|
2791
|
+
paths = archive.dynamic_evidence_artifacts(active, state)
|
|
2792
|
+
assert set(paths) == set(observed)
|
|
2793
|
+
assert {path: path.read_bytes() for path in paths} == observed
|
|
2794
|
+
assert {path: path.read_bytes() for path in manifest.parent.iterdir()} == observed
|
|
2795
|
+
|
|
2796
|
+
def terminal() -> dict:
|
|
2797
|
+
state = read_state(state_file)
|
|
2798
|
+
entry = state["phases"]["build_gate"]
|
|
2799
|
+
assert entry["verdict"] == "BLOCKED"
|
|
2800
|
+
assert entry["completed_at"] is not None and entry["duration_ms"] >= 0
|
|
2801
|
+
assert entry["model_effective"] is None
|
|
2802
|
+
assert entry.get("invocation_receipt") is None
|
|
2803
|
+
assert state["phases"]["final_report"] is None
|
|
2804
|
+
assert state["process_evidence"] == [carrier]
|
|
2805
|
+
assert not summary.exists() and not summary.is_symlink()
|
|
2806
|
+
archived(state)
|
|
2807
|
+
return state
|
|
2808
|
+
|
|
2809
|
+
completed = cli("complete", "--verdict", "BLOCKED")
|
|
2810
|
+
assert completed.returncode == 0, completed.stderr
|
|
2811
|
+
bound = terminal()
|
|
2812
|
+
print("PASS iter-0119 absent-summary BLOCKED persists exact five-file evidence")
|
|
2813
|
+
|
|
2814
|
+
for control in ("unbound", "tampered", "manifest", "binding", "malformed", "duplicate"):
|
|
2815
|
+
candidate = copy.deepcopy(bound)
|
|
2816
|
+
extra = manifest.parent / "unexpected.stdout"
|
|
2817
|
+
stream = manifest.parent / "verification-command-0001.stdout"
|
|
2818
|
+
if control == "unbound":
|
|
2819
|
+
extra.write_bytes(b"unbound")
|
|
2820
|
+
elif control == "tampered":
|
|
2821
|
+
stream.write_bytes(b"tampered")
|
|
2822
|
+
elif control == "manifest":
|
|
2823
|
+
manifest.write_text("{", encoding="utf-8")
|
|
2824
|
+
elif control == "malformed":
|
|
2825
|
+
candidate["process_evidence"] = [{}]
|
|
2826
|
+
elif control == "duplicate":
|
|
2827
|
+
candidate["process_evidence"].append(copy.deepcopy(carrier))
|
|
2828
|
+
else:
|
|
2829
|
+
candidate["process_evidence"][0]["manifest"]["sha256"] = "0" * 64
|
|
2830
|
+
try:
|
|
2831
|
+
archive.dynamic_evidence_artifacts(active, candidate)
|
|
2832
|
+
except archive.ArchiveError as exc:
|
|
2833
|
+
if control == "duplicate":
|
|
2834
|
+
assert "duplicate state-bound process-evidence" in str(exc), str(exc)
|
|
2835
|
+
else:
|
|
2836
|
+
assert ("unbound process-evidence" if control == "unbound" else
|
|
2837
|
+
"invalid bound process evidence") in str(exc), str(exc)
|
|
2838
|
+
else:
|
|
2839
|
+
raise AssertionError(f"archive accepted {control}")
|
|
2840
|
+
extra.unlink(missing_ok=True)
|
|
2841
|
+
stream.write_bytes(observed[stream])
|
|
2842
|
+
manifest.write_bytes(manifest_bytes)
|
|
2843
|
+
archived(bound)
|
|
2844
|
+
|
|
2845
|
+
state_file.write_bytes(open_bytes)
|
|
2846
|
+
for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "FAIL"):
|
|
2847
|
+
rejected("complete", "--verdict", verdict, error="spec-verify.results.json is missing")
|
|
2848
|
+
rejected("complete", error="spec-verify.results.json is missing")
|
|
2849
|
+
manifest.unlink()
|
|
2850
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2851
|
+
manifest.write_text("{", encoding="utf-8")
|
|
2852
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2853
|
+
for control in ("run_id", "phase", "round", "expectation", "expectation_met",
|
|
2854
|
+
"duplicate", "path", "bytes", "sha256"):
|
|
2855
|
+
altered = copy.deepcopy(document)
|
|
2856
|
+
entry = altered["entries"][1]
|
|
2857
|
+
if control in {"run_id", "phase", "round"}:
|
|
2858
|
+
altered[control] = {"run_id": "rs-wrong", "phase": "implement", "round": 1}[control]
|
|
2859
|
+
elif control == "expectation":
|
|
2860
|
+
entry["expectation"]["exit_code"] = "zero"
|
|
2861
|
+
elif control == "expectation_met":
|
|
2862
|
+
entry[control] = True
|
|
2863
|
+
elif control == "duplicate":
|
|
2864
|
+
altered["entries"].append(copy.deepcopy(entry))
|
|
2865
|
+
else:
|
|
2866
|
+
entry["stdout"][control] = {
|
|
2867
|
+
"path": "../escaped.stdout", "bytes": entry["stdout"]["bytes"] + 1,
|
|
2868
|
+
"sha256": "0" * 64,
|
|
2869
|
+
}[control]
|
|
2870
|
+
manifest.write_text(json.dumps(altered), encoding="utf-8")
|
|
2871
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2872
|
+
manifest.write_bytes(manifest_bytes)
|
|
2873
|
+
stream = manifest.parent / "verification-command-0001.stdout"
|
|
2874
|
+
stream.write_bytes(b"tampered")
|
|
2875
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2876
|
+
stream.write_bytes(observed[stream])
|
|
2877
|
+
outside = devlyn / "escaped-evidence"
|
|
2878
|
+
for path in (manifest, manifest.parent / "verification-command-0001.stdout"):
|
|
2879
|
+
outside.write_bytes(observed[path])
|
|
2880
|
+
path.unlink()
|
|
2881
|
+
path.symlink_to(outside)
|
|
2882
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2883
|
+
path.unlink()
|
|
2884
|
+
path.write_bytes(observed[path])
|
|
2885
|
+
bad_prior = copy.deepcopy(carrier)
|
|
2886
|
+
bad_prior["manifest"]["sha256"] = "0" * 64
|
|
2887
|
+
for invalid, error in (
|
|
2888
|
+
({}, "state.process_evidence must be null or an array"),
|
|
2889
|
+
([{}], "state process-evidence carrier has an invalid shape"),
|
|
2890
|
+
([bad_prior], "bound process evidence manifest digest mismatch"),
|
|
2891
|
+
([carrier], "duplicate state carrier"),
|
|
2892
|
+
):
|
|
2893
|
+
candidate = copy.deepcopy(fixture)
|
|
2894
|
+
candidate["process_evidence"] = invalid
|
|
2895
|
+
write_state(state_file, candidate)
|
|
2896
|
+
rejected("complete", "--verdict", "BLOCKED", error=error)
|
|
2897
|
+
state_file.write_bytes(open_bytes)
|
|
2898
|
+
|
|
2899
|
+
commands = runner.bound_carrier_summary_commands(work, carrier)
|
|
2900
|
+
wrong_commands = copy.deepcopy(commands)
|
|
2901
|
+
wrong_commands[1]["pass"] = True
|
|
2902
|
+
wrong_carrier = copy.deepcopy(carrier)
|
|
2903
|
+
wrong_carrier["round"] = 1
|
|
2904
|
+
for content in (
|
|
2905
|
+
"{", "[]", json.dumps({"commands": {}}),
|
|
2906
|
+
json.dumps({"commands": commands, "process_evidence": None}),
|
|
2907
|
+
json.dumps({"commands": commands, "process_evidence": wrong_carrier}),
|
|
2908
|
+
json.dumps({"commands": wrong_commands, "process_evidence": carrier}),
|
|
2909
|
+
):
|
|
2910
|
+
summary.write_text(content, encoding="utf-8")
|
|
2911
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2912
|
+
summary.unlink()
|
|
2913
|
+
summary.mkdir()
|
|
2914
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2915
|
+
summary.rmdir()
|
|
2916
|
+
summary.symlink_to(active / "absent-summary")
|
|
2917
|
+
rejected("complete", "--verdict", "BLOCKED")
|
|
2918
|
+
summary.unlink()
|
|
2919
|
+
# A valid summary symlink retains the existing outcome floor.
|
|
2920
|
+
target = devlyn / "valid-summary.json"
|
|
2921
|
+
target.write_text(json.dumps({
|
|
2922
|
+
"commands": commands, "process_evidence": carrier,
|
|
2923
|
+
}), encoding="utf-8")
|
|
2924
|
+
summary.symlink_to(target)
|
|
2925
|
+
for verdict in ("PASS", "PASS_WITH_ISSUES"):
|
|
2926
|
+
rejected("complete", "--verdict", verdict, error="mismatch cannot complete")
|
|
2927
|
+
completed = cli("complete", "--verdict", "FAIL")
|
|
2928
|
+
assert completed.returncode == 0, completed.stderr
|
|
2929
|
+
summarized = read_state(state_file)
|
|
2930
|
+
assert summarized["phases"]["build_gate"]["verdict"] == "FAIL"
|
|
2931
|
+
assert summarized["process_evidence"] == [carrier]
|
|
2932
|
+
archived(summarized)
|
|
2933
|
+
summary.unlink()
|
|
2934
|
+
print("PASS iter-0119 ineligible verdicts, invalid summaries and integrity controls preserve state")
|
|
2935
|
+
|
|
2936
|
+
# Valid nonzero receipt: transition stays atomic; standalone
|
|
2937
|
+
# completion persists BLOCKED before reporting the receipt error.
|
|
2938
|
+
prompt = active / "build_gate.prompt.0"
|
|
2939
|
+
prompt.write_text("inspect interrupted build\n", encoding="utf-8")
|
|
2940
|
+
session = active / "build_gate.worker-session.0.jsonl"
|
|
2941
|
+
session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
|
|
2942
|
+
receipt = active / "build_gate.invocation.0.json"
|
|
2943
|
+
model = "gpt-5.6-sol"
|
|
2944
|
+
candidate = copy.deepcopy(fixture)
|
|
2945
|
+
candidate["phases"]["build_gate"].update({
|
|
2946
|
+
"engine": "codex", "model_requested": model,
|
|
2947
|
+
"prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
|
|
2948
|
+
})
|
|
2949
|
+
write_state(state_file, candidate)
|
|
2950
|
+
receipts = invocation_receipt_module()
|
|
2951
|
+
receipts.start_receipt(
|
|
2952
|
+
work, receipt, fixture["run_id"], "build_gate", 0, str(prompt), str(session),
|
|
2953
|
+
["--json", "-C", str(work), "-s", "workspace-write", "-m", model,
|
|
2954
|
+
"-c", "sandbox_workspace_write.network_access=true", "inspect interrupted build"],
|
|
2955
|
+
)
|
|
2956
|
+
receipts.finish_receipt(work, receipt, 7)
|
|
2957
|
+
receipt_bytes = receipt.read_bytes()
|
|
2958
|
+
error = "BLOCKED:invocation-receipt-invalid: Codex invocation exited 7"
|
|
2959
|
+
rejected(
|
|
2960
|
+
"transition", "--verdict", "BLOCKED", "--engine-session-log", str(session),
|
|
2961
|
+
"--next-phase", "final_report", "--next-round", "0", error=error,
|
|
2962
|
+
)
|
|
2963
|
+
completed = cli(
|
|
2964
|
+
"complete", "--verdict", "BLOCKED", "--engine-session-log", str(session),
|
|
2965
|
+
)
|
|
2966
|
+
assert completed.returncode == 1, completed.stdout
|
|
2967
|
+
assert completed.stderr.strip() == error, completed.stderr
|
|
2968
|
+
terminal()
|
|
2969
|
+
assert receipt.read_bytes() == receipt_bytes
|
|
2970
|
+
print("PASS iter-0119 nonzero receipt persists BLOCKED before exit1; transition stays atomic")
|
|
2971
|
+
|
|
2972
|
+
candidate = copy.deepcopy(bound)
|
|
2973
|
+
do_spawn(candidate, "build_gate", 1, None, None, "claude", None)
|
|
2974
|
+
write_state(state_file, candidate)
|
|
2975
|
+
next_relative = runner.manifest_relative_path(candidate, "build_gate")
|
|
2976
|
+
runner.capture_process(
|
|
2977
|
+
work, work / next_relative, fixture["run_id"], "build_gate", 1, obligations[0],
|
|
2978
|
+
)
|
|
2979
|
+
next_carrier = runner.validate_manifest(
|
|
2980
|
+
work, next_relative, fixture["run_id"], "build_gate", 1,
|
|
2981
|
+
require_expectations=False,
|
|
2982
|
+
)
|
|
2983
|
+
completed = cli("complete", "--verdict", "BLOCKED")
|
|
2984
|
+
assert completed.returncode == 0, completed.stderr
|
|
2985
|
+
appended = read_state(state_file)
|
|
2986
|
+
assert appended["process_evidence"] == [carrier, next_carrier]
|
|
2987
|
+
assert appended["phases"]["build_gate"]["verdict"] == "BLOCKED"
|
|
2988
|
+
assert {path: path.read_bytes() for path in observed} == observed
|
|
2989
|
+
assert set(observed).issubset(archive.dynamic_evidence_artifacts(active, appended))
|
|
2990
|
+
|
|
2991
|
+
test_interrupted_build_gate()
|
|
2992
|
+
|
|
2993
|
+
# complete() before spawn() must fail loudly, not silently invent data.
|
|
2994
|
+
write_state(state_path, {"phases": {}})
|
|
2995
|
+
state = read_state(state_path)
|
|
2996
|
+
try:
|
|
2997
|
+
do_complete(state, "build_gate", "PASS", None, None, None, None, None)
|
|
1442
2998
|
except SystemExit as e:
|
|
1443
2999
|
assert "never spawned" in str(e)
|
|
1444
3000
|
else:
|
|
@@ -1505,7 +3061,9 @@ def self_test() -> int:
|
|
|
1505
3061
|
attestation_state = copy.deepcopy(transition_state)
|
|
1506
3062
|
attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
|
|
1507
3063
|
attestation_log = devlyn / "transition-attestation.log"
|
|
1508
|
-
attestation_log.write_text(
|
|
3064
|
+
attestation_log.write_text(json.dumps({
|
|
3065
|
+
"modelUsage": {"other-model": {"inputTokens": 1}},
|
|
3066
|
+
}) + "\n", encoding="utf-8")
|
|
1509
3067
|
write_state(state_path, attestation_state)
|
|
1510
3068
|
attestation_before = state_path.read_bytes()
|
|
1511
3069
|
try:
|
|
@@ -1657,7 +3215,10 @@ def self_test() -> int:
|
|
|
1657
3215
|
# flow does.
|
|
1658
3216
|
write_state(state_path, {"phases": {}})
|
|
1659
3217
|
state = read_state(state_path)
|
|
1660
|
-
do_spawn(
|
|
3218
|
+
do_spawn(
|
|
3219
|
+
state, "plan", 0, None, None, "claude", "plan-test-model",
|
|
3220
|
+
prompt_sha256=digest0,
|
|
3221
|
+
)
|
|
1661
3222
|
write_state(state_path, state)
|
|
1662
3223
|
state = read_state(state_path)
|
|
1663
3224
|
try:
|
|
@@ -1693,8 +3254,11 @@ def self_test() -> int:
|
|
|
1693
3254
|
"verify-merge.summary.json",
|
|
1694
3255
|
"codex-judge.stdout",
|
|
1695
3256
|
"claude-judge.stdout",
|
|
3257
|
+
"claude-judge.stderr",
|
|
3258
|
+
"pair-judge.summary.json",
|
|
1696
3259
|
):
|
|
1697
3260
|
(devlyn / name).write_text("stale\n", encoding="utf-8")
|
|
3261
|
+
(devlyn / "codex-primary-judge.prompt.md").write_text("current\n", encoding="utf-8")
|
|
1698
3262
|
(devlyn / "spec-verify.json").write_text("{}", encoding="utf-8")
|
|
1699
3263
|
clear_verify_round_artifacts(devlyn)
|
|
1700
3264
|
for name in (
|
|
@@ -1705,8 +3269,11 @@ def self_test() -> int:
|
|
|
1705
3269
|
"verify-merge.summary.json",
|
|
1706
3270
|
"codex-judge.stdout",
|
|
1707
3271
|
"claude-judge.stdout",
|
|
3272
|
+
"claude-judge.stderr",
|
|
3273
|
+
"pair-judge.summary.json",
|
|
1708
3274
|
):
|
|
1709
3275
|
assert not (devlyn / name).exists(), f"{name} must be cleared on VERIFY spawn"
|
|
3276
|
+
assert (devlyn / "codex-primary-judge.prompt.md").exists(), "judge prompts must survive VERIFY spawn"
|
|
1710
3277
|
assert (devlyn / "spec-verify.json").exists(), "non-VERIFY-round files must survive"
|
|
1711
3278
|
|
|
1712
3279
|
# Fix-loop respawn of phase-gated IMPLEMENT must preserve `exec`
|
|
@@ -1782,14 +3349,24 @@ def self_test() -> int:
|
|
|
1782
3349
|
subprocess.run(["git", "config", "user.name", "self-test"], cwd=work, check=True)
|
|
1783
3350
|
(work / "allowed.txt").write_text("base\n", encoding="utf-8")
|
|
1784
3351
|
(work / "blocked.txt").write_text("base\n", encoding="utf-8")
|
|
3352
|
+
(work / "blocked:literal.txt:1").write_text("base\n", encoding="utf-8")
|
|
3353
|
+
(work / "blocked:raw.txt").write_text("base\n", encoding="utf-8")
|
|
3354
|
+
(work / "branchbase").write_text("line\n" * 10, encoding="utf-8")
|
|
3355
|
+
(work / "branchbase:7").write_text("base\n", encoding="utf-8")
|
|
3356
|
+
(work / "branchbase:dir").mkdir()
|
|
3357
|
+
(work / "branchbase:dir" / "marker").write_text("base\n", encoding="utf-8")
|
|
3358
|
+
(work / "exact:1").write_text("base\n", encoding="utf-8")
|
|
1785
3359
|
(work / "schedule").mkdir()
|
|
1786
3360
|
(work / "schedule" / "__init__.py").write_text("line\n" * 700, encoding="utf-8")
|
|
1787
3361
|
(work / "test_schedule.py").write_text("line\n", encoding="utf-8")
|
|
1788
3362
|
(work / "tests").mkdir()
|
|
1789
3363
|
(work / "tests" / "cli.test.js").write_text("line\n" * 170, encoding="utf-8")
|
|
3364
|
+
(work / "tests" / "literal:1-3").write_text("line\n", encoding="utf-8")
|
|
1790
3365
|
subprocess.run(
|
|
1791
|
-
["git", "add", "--", "allowed.txt", "blocked.txt", "
|
|
1792
|
-
"
|
|
3366
|
+
["git", "add", "--", "allowed.txt", "blocked.txt", "blocked:literal.txt:1",
|
|
3367
|
+
"blocked:raw.txt", "branchbase", "branchbase:7", "branchbase:dir/marker",
|
|
3368
|
+
"exact:1", "schedule/__init__.py", "test_schedule.py", "tests/cli.test.js",
|
|
3369
|
+
"tests/literal:1-3"],
|
|
1793
3370
|
cwd=work, check=True,
|
|
1794
3371
|
)
|
|
1795
3372
|
subprocess.run(["git", "commit", "-qm", "base"], cwd=work, check=True)
|
|
@@ -1825,18 +3402,26 @@ def self_test() -> int:
|
|
|
1825
3402
|
else:
|
|
1826
3403
|
raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
|
|
1827
3404
|
assert rejected_state == {"sentinel": True}
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
3405
|
+
for blank_model in ("", " ", " sonnet"):
|
|
3406
|
+
rejected_state = {"sentinel": True}
|
|
3407
|
+
try:
|
|
3408
|
+
do_spawn(
|
|
3409
|
+
rejected_state, "surface_close", 0, None, pre_sha, "claude", blank_model,
|
|
3410
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
3411
|
+
untracked_before=["kept.txt"],
|
|
3412
|
+
)
|
|
3413
|
+
except SystemExit as exc:
|
|
3414
|
+
assert "explicit --model must be a nonempty exact string" in str(exc)
|
|
3415
|
+
else:
|
|
3416
|
+
raise AssertionError("SURFACE_CLOSE accepted an invalid requested model")
|
|
3417
|
+
assert rejected_state == {"sentinel": True}
|
|
3418
|
+
inherited_surface = {**copy.deepcopy(surface_state), "version": "3.0"}
|
|
3419
|
+
do_spawn(
|
|
3420
|
+
inherited_surface, "surface_close", 0, None, pre_sha, "claude", None,
|
|
3421
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
3422
|
+
untracked_before=["kept.txt"],
|
|
3423
|
+
)
|
|
3424
|
+
assert inherited_surface["phases"]["surface_close"]["model_requested"] is None
|
|
1840
3425
|
do_spawn(
|
|
1841
3426
|
surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
|
|
1842
3427
|
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
@@ -1845,9 +3430,89 @@ def self_test() -> int:
|
|
|
1845
3430
|
validate_surface_inputs(work, work_devlyn, surface_state)
|
|
1846
3431
|
validate_surface_prompt(work_devlyn, surface_state)
|
|
1847
3432
|
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
3433
|
+
(work_devlyn / "pipeline.state.json").write_text(
|
|
3434
|
+
json.dumps(surface_state), encoding="utf-8",
|
|
3435
|
+
)
|
|
3436
|
+
for entry in ("src/{a,b", "src/{a,}/**", "src/{a,{b,c}}", "src/{a,**}"):
|
|
3437
|
+
surface_check = subprocess.run(
|
|
3438
|
+
[
|
|
3439
|
+
sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
3440
|
+
"--devlyn-dir", ".devlyn", "--phase", "surface_close", "surface-check",
|
|
3441
|
+
"--authorized-surface-json", json.dumps([entry]),
|
|
3442
|
+
],
|
|
3443
|
+
cwd=work, capture_output=True, text=True,
|
|
3444
|
+
)
|
|
3445
|
+
if (
|
|
3446
|
+
surface_check.returncode == 0
|
|
3447
|
+
or "error: unsupported brace glob" not in surface_check.stderr
|
|
3448
|
+
or "supported form" not in surface_check.stderr
|
|
3449
|
+
or "Traceback" in surface_check.stderr
|
|
3450
|
+
):
|
|
3451
|
+
raise AssertionError(f"surface-check did not fail closed for {entry}: {surface_check.stderr}")
|
|
1848
3452
|
surface = validate_authorized_surface(
|
|
1849
|
-
'["allowed.txt", "
|
|
3453
|
+
'["allowed.txt", "branchbase", "exact-link", "exact:1", "schedule/**", '
|
|
3454
|
+
'"test_schedule.py", "tests/**", "x.ts"]'
|
|
1850
3455
|
)
|
|
3456
|
+
assert path_matches_surface("src/a/example.py", ["src/{a,b}/**"])
|
|
3457
|
+
assert path_matches_surface("src/b/example.py", ["src/{a,b}/**"])
|
|
3458
|
+
assert not path_matches_surface("src/c/example.py", ["src/{a,b}/**"])
|
|
3459
|
+
for entry in ("src/{a,b", "src/{a,}/**", "src/{a,{b,c}}", "src/{a,**}"):
|
|
3460
|
+
try:
|
|
3461
|
+
validate_authorized_surface(json.dumps([entry]))
|
|
3462
|
+
except SystemExit as exc:
|
|
3463
|
+
assert str(exc).startswith("error: unsupported brace glob") and "supported form" in str(exc)
|
|
3464
|
+
else:
|
|
3465
|
+
raise AssertionError(f"malformed brace glob passed validation: {entry}")
|
|
3466
|
+
try:
|
|
3467
|
+
path_matches_surface("src/a/example.py", [entry])
|
|
3468
|
+
except ValueError as exc:
|
|
3469
|
+
assert entry in str(exc) and "supported form" in str(exc)
|
|
3470
|
+
else:
|
|
3471
|
+
raise AssertionError(f"malformed brace glob accepted: {entry}")
|
|
3472
|
+
outside = devlyn / "outside.txt"
|
|
3473
|
+
outside.write_text("outside\n", encoding="utf-8")
|
|
3474
|
+
(work / "outside-link").symlink_to(outside)
|
|
3475
|
+
assert worktree_file_exists(work, "allowed.txt")
|
|
3476
|
+
assert not worktree_file_exists(work, str(outside))
|
|
3477
|
+
assert not worktree_file_exists(work, "../outside.txt")
|
|
3478
|
+
assert not worktree_file_exists(work, "outside-link")
|
|
3479
|
+
assert worktree_path_exists(work, "branchbase:dir")
|
|
3480
|
+
assert not worktree_path_exists(work, str(outside))
|
|
3481
|
+
assert not worktree_path_exists(work, "../outside.txt")
|
|
3482
|
+
assert not worktree_path_exists(work, "outside-link")
|
|
3483
|
+
(work / "outside-link").unlink()
|
|
3484
|
+
for link, target in (
|
|
3485
|
+
("exact-link", "allowed.txt"),
|
|
3486
|
+
("tests/inside-link", "cli.test.js"),
|
|
3487
|
+
):
|
|
3488
|
+
(work / link).symlink_to(target)
|
|
3489
|
+
output = work_devlyn / "surface-close.stdout"
|
|
3490
|
+
output.write_text(
|
|
3491
|
+
f"UVR-STALE: N/A {link} — contained symlink\n"
|
|
3492
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3493
|
+
encoding="utf-8",
|
|
3494
|
+
)
|
|
3495
|
+
validate_surface_adjudication(
|
|
3496
|
+
work, work_devlyn, surface_state, surface,
|
|
3497
|
+
)
|
|
3498
|
+
(work / link).unlink()
|
|
3499
|
+
for link in ("exact-link", "tests/outside-link"):
|
|
3500
|
+
(work / link).symlink_to(outside)
|
|
3501
|
+
output = work_devlyn / "surface-close.stdout"
|
|
3502
|
+
output.write_text(
|
|
3503
|
+
f"UVR-STALE: N/A {link} — escaping symlink\n"
|
|
3504
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3505
|
+
encoding="utf-8",
|
|
3506
|
+
)
|
|
3507
|
+
try:
|
|
3508
|
+
validate_surface_adjudication(
|
|
3509
|
+
work, work_devlyn, surface_state, surface,
|
|
3510
|
+
)
|
|
3511
|
+
except SystemExit as exc:
|
|
3512
|
+
assert "citation-missing" in str(exc), (link, exc)
|
|
3513
|
+
else:
|
|
3514
|
+
raise AssertionError(f"SURFACE_CLOSE followed escaping symlink: {link}")
|
|
3515
|
+
(work / link).unlink()
|
|
1851
3516
|
entry = surface_entry(surface_state)
|
|
1852
3517
|
assert entry["prompt_sha256"] == file_sha256(prompt)
|
|
1853
3518
|
assert entry["model_requested"] == "sonnet"
|
|
@@ -1873,6 +3538,18 @@ def self_test() -> int:
|
|
|
1873
3538
|
encoding="utf-8",
|
|
1874
3539
|
)
|
|
1875
3540
|
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
3541
|
+
output.write_text(
|
|
3542
|
+
"UVR-STALE: N/A exact:1 — exact colon path is unchanged\n"
|
|
3543
|
+
"PATH-TEST: N/A exact:1:1 — exact colon path line is covered\nPASS\n",
|
|
3544
|
+
encoding="utf-8",
|
|
3545
|
+
)
|
|
3546
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
3547
|
+
output.write_text(
|
|
3548
|
+
"UVR-STALE: N/A tests/literal:1-3 — literal range-shaped path is unchanged\n"
|
|
3549
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3550
|
+
encoding="utf-8",
|
|
3551
|
+
)
|
|
3552
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1876
3553
|
|
|
1877
3554
|
output.write_text(
|
|
1878
3555
|
"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 +3601,81 @@ def self_test() -> int:
|
|
|
1924
3601
|
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1925
3602
|
"citation-missing",
|
|
1926
3603
|
),
|
|
3604
|
+
(
|
|
3605
|
+
"UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
|
|
3606
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3607
|
+
"adjudication-malformed",
|
|
3608
|
+
),
|
|
3609
|
+
(
|
|
3610
|
+
"UVR-STALE: N/A allowed.txt:1, allowed.txt:1 — production list citation\n"
|
|
3611
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3612
|
+
"adjudication-malformed",
|
|
3613
|
+
),
|
|
3614
|
+
(
|
|
3615
|
+
"UVR-STALE: N/A tests/cli.test.js:1-3 — glob range citation\n"
|
|
3616
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3617
|
+
"adjudication-malformed",
|
|
3618
|
+
),
|
|
3619
|
+
(
|
|
3620
|
+
"UVR-STALE: N/A x.ts:12:34 — doubled line citation\n"
|
|
3621
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3622
|
+
"adjudication-malformed",
|
|
3623
|
+
),
|
|
3624
|
+
(
|
|
3625
|
+
"UVR-STALE: N/A allowed.txt:0 — zero line citation\n"
|
|
3626
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3627
|
+
"adjudication-malformed",
|
|
3628
|
+
),
|
|
3629
|
+
(
|
|
3630
|
+
"UVR-STALE: N/A allowed.txt:012 — leading-zero line citation\n"
|
|
3631
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3632
|
+
"adjudication-malformed",
|
|
3633
|
+
),
|
|
3634
|
+
(
|
|
3635
|
+
"UVR-STALE: N/A allowed.txt: — empty line citation\n"
|
|
3636
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3637
|
+
"adjudication-malformed",
|
|
3638
|
+
),
|
|
3639
|
+
(
|
|
3640
|
+
"UVR-STALE: N/A blocked.txt:1 — blocked parsed path\n"
|
|
3641
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3642
|
+
"out-of-surface",
|
|
3643
|
+
),
|
|
3644
|
+
(
|
|
3645
|
+
"UVR-STALE: N/A blocked.txt:1-3 — blocked range citation\n"
|
|
3646
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3647
|
+
"out-of-surface",
|
|
3648
|
+
),
|
|
3649
|
+
(
|
|
3650
|
+
"UVR-STALE: N/A blocked:literal.txt:1 — blocked raw colon path\n"
|
|
3651
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3652
|
+
"out-of-surface",
|
|
3653
|
+
),
|
|
3654
|
+
(
|
|
3655
|
+
"UVR-STALE: N/A blocked:raw.txt:1 — blocked parsed colon path\n"
|
|
3656
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3657
|
+
"out-of-surface",
|
|
3658
|
+
),
|
|
3659
|
+
(
|
|
3660
|
+
"UVR-STALE: N/A branchbase:7 — existing unauthorized raw path\n"
|
|
3661
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3662
|
+
"out-of-surface",
|
|
3663
|
+
),
|
|
3664
|
+
(
|
|
3665
|
+
"UVR-STALE: N/A branchbase:dir:1 — existing unauthorized parsed directory\n"
|
|
3666
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3667
|
+
"out-of-surface",
|
|
3668
|
+
),
|
|
3669
|
+
(
|
|
3670
|
+
"UVR-STALE: N/A tests/../blocked.txt:1 — wildcard traversal\n"
|
|
3671
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3672
|
+
"out-of-surface",
|
|
3673
|
+
),
|
|
3674
|
+
(
|
|
3675
|
+
"UVR-STALE: N/A tests/ghost.txt:1 — nonexistent glob descendant\n"
|
|
3676
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3677
|
+
"citation-missing",
|
|
3678
|
+
),
|
|
1927
3679
|
)
|
|
1928
3680
|
for raw_output, marker in rejected_outputs:
|
|
1929
3681
|
output.write_text(raw_output, encoding="utf-8")
|
|
@@ -1934,6 +3686,14 @@ def self_test() -> int:
|
|
|
1934
3686
|
else:
|
|
1935
3687
|
raise AssertionError(f"SURFACE_CLOSE accepted invalid adjudication: {marker}")
|
|
1936
3688
|
|
|
3689
|
+
output.write_text(
|
|
3690
|
+
"UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
|
|
3691
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
3692
|
+
encoding="utf-8",
|
|
3693
|
+
)
|
|
3694
|
+
require_surface_adjudication_malformed(
|
|
3695
|
+
work, work_devlyn, surface_state, surface,
|
|
3696
|
+
)
|
|
1937
3697
|
output.write_text(
|
|
1938
3698
|
"UVR-STALE: N/A allowed.txt:1 — no stale interface text\n"
|
|
1939
3699
|
"PATH-TEST: N/A allowed.txt:1 — requested path already covered\nPASS\n",
|
|
@@ -2006,6 +3766,78 @@ def self_test() -> int:
|
|
|
2006
3766
|
assert completed_surface["model_effective"] == "sonnet"
|
|
2007
3767
|
assert completed_surface["verdict"] == "PASS"
|
|
2008
3768
|
|
|
3769
|
+
inherited_completion = copy.deepcopy(inherited_surface)
|
|
3770
|
+
assert do_complete(
|
|
3771
|
+
inherited_completion, "surface_close", "PASS", pre_sha, None, None,
|
|
3772
|
+
None, None, str(wrapper_log), devlyn=work_devlyn,
|
|
3773
|
+
) is None
|
|
3774
|
+
assert inherited_completion["phases"]["surface_close"]["model_requested"] is None
|
|
3775
|
+
assert inherited_completion["phases"]["surface_close"]["model_effective"] == "sonnet"
|
|
3776
|
+
replacement = copy.deepcopy(inherited_surface)
|
|
3777
|
+
try:
|
|
3778
|
+
do_complete(replacement, "surface_close", "PASS", None, None, None,
|
|
3779
|
+
None, "sonnet", str(wrapper_log), devlyn=work_devlyn)
|
|
3780
|
+
except SystemExit as exc:
|
|
3781
|
+
assert "cannot replace requested model" in str(exc)
|
|
3782
|
+
else:
|
|
3783
|
+
raise AssertionError("completion replaced inherited model selection")
|
|
3784
|
+
assert replacement == inherited_surface
|
|
3785
|
+
|
|
3786
|
+
native_json = wrapper_log.read_bytes()
|
|
3787
|
+
transcript_bytes = transcript.read_bytes()
|
|
3788
|
+
foreign_log = work_devlyn / "foreign.output.json"
|
|
3789
|
+
foreign_log.write_bytes(native_json)
|
|
3790
|
+
for retained in (False, True):
|
|
3791
|
+
if retained:
|
|
3792
|
+
transcript.write_bytes(transcript_bytes)
|
|
3793
|
+
else:
|
|
3794
|
+
transcript.unlink()
|
|
3795
|
+
for log in (None, str(foreign_log)):
|
|
3796
|
+
rejected = copy.deepcopy(inherited_surface)
|
|
3797
|
+
error = do_complete(rejected, "surface_close", "PASS", None, None, None,
|
|
3798
|
+
None, None, log, devlyn=work_devlyn)
|
|
3799
|
+
assert error and "canonical native JSON" in error
|
|
3800
|
+
assert rejected["phases"]["surface_close"]["verdict"] == "BLOCKED"
|
|
3801
|
+
for invalid_json in (
|
|
3802
|
+
b"not JSON", b'{"result":"PASS"}',
|
|
3803
|
+
b'{"modelUsage":{"model-a":{},"model-b":{}}}',
|
|
3804
|
+
b'{"type":"turn_context","payload":{"model":"sonnet"}}\n',
|
|
3805
|
+
):
|
|
3806
|
+
wrapper_log.write_bytes(invalid_json)
|
|
3807
|
+
rejected = copy.deepcopy(inherited_surface)
|
|
3808
|
+
assert do_complete(rejected, "surface_close", "PASS", None, None, None,
|
|
3809
|
+
None, None, str(wrapper_log), devlyn=work_devlyn)
|
|
3810
|
+
assert rejected["phases"]["surface_close"]["verdict"] == "BLOCKED"
|
|
3811
|
+
alias_log = work_devlyn / "aliased-native.jsonl"
|
|
3812
|
+
alias_log.symlink_to(wrapper_log)
|
|
3813
|
+
rejected = copy.deepcopy(inherited_surface)
|
|
3814
|
+
assert do_complete(rejected, "surface_close", "PASS", None, None, None,
|
|
3815
|
+
None, None, str(alias_log), devlyn=work_devlyn)
|
|
3816
|
+
wrapper_log.unlink()
|
|
3817
|
+
rejected = copy.deepcopy(inherited_surface)
|
|
3818
|
+
assert do_complete(rejected, "surface_close", "PASS", None, None, None,
|
|
3819
|
+
None, None, str(wrapper_log), devlyn=work_devlyn)
|
|
3820
|
+
recovery_failure = copy.deepcopy(inherited_surface)
|
|
3821
|
+
assert do_surface_adjudication_recovery(recovery_failure, work_devlyn)
|
|
3822
|
+
assert recovery_failure["phases"]["surface_close"]["verdict"] == "BLOCKED"
|
|
3823
|
+
assert not recovery_failure["phases"]["surface_close"].get("continued_after_block")
|
|
3824
|
+
transition_state = copy.deepcopy(inherited_surface)
|
|
3825
|
+
try:
|
|
3826
|
+
do_transition(transition_state, "surface_close", "build_gate", "PASS", pre_sha,
|
|
3827
|
+
None, None, None, None, None, work_devlyn, 0, None, None, "claude", None)
|
|
3828
|
+
except SystemExit as exc:
|
|
3829
|
+
assert "canonical native JSON" in str(exc)
|
|
3830
|
+
else:
|
|
3831
|
+
raise AssertionError("SURFACE_CLOSE opened BUILD_GATE without native evidence")
|
|
3832
|
+
assert transition_state == inherited_surface
|
|
3833
|
+
wrapper_log.write_text('{"modelUsage":{"different-model":{"inputTokens":1}}}\n')
|
|
3834
|
+
mismatch = copy.deepcopy(surface_state)
|
|
3835
|
+
assert "model-attestation-mismatch" in do_complete(
|
|
3836
|
+
mismatch, "surface_close", "PASS", None, None, None, None, None,
|
|
3837
|
+
str(wrapper_log), devlyn=work_devlyn,
|
|
3838
|
+
)
|
|
3839
|
+
wrapper_log.write_bytes(native_json)
|
|
3840
|
+
|
|
2009
3841
|
recovery_state = loads_strict_json(json.dumps(surface_state))
|
|
2010
3842
|
recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
|
|
2011
3843
|
output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
|
|
@@ -2059,20 +3891,178 @@ def self_test() -> int:
|
|
|
2059
3891
|
assert skipped["verdict"] is None
|
|
2060
3892
|
assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
|
|
2061
3893
|
|
|
2062
|
-
# Effective model evidence:
|
|
3894
|
+
# Effective model evidence: handwritten headers are not provenance;
|
|
3895
|
+
# canonical rollout JSONL and Claude wrapper output remain accepted.
|
|
2063
3896
|
header_log = devlyn / "codex-build.log"
|
|
2064
3897
|
header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
|
|
2065
|
-
|
|
3898
|
+
try:
|
|
3899
|
+
parse_effective_model(header_log)
|
|
3900
|
+
except ValueError as exc:
|
|
3901
|
+
assert "no effective-model evidence" in str(exc)
|
|
3902
|
+
else:
|
|
3903
|
+
raise AssertionError("plaintext model header was accepted as provenance")
|
|
2066
3904
|
rollout_log = devlyn / "rollout.jsonl"
|
|
2067
3905
|
rollout_log.write_text(json.dumps({
|
|
2068
3906
|
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2069
3907
|
}) + "\n", encoding="utf-8")
|
|
2070
3908
|
assert parse_effective_model(rollout_log) == "gpt-5.6-terra"
|
|
2071
3909
|
claude_log = devlyn / "claude-result.json"
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
3910
|
+
|
|
3911
|
+
def claude_wrapper(primary_usage, entries):
|
|
3912
|
+
wrapper = {"modelUsage": {}}
|
|
3913
|
+
if primary_usage is not None:
|
|
3914
|
+
wrapper["usage"] = dict(zip((
|
|
3915
|
+
"input_tokens", "output_tokens", "cache_read_input_tokens",
|
|
3916
|
+
"cache_creation_input_tokens",
|
|
3917
|
+
), primary_usage))
|
|
3918
|
+
for model, entry_usage, metadata in entries:
|
|
3919
|
+
entry = dict(zip((
|
|
3920
|
+
"inputTokens", "outputTokens", "cacheReadInputTokens",
|
|
3921
|
+
"cacheCreationInputTokens",
|
|
3922
|
+
), entry_usage))
|
|
3923
|
+
entry.update(metadata)
|
|
3924
|
+
wrapper["modelUsage"][model] = entry
|
|
3925
|
+
return wrapper
|
|
3926
|
+
|
|
3927
|
+
oversized_counter = 10 ** 400
|
|
3928
|
+
claude_selector_rows = (
|
|
3929
|
+
(
|
|
3930
|
+
"singleton",
|
|
3931
|
+
{"modelUsage": {"claude-alpha-1": {"inputTokens": 1}}},
|
|
3932
|
+
"claude-alpha-1",
|
|
3933
|
+
),
|
|
3934
|
+
(
|
|
3935
|
+
"opus-primary",
|
|
3936
|
+
claude_wrapper(
|
|
3937
|
+
(2854, 9927, 1095180, 73889),
|
|
3938
|
+
(
|
|
3939
|
+
("claude-" "haiku-4-5-20251001", (4106, 14, 0, 0), {
|
|
3940
|
+
"costUSD": 0.004176,
|
|
3941
|
+
}),
|
|
3942
|
+
("claude-" "opus-5[1m]", (2854, 9927, 1095180, 73889), {
|
|
3943
|
+
"costUSD": 1.5489249999999999,
|
|
3944
|
+
"canonicalModel": "claude-" "opus-5",
|
|
3945
|
+
}),
|
|
3946
|
+
),
|
|
3947
|
+
),
|
|
3948
|
+
"claude-" "opus-5[1m]",
|
|
3949
|
+
),
|
|
3950
|
+
(
|
|
3951
|
+
"fable-primary",
|
|
3952
|
+
claude_wrapper(
|
|
3953
|
+
(8068, 26740, 403950, 76656),
|
|
3954
|
+
(
|
|
3955
|
+
("claude-" "haiku-4-5-20251001", (2246, 22, 0, 0), {
|
|
3956
|
+
"costUSD": 0.002356,
|
|
3957
|
+
}),
|
|
3958
|
+
("claude-" "fable-5", (8068, 26740, 403950, 76656), {
|
|
3959
|
+
"costUSD": 3.35475,
|
|
3960
|
+
"canonicalModel": "claude-" "fable-5",
|
|
3961
|
+
}),
|
|
3962
|
+
),
|
|
3963
|
+
),
|
|
3964
|
+
"claude-" "fable-5",
|
|
3965
|
+
),
|
|
3966
|
+
(
|
|
3967
|
+
"rank-confound",
|
|
3968
|
+
claude_wrapper(
|
|
3969
|
+
(3, 5, 7, 11),
|
|
3970
|
+
(
|
|
3971
|
+
("larger-auxiliary", (300000, 500000, 700000, 1100000), {
|
|
3972
|
+
"costUSD": 999.0,
|
|
3973
|
+
}),
|
|
3974
|
+
("expected-primary", (3, 5, 7, 11), {"costUSD": 0.01}),
|
|
3975
|
+
),
|
|
3976
|
+
),
|
|
3977
|
+
"expected-primary",
|
|
3978
|
+
),
|
|
3979
|
+
(
|
|
3980
|
+
"oversized-integer",
|
|
3981
|
+
claude_wrapper(
|
|
3982
|
+
(oversized_counter, 5, 7, 11),
|
|
3983
|
+
(
|
|
3984
|
+
("oversized-primary", (oversized_counter, 5, 7, 11), {}),
|
|
3985
|
+
("auxiliary", (3, 5, 7, 11), {}),
|
|
3986
|
+
),
|
|
3987
|
+
),
|
|
3988
|
+
"oversized-primary",
|
|
3989
|
+
),
|
|
3990
|
+
(
|
|
3991
|
+
"zero-match",
|
|
3992
|
+
claude_wrapper(
|
|
3993
|
+
(1, 2, 3, 4),
|
|
3994
|
+
(("model-a", (10, 2, 3, 4), {}), ("model-b", (1, 20, 3, 4), {})),
|
|
3995
|
+
),
|
|
3996
|
+
ValueError,
|
|
3997
|
+
),
|
|
3998
|
+
(
|
|
3999
|
+
"duplicate-match",
|
|
4000
|
+
claude_wrapper(
|
|
4001
|
+
(1, 2, 3, 4),
|
|
4002
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (1, 2, 3, 4), {})),
|
|
4003
|
+
),
|
|
4004
|
+
ValueError,
|
|
4005
|
+
),
|
|
4006
|
+
(
|
|
4007
|
+
"missing-top-level-usage",
|
|
4008
|
+
claude_wrapper(
|
|
4009
|
+
None,
|
|
4010
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
|
|
4011
|
+
),
|
|
4012
|
+
ValueError,
|
|
4013
|
+
),
|
|
4014
|
+
(
|
|
4015
|
+
"malformed-top-level-float",
|
|
4016
|
+
claude_wrapper(
|
|
4017
|
+
(1.0, 2, 3, 4),
|
|
4018
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
|
|
4019
|
+
),
|
|
4020
|
+
ValueError,
|
|
4021
|
+
),
|
|
4022
|
+
(
|
|
4023
|
+
"malformed-top-level-negative",
|
|
4024
|
+
claude_wrapper(
|
|
4025
|
+
(-1, 2, 3, 4),
|
|
4026
|
+
(("model-a", (-1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
|
|
4027
|
+
),
|
|
4028
|
+
ValueError,
|
|
4029
|
+
),
|
|
4030
|
+
(
|
|
4031
|
+
"malformed-entry-counter",
|
|
4032
|
+
claude_wrapper(
|
|
4033
|
+
(1, 2, 3, 4),
|
|
4034
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (True, 20, 30, 40), {})),
|
|
4035
|
+
),
|
|
4036
|
+
ValueError,
|
|
4037
|
+
),
|
|
4038
|
+
)
|
|
4039
|
+
for row_name, wrapper, expected in claude_selector_rows:
|
|
4040
|
+
claude_log.write_text(json.dumps(wrapper) + "\n", encoding="utf-8")
|
|
4041
|
+
if expected is ValueError:
|
|
4042
|
+
try:
|
|
4043
|
+
parse_effective_model(claude_log)
|
|
4044
|
+
except ValueError:
|
|
4045
|
+
pass
|
|
4046
|
+
else:
|
|
4047
|
+
raise AssertionError(f"Claude selector accepted {row_name}")
|
|
4048
|
+
else:
|
|
4049
|
+
assert parse_effective_model(claude_log) == expected, row_name
|
|
4050
|
+
|
|
4051
|
+
claude_log.write_text(
|
|
4052
|
+
json.dumps(claude_selector_rows[-1][1]) + "\n", encoding="utf-8"
|
|
4053
|
+
)
|
|
4054
|
+
write_state(state_path, {"phases": {}})
|
|
4055
|
+
state = read_state(state_path)
|
|
4056
|
+
do_spawn(
|
|
4057
|
+
state, "plan", 0, None, None, "claude", "claude-default",
|
|
4058
|
+
prompt_sha256=digest0,
|
|
4059
|
+
)
|
|
4060
|
+
malformed = do_complete(
|
|
4061
|
+
state, "plan", "PASS", None, None, None, None, None, str(claude_log)
|
|
4062
|
+
)
|
|
4063
|
+
assert malformed and "model-attestation-failed" in malformed
|
|
4064
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
4065
|
+
assert state["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
2076
4066
|
|
|
2077
4067
|
# Requested/effective drift is a persisted, fail-closed attestation.
|
|
2078
4068
|
write_state(state_path, {"phases": {}})
|
|
@@ -2087,6 +4077,21 @@ def self_test() -> int:
|
|
|
2087
4077
|
assert mismatched["model_effective"] == "gpt-5.6-terra"
|
|
2088
4078
|
assert mismatched["verdict"] == "BLOCKED"
|
|
2089
4079
|
|
|
4080
|
+
claude_log.write_text(
|
|
4081
|
+
json.dumps(claude_selector_rows[1][1]) + "\n", encoding="utf-8"
|
|
4082
|
+
)
|
|
4083
|
+
write_state(state_path, {"phases": {}})
|
|
4084
|
+
state = read_state(state_path)
|
|
4085
|
+
do_spawn(state, "build_gate", 0, None, None, "claude", "claude-" "opus-5")
|
|
4086
|
+
mismatch = do_complete(
|
|
4087
|
+
state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
|
|
4088
|
+
)
|
|
4089
|
+
mismatched = state["phases"]["build_gate"]
|
|
4090
|
+
assert mismatch and "model-attestation-mismatch" in mismatch
|
|
4091
|
+
assert mismatched["model_requested"] == "claude-" "opus-5"
|
|
4092
|
+
assert mismatched["model_effective"] == "claude-" "opus-5[1m]"
|
|
4093
|
+
assert mismatched["verdict"] == "BLOCKED"
|
|
4094
|
+
|
|
2090
4095
|
# A retained mutation-worker session makes the completion flag
|
|
2091
4096
|
# mandatory; without a retained file, null remains legal.
|
|
2092
4097
|
retained_log = devlyn / "implement.worker-session.0.jsonl"
|
|
@@ -2115,6 +4120,437 @@ def self_test() -> int:
|
|
|
2115
4120
|
assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
|
|
2116
4121
|
assert state["phases"]["implement"]["verdict"] == "PASS"
|
|
2117
4122
|
|
|
4123
|
+
receipt_work = devlyn / "receipt-work"
|
|
4124
|
+
receipt_devlyn = receipt_work / ".devlyn"
|
|
4125
|
+
receipt_devlyn.mkdir(parents=True)
|
|
4126
|
+
receipt_prompt = receipt_devlyn / "implement.prompt.0"
|
|
4127
|
+
receipt_prompt.write_text("implement exactly\n", encoding="utf-8")
|
|
4128
|
+
receipt_session = receipt_devlyn / "implement.worker-session.0.jsonl"
|
|
4129
|
+
receipt_session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
|
|
4130
|
+
receipt_path = receipt_devlyn / "implement.invocation.0.json"
|
|
4131
|
+
receipt_model = "gpt-5.6-sol"
|
|
4132
|
+
receipt_prompt_sha = hashlib.sha256(receipt_prompt.read_bytes()).hexdigest()
|
|
4133
|
+
inherited_engine_state = {
|
|
4134
|
+
"version": "3.0",
|
|
4135
|
+
"run_id": "rs-inherited-engine",
|
|
4136
|
+
"engine": "codex",
|
|
4137
|
+
"phases": {"implement": None},
|
|
4138
|
+
}
|
|
4139
|
+
try:
|
|
4140
|
+
do_spawn(
|
|
4141
|
+
inherited_engine_state, "implement", 0, None, None, None, None,
|
|
4142
|
+
prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
|
|
4143
|
+
)
|
|
4144
|
+
except SystemExit as exc:
|
|
4145
|
+
assert "spawn requires --model" in str(exc)
|
|
4146
|
+
else:
|
|
4147
|
+
raise AssertionError("schema-v3 inherited Codex engine accepted no model")
|
|
4148
|
+
do_spawn(
|
|
4149
|
+
inherited_engine_state, "implement", 0, None, None, None, receipt_model,
|
|
4150
|
+
prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
|
|
4151
|
+
)
|
|
4152
|
+
assert inherited_engine_state["phases"]["implement"]["engine"] == "codex"
|
|
4153
|
+
receipt_state = {
|
|
4154
|
+
"version": "3.0",
|
|
4155
|
+
"run_id": "rs-invocation-state",
|
|
4156
|
+
"engine": "codex",
|
|
4157
|
+
"phases": {"implement": None},
|
|
4158
|
+
}
|
|
4159
|
+
do_spawn(
|
|
4160
|
+
receipt_state, "implement", 0, None, None, "codex", receipt_model,
|
|
4161
|
+
prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
|
|
4162
|
+
)
|
|
4163
|
+
receipt_runner = invocation_receipt_module()
|
|
4164
|
+
receipt_runner.start_receipt(
|
|
4165
|
+
receipt_work, receipt_path, receipt_state["run_id"], "implement", 0,
|
|
4166
|
+
str(receipt_prompt), str(receipt_session),
|
|
4167
|
+
["--json", "-C", str(receipt_work), "-s", "workspace-write", "-m", receipt_model,
|
|
4168
|
+
"-c", "sandbox_workspace_write.network_access=false", "implement exactly"],
|
|
4169
|
+
)
|
|
4170
|
+
receipt_runner.finish_receipt(receipt_work, receipt_path, 0)
|
|
4171
|
+
rerouted_state = copy.deepcopy(receipt_state)
|
|
4172
|
+
assert do_complete(
|
|
4173
|
+
receipt_state, "implement", "PASS", None, None, None, None, None,
|
|
4174
|
+
str(receipt_session), devlyn=receipt_devlyn, work=receipt_work,
|
|
4175
|
+
) is None
|
|
4176
|
+
receipt_entry = receipt_state["phases"]["implement"]
|
|
4177
|
+
assert receipt_entry["model_effective"] is None
|
|
4178
|
+
assert receipt_entry["model_requested"] == receipt_model
|
|
4179
|
+
assert receipt_entry["invocation_receipt"]["path"] == (
|
|
4180
|
+
".devlyn/implement.invocation.0.json"
|
|
4181
|
+
)
|
|
4182
|
+
|
|
4183
|
+
# A native reroute remains a failed phase even after a successful terminal event.
|
|
4184
|
+
receipt_session.write_text(
|
|
4185
|
+
'{"type":"item.completed","item":{"type":"error",'
|
|
4186
|
+
'"message":"model rerouted: gpt-5.6-sol -> other (Policy)"}}\n'
|
|
4187
|
+
'{"type":"turn.completed"}\n', encoding="utf-8",
|
|
4188
|
+
)
|
|
4189
|
+
receipt_path.unlink()
|
|
4190
|
+
receipt_runner.start_receipt(
|
|
4191
|
+
receipt_work, receipt_path, rerouted_state["run_id"], "implement", 0,
|
|
4192
|
+
str(receipt_prompt), str(receipt_session),
|
|
4193
|
+
["--json", "-C", str(receipt_work), "-s", "workspace-write", "-m", receipt_model,
|
|
4194
|
+
"-c", "sandbox_workspace_write.network_access=false", "implement exactly"],
|
|
4195
|
+
)
|
|
4196
|
+
receipt_runner.finish_receipt(receipt_work, receipt_path, 0)
|
|
4197
|
+
reroute_error = do_complete(
|
|
4198
|
+
rerouted_state, "implement", "PASS", None, None, None, None, None,
|
|
4199
|
+
str(receipt_session), devlyn=receipt_devlyn, work=receipt_work,
|
|
4200
|
+
)
|
|
4201
|
+
assert reroute_error and "model reroute" in reroute_error
|
|
4202
|
+
assert rerouted_state["phases"]["implement"]["verdict"] == "BLOCKED"
|
|
4203
|
+
do_spawn(rerouted_state, "implement", 1, None, None, "codex", receipt_model,
|
|
4204
|
+
prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn)
|
|
4205
|
+
assert rerouted_state["phases"]["implement"]["history"][-1]["verdict"] == "BLOCKED"
|
|
4206
|
+
|
|
4207
|
+
plan_prompt = receipt_devlyn / "plan.prompt.0"
|
|
4208
|
+
plan_prompt.write_text("plan exactly\n", encoding="utf-8")
|
|
4209
|
+
plan_session = receipt_devlyn / "plan.worker-session.0.jsonl"
|
|
4210
|
+
plan_session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
|
|
4211
|
+
plan_path = receipt_devlyn / "plan.invocation.0.json"
|
|
4212
|
+
(receipt_devlyn / "plan.md").write_text("## Files to touch\n", encoding="utf-8")
|
|
4213
|
+
plan_model = "gpt-5.6-sol"
|
|
4214
|
+
plan_prompt_sha = hashlib.sha256(plan_prompt.read_bytes()).hexdigest()
|
|
4215
|
+
plan_state = {
|
|
4216
|
+
"version": "3.0",
|
|
4217
|
+
"run_id": "rs-plan-invocation",
|
|
4218
|
+
"engine": "codex",
|
|
4219
|
+
"phases": {"plan": None},
|
|
4220
|
+
}
|
|
4221
|
+
do_spawn(
|
|
4222
|
+
plan_state, "plan", 0, None, None, "codex", plan_model,
|
|
4223
|
+
prompt_sha256=plan_prompt_sha, devlyn=receipt_devlyn,
|
|
4224
|
+
)
|
|
4225
|
+
receipt_runner.start_receipt(
|
|
4226
|
+
receipt_work, plan_path, plan_state["run_id"], "plan", 0,
|
|
4227
|
+
str(plan_prompt), str(plan_session),
|
|
4228
|
+
["--json", "-C", str(receipt_work), "-s", "workspace-write",
|
|
4229
|
+
"-m", plan_model, "-c",
|
|
4230
|
+
"sandbox_workspace_write.network_access=false", "plan exactly"],
|
|
4231
|
+
)
|
|
4232
|
+
receipt_runner.finish_receipt(receipt_work, plan_path, 0)
|
|
4233
|
+
assert do_complete(
|
|
4234
|
+
plan_state, "plan", "PASS", None, None, None, None, None,
|
|
4235
|
+
str(plan_session), devlyn=receipt_devlyn, work=receipt_work,
|
|
4236
|
+
) is None
|
|
4237
|
+
plan_entry = plan_state["phases"]["plan"]
|
|
4238
|
+
assert plan_entry["model_effective"] is None
|
|
4239
|
+
assert plan_entry["model_requested"] == plan_model
|
|
4240
|
+
assert plan_entry["invocation_receipt"]["path"] == ".devlyn/plan.invocation.0.json"
|
|
4241
|
+
assert plan_entry["output_sha256"] == hashlib.sha256(
|
|
4242
|
+
(receipt_devlyn / "plan.md").read_bytes()
|
|
4243
|
+
).hexdigest()
|
|
4244
|
+
plan_prompt_1 = receipt_devlyn / "plan.prompt.1"
|
|
4245
|
+
plan_prompt_1.write_text("replan exactly\n", encoding="utf-8")
|
|
4246
|
+
plan_session_1 = receipt_devlyn / "plan.worker-session.1.jsonl"
|
|
4247
|
+
plan_session_1.write_text('{"type":"thread.started"}\n', encoding="utf-8")
|
|
4248
|
+
plan_path_1 = receipt_devlyn / "plan.invocation.1.json"
|
|
4249
|
+
plan_prompt_sha_1 = hashlib.sha256(plan_prompt_1.read_bytes()).hexdigest()
|
|
4250
|
+
do_spawn(
|
|
4251
|
+
plan_state, "plan", 1, "plan", None, "codex", plan_model,
|
|
4252
|
+
prompt_sha256=plan_prompt_sha_1, devlyn=receipt_devlyn,
|
|
4253
|
+
)
|
|
4254
|
+
plan_history = plan_state["phases"]["plan"]["history"]
|
|
4255
|
+
assert plan_history[0]["invocation_receipt"]["path"] == (
|
|
4256
|
+
".devlyn/plan.invocation.0.json"
|
|
4257
|
+
)
|
|
4258
|
+
assert "invocation_receipt" not in plan_state["phases"]["plan"]
|
|
4259
|
+
receipt_runner.start_receipt(
|
|
4260
|
+
receipt_work, plan_path_1, plan_state["run_id"], "plan", 1,
|
|
4261
|
+
str(plan_prompt_1), str(plan_session_1),
|
|
4262
|
+
["--json", "-C", str(receipt_work), "-s", "workspace-write",
|
|
4263
|
+
"-m", plan_model, "-c",
|
|
4264
|
+
"sandbox_workspace_write.network_access=false", "replan exactly"],
|
|
4265
|
+
)
|
|
4266
|
+
receipt_runner.finish_receipt(receipt_work, plan_path_1, 0)
|
|
4267
|
+
assert do_complete(
|
|
4268
|
+
plan_state, "plan", "PASS", None, None, None, None, None,
|
|
4269
|
+
str(plan_session_1), devlyn=receipt_devlyn, work=receipt_work,
|
|
4270
|
+
) is None
|
|
4271
|
+
assert plan_state["phases"]["plan"]["invocation_receipt"]["path"] == (
|
|
4272
|
+
".devlyn/plan.invocation.1.json"
|
|
4273
|
+
)
|
|
4274
|
+
|
|
4275
|
+
confused_state = {
|
|
4276
|
+
"version": "3.0",
|
|
4277
|
+
"run_id": "rs-invocation-confused",
|
|
4278
|
+
"engine": "codex",
|
|
4279
|
+
"phases": {"implement": None},
|
|
4280
|
+
}
|
|
4281
|
+
do_spawn(
|
|
4282
|
+
confused_state, "implement", 0, None, None, "codex", receipt_model,
|
|
4283
|
+
prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
|
|
4284
|
+
)
|
|
4285
|
+
confused_before = copy.deepcopy(confused_state)
|
|
4286
|
+
try:
|
|
4287
|
+
do_complete(
|
|
4288
|
+
confused_state, "implement", "PASS", None, None, None,
|
|
4289
|
+
"claude", receipt_model, str(receipt_session),
|
|
4290
|
+
devlyn=receipt_devlyn, work=receipt_work,
|
|
4291
|
+
)
|
|
4292
|
+
except SystemExit as exc:
|
|
4293
|
+
assert "cannot replace spawn engine" in str(exc)
|
|
4294
|
+
else:
|
|
4295
|
+
raise AssertionError("completion replaced a Codex spawn with Claude attestation")
|
|
4296
|
+
assert confused_state == confused_before
|
|
4297
|
+
try:
|
|
4298
|
+
do_complete(
|
|
4299
|
+
confused_state, "implement", "PASS", None, None, None,
|
|
4300
|
+
None, "other-model", str(receipt_session),
|
|
4301
|
+
devlyn=receipt_devlyn, work=receipt_work,
|
|
4302
|
+
)
|
|
4303
|
+
except SystemExit as exc:
|
|
4304
|
+
assert "cannot replace requested model" in str(exc)
|
|
4305
|
+
else:
|
|
4306
|
+
raise AssertionError("completion replaced the requested Codex model")
|
|
4307
|
+
assert confused_state == confused_before
|
|
4308
|
+
|
|
4309
|
+
wrong_path_state = {
|
|
4310
|
+
"version": "3.0",
|
|
4311
|
+
"run_id": "rs-invocation-wrong-path",
|
|
4312
|
+
"engine": "codex",
|
|
4313
|
+
"phases": {"implement": None},
|
|
4314
|
+
}
|
|
4315
|
+
do_spawn(
|
|
4316
|
+
wrong_path_state, "implement", 0, None, None, "codex", receipt_model,
|
|
4317
|
+
prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
|
|
4318
|
+
)
|
|
4319
|
+
arbitrary_log = receipt_devlyn / "handwritten.log"
|
|
4320
|
+
arbitrary_log.write_text(f"model: {receipt_model}\n", encoding="utf-8")
|
|
4321
|
+
wrong_path_error = do_complete(
|
|
4322
|
+
wrong_path_state, "implement", "PASS", None, None, None, None, None,
|
|
4323
|
+
str(arbitrary_log), devlyn=receipt_devlyn, work=receipt_work,
|
|
4324
|
+
)
|
|
4325
|
+
assert wrong_path_error and "canonical phase/round session" in wrong_path_error
|
|
4326
|
+
assert wrong_path_state["phases"]["implement"]["verdict"] == "BLOCKED"
|
|
4327
|
+
print("PASS iter-0112 canonical Codex invocation receipt and session ownership")
|
|
4328
|
+
|
|
4329
|
+
# Iter-0121: rs-20260906T173436Z-5a086a70590e exited 1 with an
|
|
4330
|
+
# empty canonical session and no plan. Exercise the real CLI writes.
|
|
4331
|
+
receipt_state_path = receipt_devlyn / "pipeline.state.json"
|
|
4332
|
+
missing_plan_output = receipt_devlyn / "plan.md"
|
|
4333
|
+
missing_plan_output.unlink()
|
|
4334
|
+
plan_session.write_bytes(b"")
|
|
4335
|
+
plan_path.unlink()
|
|
4336
|
+
receipt_runner.start_receipt(
|
|
4337
|
+
receipt_work, plan_path, plan_state["run_id"], "plan", 0,
|
|
4338
|
+
str(plan_prompt), str(plan_session),
|
|
4339
|
+
["--json", "-C", str(receipt_work), "-s", "workspace-write",
|
|
4340
|
+
"-m", plan_model, "-c",
|
|
4341
|
+
"sandbox_workspace_write.network_access=false", "plan exactly"],
|
|
4342
|
+
)
|
|
4343
|
+
receipt_runner.finish_receipt(receipt_work, plan_path, 1)
|
|
4344
|
+
failed_receipt_bytes = plan_path.read_bytes()
|
|
4345
|
+
|
|
4346
|
+
def receipt_cli(phase: str, *event_args: str) -> subprocess.CompletedProcess[str]:
|
|
4347
|
+
return subprocess.run(
|
|
4348
|
+
[sys.executable, script, "--devlyn-dir", str(receipt_devlyn),
|
|
4349
|
+
"--phase", phase, *event_args],
|
|
4350
|
+
cwd=receipt_work, capture_output=True, text=True, check=False,
|
|
4351
|
+
)
|
|
4352
|
+
|
|
4353
|
+
write_state(receipt_state_path, {
|
|
4354
|
+
"version": "3.0", "run_id": plan_state["run_id"],
|
|
4355
|
+
"engine": "codex", "phases": {},
|
|
4356
|
+
})
|
|
4357
|
+
result = receipt_cli(
|
|
4358
|
+
"plan", "spawn", "--round", "0", "--engine", "codex",
|
|
4359
|
+
"--model", plan_model, "--prompt-sha256", plan_prompt_sha,
|
|
4360
|
+
)
|
|
4361
|
+
assert result.returncode == 0, result.stderr
|
|
4362
|
+
open_plan_bytes = receipt_state_path.read_bytes()
|
|
4363
|
+
blocked_args = (
|
|
4364
|
+
"--verdict", "BLOCKED", "--engine-session-log", str(plan_session),
|
|
4365
|
+
)
|
|
4366
|
+
result = receipt_cli("plan", "complete", *blocked_args)
|
|
4367
|
+
assert result.returncode == 1, result.stderr
|
|
4368
|
+
assert "BLOCKED:invocation-receipt-invalid: Codex invocation exited 1" in result.stderr, result.stderr
|
|
4369
|
+
blocked_plan = read_state(receipt_state_path)["phases"]["plan"]
|
|
4370
|
+
assert blocked_plan["verdict"] == "BLOCKED"
|
|
4371
|
+
assert blocked_plan["output_sha256"] is None
|
|
4372
|
+
assert blocked_plan["model_effective"] is None
|
|
4373
|
+
assert blocked_plan["completed_at"] is not None
|
|
4374
|
+
assert blocked_plan["duration_ms"] == round((
|
|
4375
|
+
parse_iso(blocked_plan["completed_at"]) - parse_iso(blocked_plan["started_at"])
|
|
4376
|
+
).total_seconds() * 1000)
|
|
4377
|
+
assert blocked_plan["duration_ms"] >= 0
|
|
4378
|
+
assert not os.path.lexists(missing_plan_output)
|
|
4379
|
+
assert plan_path.read_bytes() == failed_receipt_bytes
|
|
4380
|
+
print("PASS iter-0121 failed PLAN worker persists BLOCKED timing/null output and receipt error")
|
|
4381
|
+
|
|
4382
|
+
for phase, event_args in [
|
|
4383
|
+
(phase, (
|
|
4384
|
+
"spawn", "--round", "1" if phase == "plan" else "0",
|
|
4385
|
+
"--triggered-by", "plan", "--engine", "codex",
|
|
4386
|
+
"--model", plan_model, "--prompt-sha256", plan_prompt_sha,
|
|
4387
|
+
)) for phase in sorted(PHASE_NAMES - {"final_report"})
|
|
4388
|
+
] + [
|
|
4389
|
+
("surface_close", ("surface-skip",)),
|
|
4390
|
+
("surface_close", ("surface-check", "--authorized-surface-json", "[]")),
|
|
4391
|
+
("surface_close", ("surface-adjudication-recover", "--authorized-surface-json", "[]")),
|
|
4392
|
+
("surface_close", ("surface-rollback",)),
|
|
4393
|
+
("implement", ("durability-enforce", "--round", "1", "--origin-phase", "verify")),
|
|
4394
|
+
]:
|
|
4395
|
+
before = receipt_state_path.read_bytes()
|
|
4396
|
+
result = receipt_cli(phase, *event_args)
|
|
4397
|
+
assert result.returncode == 1, (phase, event_args, result.stderr)
|
|
4398
|
+
assert "BLOCKED:plan-output-missing" in result.stderr, (phase, event_args, result.stderr)
|
|
4399
|
+
assert receipt_state_path.read_bytes() == before
|
|
4400
|
+
result = receipt_cli("final_report", "spawn", "--round", "0")
|
|
4401
|
+
assert result.returncode == 0, result.stderr
|
|
4402
|
+
blocked_report = receipt_devlyn / "final-report.md"
|
|
4403
|
+
blocked_report.write_text(
|
|
4404
|
+
f"<!-- devlyn:final-report run_id={read_state(receipt_state_path)['run_id']} -->\n"
|
|
4405
|
+
"# BLOCKED\nPLAN invocation failed; no PLAN output was produced.\n", encoding="utf-8",
|
|
4406
|
+
)
|
|
4407
|
+
result = receipt_cli("final_report", "complete", "--verdict", "BLOCKED", "--log-file", str(blocked_report))
|
|
4408
|
+
assert result.returncode == 0, result.stderr
|
|
4409
|
+
terminal_state = read_state(receipt_state_path)
|
|
4410
|
+
assert terminal_state["phases"]["plan"] == blocked_plan
|
|
4411
|
+
assert terminal_state["phases"]["final_report"]["completed_at"] is not None
|
|
4412
|
+
assert terminal_state["phases"]["final_report"]["verdict"] == "BLOCKED"
|
|
4413
|
+
assert terminal_state["phases"]["final_report"]["output_sha256"] == hashlib.sha256(blocked_report.read_bytes()).hexdigest()
|
|
4414
|
+
with tempfile.TemporaryDirectory() as archive_tmp:
|
|
4415
|
+
archive_work = pathlib.Path(archive_tmp)
|
|
4416
|
+
archive_devlyn = archive_work / ".devlyn"
|
|
4417
|
+
archive_devlyn.mkdir()
|
|
4418
|
+
retained = (receipt_state_path, blocked_report, plan_path, plan_prompt, plan_session)
|
|
4419
|
+
for source in retained:
|
|
4420
|
+
shutil.copy2(source, archive_devlyn / source.name)
|
|
4421
|
+
archived = subprocess.run(
|
|
4422
|
+
[sys.executable, str(pathlib.Path(script).with_name("archive_run.py"))],
|
|
4423
|
+
cwd=archive_work, capture_output=True, text=True,
|
|
4424
|
+
)
|
|
4425
|
+
assert archived.returncode == 0, archived.stderr
|
|
4426
|
+
destination = archive_devlyn / "runs" / terminal_state["run_id"]
|
|
4427
|
+
assert not (archive_devlyn / "pipeline.state.json").exists()
|
|
4428
|
+
assert not (destination / "plan.md").exists()
|
|
4429
|
+
assert all((destination / source.name).read_bytes() == source.read_bytes() for source in retained)
|
|
4430
|
+
print("PASS iter-0121 missing-output BLOCKED permits final closure and refuses all work spawns/special events")
|
|
4431
|
+
|
|
4432
|
+
# Non-BLOCKED requests cannot obtain the exception via failed attestation.
|
|
4433
|
+
for verdict in sorted(VALID_VERDICTS - {"BLOCKED"}):
|
|
4434
|
+
receipt_state_path.write_bytes(open_plan_bytes)
|
|
4435
|
+
result = receipt_cli(
|
|
4436
|
+
"plan", "complete", "--verdict", verdict,
|
|
4437
|
+
"--engine-session-log", str(plan_session),
|
|
4438
|
+
)
|
|
4439
|
+
assert result.returncode == 1, (verdict, result.stderr)
|
|
4440
|
+
assert "BLOCKED:plan-integrity-invalid" in result.stderr
|
|
4441
|
+
assert receipt_state_path.read_bytes() == open_plan_bytes
|
|
4442
|
+
for next_phase in ("implement", "probe_derive", "final_report"):
|
|
4443
|
+
result = receipt_cli(
|
|
4444
|
+
"plan", "transition", *blocked_args, "--next-phase", next_phase,
|
|
4445
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
4446
|
+
)
|
|
4447
|
+
assert result.returncode == 1, result.stderr
|
|
4448
|
+
assert "Codex invocation exited 1" in result.stderr
|
|
4449
|
+
assert receipt_state_path.read_bytes() == open_plan_bytes
|
|
4450
|
+
plan_path.write_text("{", encoding="utf-8")
|
|
4451
|
+
result = receipt_cli("plan", "complete", *blocked_args)
|
|
4452
|
+
assert result.returncode == 1, result.stderr
|
|
4453
|
+
assert "BLOCKED:invocation-receipt-invalid" in result.stderr
|
|
4454
|
+
malformed_plan = read_state(receipt_state_path)["phases"]["plan"]
|
|
4455
|
+
assert malformed_plan["verdict"] == "BLOCKED"
|
|
4456
|
+
assert malformed_plan["model_effective"] is None
|
|
4457
|
+
assert malformed_plan["output_sha256"] is None
|
|
4458
|
+
assert malformed_plan["completed_at"] is not None
|
|
4459
|
+
|
|
4460
|
+
# Even attested BLOCKED completion cannot transition into more work.
|
|
4461
|
+
plan_path.unlink()
|
|
4462
|
+
receipt_runner.start_receipt(
|
|
4463
|
+
receipt_work, plan_path, plan_state["run_id"], "plan", 0,
|
|
4464
|
+
str(plan_prompt), str(plan_session),
|
|
4465
|
+
["--json", "-C", str(receipt_work), "-s", "workspace-write",
|
|
4466
|
+
"-m", plan_model, "-c",
|
|
4467
|
+
"sandbox_workspace_write.network_access=false", "plan exactly"],
|
|
4468
|
+
)
|
|
4469
|
+
receipt_runner.finish_receipt(receipt_work, plan_path, 0)
|
|
4470
|
+
receipt_state_path.write_bytes(open_plan_bytes)
|
|
4471
|
+
for next_phase in ("implement", "probe_derive"):
|
|
4472
|
+
result = receipt_cli(
|
|
4473
|
+
"plan", "transition", *blocked_args, "--next-phase", next_phase,
|
|
4474
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
4475
|
+
)
|
|
4476
|
+
assert result.returncode == 1, result.stderr
|
|
4477
|
+
assert "BLOCKED:plan-output-missing" in result.stderr
|
|
4478
|
+
assert receipt_state_path.read_bytes() == open_plan_bytes
|
|
4479
|
+
result = receipt_cli(
|
|
4480
|
+
"plan", "transition", *blocked_args, "--next-phase", "final_report",
|
|
4481
|
+
"--next-round", "0",
|
|
4482
|
+
)
|
|
4483
|
+
assert result.returncode == 0, result.stderr
|
|
4484
|
+
absent_transition_bytes = receipt_state_path.read_bytes()
|
|
4485
|
+
assert read_state(receipt_state_path)["phases"]["plan"]["model_effective"] is None
|
|
4486
|
+
print("PASS iter-0121 non-BLOCKED output requirement, malformed receipt and atomic transitions")
|
|
4487
|
+
|
|
4488
|
+
for path_kind in ("file", "directory", "dangling-symlink", "unreadable"):
|
|
4489
|
+
if path_kind == "directory":
|
|
4490
|
+
missing_plan_output.mkdir()
|
|
4491
|
+
elif path_kind == "dangling-symlink":
|
|
4492
|
+
missing_plan_output.symlink_to(receipt_devlyn / "missing-target")
|
|
4493
|
+
else:
|
|
4494
|
+
missing_plan_output.write_bytes(b"## Files to touch\n")
|
|
4495
|
+
if path_kind == "unreadable":
|
|
4496
|
+
missing_plan_output.chmod(0)
|
|
4497
|
+
try:
|
|
4498
|
+
receipt_state_path.write_bytes(absent_transition_bytes)
|
|
4499
|
+
result = receipt_cli("final_report", "complete", "--verdict", "BLOCKED")
|
|
4500
|
+
assert result.returncode == 1, (path_kind, result.stderr)
|
|
4501
|
+
assert "BLOCKED:plan-integrity-invalid" in result.stderr
|
|
4502
|
+
assert receipt_state_path.read_bytes() == absent_transition_bytes
|
|
4503
|
+
if path_kind in {"directory", "dangling-symlink"} or (
|
|
4504
|
+
path_kind == "unreadable" and not os.access(missing_plan_output, os.R_OK)
|
|
4505
|
+
):
|
|
4506
|
+
receipt_state_path.write_bytes(open_plan_bytes)
|
|
4507
|
+
result = receipt_cli("plan", "complete", *blocked_args)
|
|
4508
|
+
assert result.returncode == 1, (path_kind, result.stderr)
|
|
4509
|
+
assert "BLOCKED:plan-integrity-invalid" in result.stderr
|
|
4510
|
+
assert receipt_state_path.read_bytes() == open_plan_bytes
|
|
4511
|
+
finally:
|
|
4512
|
+
if path_kind == "directory":
|
|
4513
|
+
missing_plan_output.rmdir()
|
|
4514
|
+
else:
|
|
4515
|
+
if path_kind == "unreadable":
|
|
4516
|
+
missing_plan_output.chmod(0o600)
|
|
4517
|
+
missing_plan_output.unlink()
|
|
4518
|
+
print("PASS iter-0121 lexical absence rejects existing/nonregular/unreadable output")
|
|
4519
|
+
|
|
4520
|
+
# BLOCKED with bytes still binds them, including across legal correction.
|
|
4521
|
+
missing_plan_output.write_bytes(b"## Files to touch\n")
|
|
4522
|
+
receipt_state_path.write_bytes(open_plan_bytes)
|
|
4523
|
+
result = receipt_cli("plan", "complete", *blocked_args)
|
|
4524
|
+
assert result.returncode == 0, result.stderr
|
|
4525
|
+
bound_bytes = receipt_state_path.read_bytes()
|
|
4526
|
+
assert read_state(receipt_state_path)["phases"]["plan"]["output_sha256"] == hashlib.sha256(
|
|
4527
|
+
missing_plan_output.read_bytes()
|
|
4528
|
+
).hexdigest()
|
|
4529
|
+
result = receipt_cli(
|
|
4530
|
+
"plan", "spawn", "--round", "1", "--triggered-by", "plan",
|
|
4531
|
+
"--engine", "codex", "--model", plan_model,
|
|
4532
|
+
"--prompt-sha256", plan_prompt_sha_1,
|
|
4533
|
+
)
|
|
4534
|
+
assert result.returncode == 0, result.stderr
|
|
4535
|
+
correction_bytes = receipt_state_path.read_bytes()
|
|
4536
|
+
missing_plan_output.unlink()
|
|
4537
|
+
result = receipt_cli(
|
|
4538
|
+
"plan", "complete", "--verdict", "BLOCKED",
|
|
4539
|
+
"--engine-session-log", str(plan_session_1),
|
|
4540
|
+
)
|
|
4541
|
+
assert result.returncode == 1, result.stderr
|
|
4542
|
+
assert "BLOCKED:plan-integrity-invalid" in result.stderr
|
|
4543
|
+
assert receipt_state_path.read_bytes() == correction_bytes
|
|
4544
|
+
for changed in (False, True):
|
|
4545
|
+
if changed:
|
|
4546
|
+
missing_plan_output.write_bytes(b"altered plan\n")
|
|
4547
|
+
receipt_state_path.write_bytes(bound_bytes)
|
|
4548
|
+
result = receipt_cli("final_report", "spawn", "--round", "0")
|
|
4549
|
+
assert result.returncode == 1, result.stderr
|
|
4550
|
+
assert "BLOCKED:plan-integrity-" in result.stderr
|
|
4551
|
+
assert receipt_state_path.read_bytes() == bound_bytes
|
|
4552
|
+
print("PASS iter-0121 BLOCKED output binding rejects deletion/tampering and lost correction output")
|
|
4553
|
+
|
|
2118
4554
|
retained_log.unlink()
|
|
2119
4555
|
write_state(state_path, {"phases": {}})
|
|
2120
4556
|
state = read_state(state_path)
|
|
@@ -2128,14 +4564,20 @@ def self_test() -> int:
|
|
|
2128
4564
|
# Supplied evidence must parse and never silently record null.
|
|
2129
4565
|
write_state(state_path, {"phases": {}})
|
|
2130
4566
|
state = read_state(state_path)
|
|
2131
|
-
do_spawn(
|
|
4567
|
+
do_spawn(
|
|
4568
|
+
state, "plan", 0, None, None, "claude", "claude-default",
|
|
4569
|
+
prompt_sha256=digest0,
|
|
4570
|
+
)
|
|
2132
4571
|
assert do_complete(state, "plan", "PASS", None, None, None, None, None) is None
|
|
2133
4572
|
assert state["phases"]["plan"]["model_effective"] is None
|
|
2134
4573
|
invalid_log = devlyn / "invalid-session.log"
|
|
2135
4574
|
invalid_log.write_text("no model evidence\n", encoding="utf-8")
|
|
2136
4575
|
write_state(state_path, {"phases": {}})
|
|
2137
4576
|
state = read_state(state_path)
|
|
2138
|
-
do_spawn(
|
|
4577
|
+
do_spawn(
|
|
4578
|
+
state, "plan", 0, None, None, "claude", "claude-default",
|
|
4579
|
+
prompt_sha256=digest0,
|
|
4580
|
+
)
|
|
2139
4581
|
invalid = do_complete(
|
|
2140
4582
|
state, "plan", "PASS", None, None, None, None, None, str(invalid_log)
|
|
2141
4583
|
)
|
|
@@ -2227,7 +4669,6 @@ def self_test() -> int:
|
|
|
2227
4669
|
["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
|
|
2228
4670
|
check=True, capture_output=True, text=True,
|
|
2229
4671
|
).stdout.strip() == "chore(pipeline): closure-restore round 1"
|
|
2230
|
-
import importlib.util
|
|
2231
4672
|
gate_spec = importlib.util.spec_from_file_location(
|
|
2232
4673
|
"f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
|
|
2233
4674
|
/ "benchmark/ceiling/scripts/f7-carrier-gate.py",
|
|
@@ -2502,11 +4943,13 @@ def main() -> int:
|
|
|
2502
4943
|
ap.add_argument("--devlyn-dir", default=".devlyn")
|
|
2503
4944
|
ap.add_argument("--phase", choices=sorted(PHASE_NAMES))
|
|
2504
4945
|
ap.add_argument("--self-test", action="store_true")
|
|
4946
|
+
ap.add_argument("--freeze-roles", action="store_true")
|
|
4947
|
+
ap.add_argument("--default-engine", default="claude")
|
|
2505
4948
|
sub = ap.add_subparsers(dest="event")
|
|
2506
4949
|
|
|
2507
4950
|
spawn_p = sub.add_parser("spawn")
|
|
2508
4951
|
spawn_p.add_argument("--round", type=int, required=True)
|
|
2509
|
-
spawn_p.add_argument("--triggered-by", choices=sorted(
|
|
4952
|
+
spawn_p.add_argument("--triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
|
|
2510
4953
|
spawn_p.add_argument("--pre-sha", default=None)
|
|
2511
4954
|
spawn_p.add_argument("--input-patch-sha256", default=None)
|
|
2512
4955
|
spawn_p.add_argument("--prompt-sha256", default=None)
|
|
@@ -2533,7 +4976,7 @@ def main() -> int:
|
|
|
2533
4976
|
transition_p.add_argument("--engine-session-log", default=None)
|
|
2534
4977
|
transition_p.add_argument("--next-phase", choices=sorted(PHASE_NAMES), required=True)
|
|
2535
4978
|
transition_p.add_argument("--next-round", type=int, required=True)
|
|
2536
|
-
transition_p.add_argument("--next-triggered-by", choices=sorted(
|
|
4979
|
+
transition_p.add_argument("--next-triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
|
|
2537
4980
|
transition_p.add_argument("--next-pre-sha", default=None)
|
|
2538
4981
|
transition_p.add_argument("--next-input-patch-sha256", default=None)
|
|
2539
4982
|
transition_p.add_argument("--next-prompt-sha256", default=None)
|
|
@@ -2555,12 +4998,24 @@ def main() -> int:
|
|
|
2555
4998
|
if args.self_test:
|
|
2556
4999
|
return self_test()
|
|
2557
5000
|
|
|
5001
|
+
if args.freeze_roles:
|
|
5002
|
+
devlyn = pathlib.Path(args.devlyn_dir)
|
|
5003
|
+
state_path = devlyn / "pipeline.state.json"
|
|
5004
|
+
try:
|
|
5005
|
+
state = read_state(state_path)
|
|
5006
|
+
result = freeze_roles(state, devlyn.resolve().parent, args.default_engine)
|
|
5007
|
+
write_state(state_path, state)
|
|
5008
|
+
print(json.dumps(result, sort_keys=True))
|
|
5009
|
+
return 0
|
|
5010
|
+
except (ValueError, OSError) as exc:
|
|
5011
|
+
print(str(exc), file=sys.stderr)
|
|
5012
|
+
return 1
|
|
5013
|
+
|
|
2558
5014
|
surface_events = {
|
|
2559
5015
|
"surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
|
|
2560
5016
|
}
|
|
2561
5017
|
if not args.phase or args.event not in {"spawn", "complete", "transition", "durability-enforce", *surface_events}:
|
|
2562
5018
|
ap.error("--phase and a phase event are required unless --self-test")
|
|
2563
|
-
|
|
2564
5019
|
devlyn = pathlib.Path(args.devlyn_dir)
|
|
2565
5020
|
if not devlyn.is_dir():
|
|
2566
5021
|
sys.stderr.write(f"error: {devlyn} is not a directory\n")
|
|
@@ -2571,6 +5026,7 @@ def main() -> int:
|
|
|
2571
5026
|
if args.event == "durability-enforce":
|
|
2572
5027
|
if args.phase != "implement":
|
|
2573
5028
|
ap.error("durability-enforce is valid only for --phase implement")
|
|
5029
|
+
validate_plan_output(state, devlyn, args.phase)
|
|
2574
5030
|
_persist_durability_event(
|
|
2575
5031
|
pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
|
|
2576
5032
|
)
|
|
@@ -2580,6 +5036,7 @@ def main() -> int:
|
|
|
2580
5036
|
if args.event in surface_events:
|
|
2581
5037
|
if args.phase != "surface_close":
|
|
2582
5038
|
ap.error(f"{args.event} is valid only for --phase surface_close")
|
|
5039
|
+
validate_plan_output(state, devlyn, args.phase)
|
|
2583
5040
|
if args.event == "surface-skip":
|
|
2584
5041
|
do_surface_skip(state)
|
|
2585
5042
|
write_state(state_path, state)
|
|
@@ -2652,6 +5109,7 @@ def main() -> int:
|
|
|
2652
5109
|
input_patch_sha256=args.input_patch_sha256,
|
|
2653
5110
|
prompt_sha256=args.prompt_sha256,
|
|
2654
5111
|
untracked_before=untracked_before,
|
|
5112
|
+
devlyn=devlyn,
|
|
2655
5113
|
)
|
|
2656
5114
|
else:
|
|
2657
5115
|
next_untracked_before = (
|
|
@@ -2669,6 +5127,7 @@ def main() -> int:
|
|
|
2669
5127
|
next_input_patch_sha256=args.next_input_patch_sha256,
|
|
2670
5128
|
next_prompt_sha256=args.next_prompt_sha256,
|
|
2671
5129
|
next_untracked_before=next_untracked_before,
|
|
5130
|
+
work=pathlib.Path.cwd(),
|
|
2672
5131
|
)
|
|
2673
5132
|
if spawn_phase == "surface_close":
|
|
2674
5133
|
validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
|
|
@@ -2700,7 +5159,16 @@ def main() -> int:
|
|
|
2700
5159
|
attestation_error = do_complete(
|
|
2701
5160
|
state, args.phase, args.verdict, args.post_sha, args.findings_file,
|
|
2702
5161
|
args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
|
|
5162
|
+
pathlib.Path.cwd(),
|
|
2703
5163
|
)
|
|
5164
|
+
if (
|
|
5165
|
+
args.phase == "plan"
|
|
5166
|
+
and state["phases"]["plan"]["verdict"] in {"PASS", "PASS_WITH_ISSUES"}
|
|
5167
|
+
):
|
|
5168
|
+
raise SystemExit(
|
|
5169
|
+
"error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
|
|
5170
|
+
"--phase plan transition --verdict <verdict> --next-phase <phase>"
|
|
5171
|
+
)
|
|
2704
5172
|
|
|
2705
5173
|
if args.event not in {"spawn", "transition"}:
|
|
2706
5174
|
write_state(state_path, state)
|