devlyn-cli 2.10.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -9
- package/CLAUDE.md +17 -17
- package/README.md +33 -9
- 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/bin/devlyn.js +9 -2
- 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 +4 -2
- 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 +311 -128
- package/config/skills/_shared/engine-preflight.md +8 -7
- package/config/skills/_shared/expected.schema.json +61 -0
- package/config/skills/_shared/finish-gate.py +12 -11
- package/config/skills/_shared/grok-anchor-guard.py +18 -1
- 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/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 +749 -181
- package/config/skills/_shared/state-phase-write.py +1838 -58
- package/config/skills/_shared/terminal-claim-check.py +15 -0
- package/config/skills/_shared/verify-merge-findings.py +958 -51
- package/config/skills/devlyn:engines/SKILL.md +8 -16
- package/config/skills/devlyn:ideate/SKILL.md +2 -2
- package/config/skills/devlyn:ideate/references/spec-template.md +6 -5
- package/config/skills/devlyn:queue/SKILL.md +7 -4
- package/config/skills/devlyn:resolve/SKILL.md +93 -35
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -0
- package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
- package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
- package/package.json +5 -1
- package/scripts/lint-skills.sh +314 -285
|
@@ -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
|
|
@@ -51,11 +53,12 @@ LEGAL_TRANSITIONS = {
|
|
|
51
53
|
"final_report": set(),
|
|
52
54
|
}
|
|
53
55
|
WORKER_SESSION_ARTIFACT_PHASES = {
|
|
56
|
+
"plan": "plan",
|
|
54
57
|
"implement": "implement",
|
|
55
58
|
"surface_close": "surface-close",
|
|
59
|
+
"build_gate": "build_gate",
|
|
56
60
|
"cleanup": "cleanup",
|
|
57
61
|
}
|
|
58
|
-
MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
|
|
59
62
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
60
63
|
SURFACE_ROW_RE = re.compile(
|
|
61
64
|
r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
|
|
@@ -74,17 +77,32 @@ PLAN_SPAWN_RECEIPT_FIELDS = (
|
|
|
74
77
|
"round", "started_at", "triggered_by", "engine", "model_requested", "prompt_sha256",
|
|
75
78
|
)
|
|
76
79
|
PLAN_COMPLETION_RECEIPT_FIELDS = (
|
|
77
|
-
"completed_at", "duration_ms", "verdict", "model_effective",
|
|
80
|
+
"completed_at", "duration_ms", "verdict", "model_effective", "output_sha256",
|
|
78
81
|
)
|
|
79
82
|
PLAN_RECEIPT_FIELDS = PLAN_SPAWN_RECEIPT_FIELDS + PLAN_COMPLETION_RECEIPT_FIELDS
|
|
83
|
+
_PROCESS_EVIDENCE_MODULE = None
|
|
84
|
+
_INVOCATION_RECEIPT_MODULE = None
|
|
80
85
|
|
|
81
86
|
|
|
82
87
|
def reject_json_constant(token: str) -> None:
|
|
83
88
|
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
84
89
|
|
|
85
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
|
+
|
|
86
100
|
def loads_strict_json(text: str):
|
|
87
|
-
return json.loads(
|
|
101
|
+
return json.loads(
|
|
102
|
+
text,
|
|
103
|
+
parse_constant=reject_json_constant,
|
|
104
|
+
object_pairs_hook=reject_duplicate_keys,
|
|
105
|
+
)
|
|
88
106
|
|
|
89
107
|
|
|
90
108
|
def now_ms() -> datetime.datetime:
|
|
@@ -129,6 +147,236 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
|
|
|
129
147
|
raise
|
|
130
148
|
|
|
131
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
|
+
|
|
132
380
|
def parse_string_list(raw: str, label: str) -> list[str]:
|
|
133
381
|
try:
|
|
134
382
|
value = loads_strict_json(raw)
|
|
@@ -235,6 +483,24 @@ def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, stat
|
|
|
235
483
|
raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
|
|
236
484
|
|
|
237
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
|
+
|
|
238
504
|
def validate_authorized_surface(raw: str) -> list[str]:
|
|
239
505
|
surface = parse_string_list(raw, "--authorized-surface-json")
|
|
240
506
|
if not surface:
|
|
@@ -247,23 +513,42 @@ def validate_authorized_surface(raw: str) -> list[str]:
|
|
|
247
513
|
or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
|
|
248
514
|
):
|
|
249
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}")
|
|
250
519
|
return surface
|
|
251
520
|
|
|
252
521
|
|
|
253
522
|
def path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
254
|
-
parsed = pathlib.PurePosixPath(path)
|
|
255
|
-
if parsed.is_absolute() or ".." in parsed.parts:
|
|
256
|
-
return False
|
|
257
523
|
for entry in surface:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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:
|
|
261
543
|
return True
|
|
262
|
-
elif path == entry:
|
|
263
|
-
return True
|
|
264
544
|
return False
|
|
265
545
|
|
|
266
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
|
+
|
|
267
552
|
def worktree_file_exists(work: pathlib.Path, path: str) -> bool:
|
|
268
553
|
parsed = pathlib.PurePosixPath(path)
|
|
269
554
|
if parsed.is_absolute() or ".." in parsed.parts:
|
|
@@ -296,17 +581,17 @@ def resolve_na_surface_citation(
|
|
|
296
581
|
if path in surface and not path.endswith("/**"):
|
|
297
582
|
return True
|
|
298
583
|
return worktree_file_exists(work, path) and any(
|
|
299
|
-
entry.endswith("/**") and
|
|
584
|
+
entry.endswith("/**") and safe_path_matches_surface(path, [entry])
|
|
300
585
|
for entry in surface
|
|
301
586
|
)
|
|
302
587
|
|
|
303
588
|
if proven(raw):
|
|
304
589
|
return raw, None
|
|
305
|
-
if worktree_path_exists(work, raw) and not
|
|
590
|
+
if worktree_path_exists(work, raw) and not safe_path_matches_surface(raw, surface):
|
|
306
591
|
raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
|
|
307
592
|
if (
|
|
308
593
|
worktree_path_exists(work, parsed_path)
|
|
309
|
-
and not
|
|
594
|
+
and not safe_path_matches_surface(parsed_path, surface)
|
|
310
595
|
):
|
|
311
596
|
raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
|
|
312
597
|
for split in range(len(raw) - 1, -1, -1):
|
|
@@ -324,7 +609,7 @@ def resolve_na_surface_citation(
|
|
|
324
609
|
def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
|
|
325
610
|
surface: list[str]) -> list[str]:
|
|
326
611
|
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
327
|
-
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))
|
|
328
613
|
|
|
329
614
|
|
|
330
615
|
def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
|
|
@@ -416,7 +701,7 @@ def validate_surface_adjudication(
|
|
|
416
701
|
work, citation, path, line_number, surface,
|
|
417
702
|
)
|
|
418
703
|
citation = path if line_number is None else f"{path}:{line_number}"
|
|
419
|
-
if not
|
|
704
|
+
if not safe_path_matches_surface(path, surface):
|
|
420
705
|
raise SystemExit(
|
|
421
706
|
f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
|
|
422
707
|
)
|
|
@@ -539,7 +824,7 @@ def validate_surface_write_audit(
|
|
|
539
824
|
"BLOCKED:surface-close-write-audit-violation: "
|
|
540
825
|
f"line {line_number}: {raw_target!r}"
|
|
541
826
|
) from exc
|
|
542
|
-
if not
|
|
827
|
+
if not safe_path_matches_surface(relative, surface):
|
|
543
828
|
raise SystemExit(
|
|
544
829
|
"BLOCKED:surface-close-write-audit-violation: "
|
|
545
830
|
f"line {line_number}: {relative!r}"
|
|
@@ -623,10 +908,6 @@ def parse_effective_model(session_log: pathlib.Path) -> str:
|
|
|
623
908
|
except (OSError, UnicodeError) as exc:
|
|
624
909
|
raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
|
|
625
910
|
|
|
626
|
-
header = MODEL_HEADER_RE.search(text)
|
|
627
|
-
if header:
|
|
628
|
-
return header.group(1)
|
|
629
|
-
|
|
630
911
|
models: set[str] = set()
|
|
631
912
|
if session_log.suffix == ".jsonl":
|
|
632
913
|
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
@@ -1254,9 +1535,9 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
|
1254
1535
|
# verify-merge-findings.py (iter-0060 R0 finding).
|
|
1255
1536
|
# verify*.jsonl (not just *.findings.jsonl): judge-specific files like
|
|
1256
1537
|
# verify.findings.judge-codex.jsonl end in .judge-<engine>.jsonl.
|
|
1257
|
-
#
|
|
1258
|
-
#
|
|
1259
|
-
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"):
|
|
1260
1541
|
for path in devlyn.glob(pattern):
|
|
1261
1542
|
path.unlink()
|
|
1262
1543
|
(devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
|
|
@@ -1272,23 +1553,90 @@ def append_phase_history(entry: dict, phase: str) -> None:
|
|
|
1272
1553
|
history = entry.get("history")
|
|
1273
1554
|
if not isinstance(history, list):
|
|
1274
1555
|
history = []
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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")
|
|
1279
1569
|
history.append({field: entry.get(field) for field in fields})
|
|
1280
1570
|
entry["history"] = history
|
|
1281
1571
|
|
|
1282
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
|
+
|
|
1283
1619
|
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
1284
1620
|
pre_sha: str | None, engine: str | None, model: str | None, *,
|
|
1285
1621
|
input_patch_sha256: str | None = None,
|
|
1286
1622
|
prompt_sha256: str | None = None,
|
|
1287
|
-
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
|
|
1288
1635
|
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
1289
1636
|
# other field this script doesn't own) survives a fix-loop respawn.
|
|
1290
1637
|
phases_value = state.get("phases")
|
|
1291
1638
|
entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
|
|
1639
|
+
validate_plan_output(state, devlyn, phase)
|
|
1292
1640
|
if phase == "plan":
|
|
1293
1641
|
history = entry.get("history", []) if isinstance(entry, dict) else []
|
|
1294
1642
|
if not isinstance(history, list):
|
|
@@ -1312,8 +1660,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1312
1660
|
raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
|
|
1313
1661
|
if phase == "surface_close" and engine != "claude":
|
|
1314
1662
|
raise SystemExit("error: phases.surface_close spawn requires --engine claude")
|
|
1315
|
-
if phase == "surface_close" and not
|
|
1316
|
-
|
|
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")
|
|
1317
1667
|
if phase == "surface_close" and isinstance(entry, dict) and (
|
|
1318
1668
|
entry.get("started_at") is not None or entry.get("skipped_reason") is not None
|
|
1319
1669
|
):
|
|
@@ -1330,8 +1680,38 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1330
1680
|
elif phase == "plan":
|
|
1331
1681
|
if input_patch_sha256 is not None or untracked_before is not None:
|
|
1332
1682
|
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
+
)
|
|
1335
1715
|
phases = state.setdefault("phases", {})
|
|
1336
1716
|
if not isinstance(entry, dict):
|
|
1337
1717
|
entry = {}
|
|
@@ -1341,6 +1721,9 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1341
1721
|
f"error: phases.{phase} has an open span — complete it before respawn"
|
|
1342
1722
|
)
|
|
1343
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)
|
|
1344
1727
|
entry["started_at"] = now_iso()
|
|
1345
1728
|
entry["completed_at"] = None
|
|
1346
1729
|
entry["duration_ms"] = None
|
|
@@ -1351,8 +1734,16 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1351
1734
|
entry["sub_verdicts"] = None
|
|
1352
1735
|
if phase == "verify":
|
|
1353
1736
|
entry["judge_durations_ms"] = None
|
|
1737
|
+
entry.pop("role_evidence", None)
|
|
1354
1738
|
if engine is not None:
|
|
1355
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"]
|
|
1356
1747
|
if model is not None:
|
|
1357
1748
|
entry["model_requested"] = model
|
|
1358
1749
|
else:
|
|
@@ -1361,7 +1752,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
1361
1752
|
entry["model_effective"] = None
|
|
1362
1753
|
if pre_sha is not None:
|
|
1363
1754
|
entry["pre_sha"] = pre_sha
|
|
1364
|
-
if
|
|
1755
|
+
if prompt_sha256 is not None:
|
|
1365
1756
|
entry["prompt_sha256"] = prompt_sha256
|
|
1366
1757
|
if phase == "surface_close":
|
|
1367
1758
|
entry["input_patch_sha256"] = input_patch_sha256
|
|
@@ -1395,11 +1786,26 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1395
1786
|
post_sha: str | None, findings_file: str | None, log_file: str | None,
|
|
1396
1787
|
engine: str | None, model: str | None,
|
|
1397
1788
|
engine_session_log: str | None = None,
|
|
1398
|
-
devlyn: pathlib.Path | None = None
|
|
1789
|
+
devlyn: pathlib.Path | None = None,
|
|
1790
|
+
work: pathlib.Path | None = None) -> str | None:
|
|
1399
1791
|
phases = state.setdefault("phases", {})
|
|
1400
1792
|
entry = phases.get(phase)
|
|
1401
1793
|
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
1402
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"
|
|
1403
1809
|
if phase == "plan" and entry.get("prompt_sha256") is not None:
|
|
1404
1810
|
missing = [field for field in PLAN_SPAWN_RECEIPT_FIELDS if field not in entry]
|
|
1405
1811
|
if missing:
|
|
@@ -1410,16 +1816,19 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1410
1816
|
raise SystemExit("error: phases.plan completion cannot replace spawn engine")
|
|
1411
1817
|
if model is not None and model != entry["model_requested"]:
|
|
1412
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)
|
|
1413
1827
|
started = parse_iso(entry["started_at"])
|
|
1414
1828
|
now = now_ms()
|
|
1415
1829
|
entry["completed_at"] = now_iso(now)
|
|
1416
1830
|
entry["duration_ms"] = max(0, round((now - started).total_seconds() * 1000))
|
|
1417
1831
|
if phase == "verify":
|
|
1418
|
-
if verdict is not None:
|
|
1419
|
-
raise SystemExit(
|
|
1420
|
-
"error: phases.verify.verdict is owned by verify-merge-findings.py "
|
|
1421
|
-
"--write-state; do not pass --verdict to complete for this phase"
|
|
1422
|
-
)
|
|
1423
1832
|
if entry.get("verdict") is None:
|
|
1424
1833
|
raise SystemExit(
|
|
1425
1834
|
"error: phases.verify.verdict is still null — run "
|
|
@@ -1429,6 +1838,13 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1429
1838
|
entry["verdict"] = verdict
|
|
1430
1839
|
else:
|
|
1431
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
|
|
1432
1848
|
if findings_file is not None or log_file is not None:
|
|
1433
1849
|
artifacts = entry.setdefault("artifacts", {"findings_file": None, "log_file": None})
|
|
1434
1850
|
if findings_file is not None:
|
|
@@ -1451,13 +1867,74 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
1451
1867
|
retained_session = candidate
|
|
1452
1868
|
|
|
1453
1869
|
attestation_error = None
|
|
1454
|
-
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:
|
|
1455
1908
|
entry["model_effective"] = None
|
|
1456
1909
|
if retained_session is not None:
|
|
1457
1910
|
attestation_error = (
|
|
1458
1911
|
"BLOCKED:model-attestation-failed: --engine-session-log is required because "
|
|
1459
1912
|
f"retained worker session exists: {retained_session}"
|
|
1460
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}"
|
|
1461
1938
|
else:
|
|
1462
1939
|
try:
|
|
1463
1940
|
entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
|
|
@@ -1500,6 +1977,7 @@ def do_transition(
|
|
|
1500
1977
|
next_prompt_sha256: str | None = None,
|
|
1501
1978
|
next_untracked_before: list[str] | None = None,
|
|
1502
1979
|
between=None,
|
|
1980
|
+
work: pathlib.Path | None = None,
|
|
1503
1981
|
) -> dict:
|
|
1504
1982
|
"""Validate complete + caller-selected spawn against a copy of state.
|
|
1505
1983
|
|
|
@@ -1513,7 +1991,7 @@ def do_transition(
|
|
|
1513
1991
|
candidate = copy.deepcopy(state)
|
|
1514
1992
|
attestation_error = do_complete(
|
|
1515
1993
|
candidate, phase, verdict, post_sha, findings_file, log_file,
|
|
1516
|
-
engine, model, engine_session_log, devlyn,
|
|
1994
|
+
engine, model, engine_session_log, devlyn, work,
|
|
1517
1995
|
)
|
|
1518
1996
|
if attestation_error is not None:
|
|
1519
1997
|
raise SystemExit(attestation_error)
|
|
@@ -1525,6 +2003,7 @@ def do_transition(
|
|
|
1525
2003
|
input_patch_sha256=next_input_patch_sha256,
|
|
1526
2004
|
prompt_sha256=next_prompt_sha256,
|
|
1527
2005
|
untracked_before=next_untracked_before,
|
|
2006
|
+
devlyn=devlyn,
|
|
1528
2007
|
)
|
|
1529
2008
|
return candidate
|
|
1530
2009
|
|
|
@@ -1544,9 +2023,157 @@ def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str |
|
|
|
1544
2023
|
return None
|
|
1545
2024
|
|
|
1546
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
|
+
|
|
1547
2129
|
def self_test() -> int:
|
|
1548
2130
|
import time
|
|
1549
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
|
+
|
|
1550
2177
|
with tempfile.TemporaryDirectory() as tmp:
|
|
1551
2178
|
devlyn = pathlib.Path(tmp)
|
|
1552
2179
|
state_path = devlyn / "pipeline.state.json"
|
|
@@ -1686,6 +2313,43 @@ def self_test() -> int:
|
|
|
1686
2313
|
}
|
|
1687
2314
|
assert not is_plan_dispatch_receipt(old_receipt)
|
|
1688
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
|
+
|
|
1689
2353
|
write_state(state_path, {
|
|
1690
2354
|
"phases": {
|
|
1691
2355
|
"plan": {
|
|
@@ -1785,10 +2449,547 @@ def self_test() -> int:
|
|
|
1785
2449
|
write_state(state_path, state)
|
|
1786
2450
|
final = read_state(state_path)["phases"]["implement"]
|
|
1787
2451
|
assert final["verdict"] == "PASS"
|
|
2452
|
+
assert read_state(state_path)["process_evidence"] is None
|
|
1788
2453
|
assert parse_iso(final["started_at"]) == parse_iso(respawned["started_at"])
|
|
1789
2454
|
assert parse_iso(final["completed_at"]) >= parse_iso(final["started_at"])
|
|
1790
2455
|
assert final["artifacts"]["findings_file"] == ".devlyn/x.jsonl"
|
|
1791
2456
|
|
|
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
|
+
|
|
1792
2993
|
# complete() before spawn() must fail loudly, not silently invent data.
|
|
1793
2994
|
write_state(state_path, {"phases": {}})
|
|
1794
2995
|
state = read_state(state_path)
|
|
@@ -1860,7 +3061,9 @@ def self_test() -> int:
|
|
|
1860
3061
|
attestation_state = copy.deepcopy(transition_state)
|
|
1861
3062
|
attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
|
|
1862
3063
|
attestation_log = devlyn / "transition-attestation.log"
|
|
1863
|
-
attestation_log.write_text(
|
|
3064
|
+
attestation_log.write_text(json.dumps({
|
|
3065
|
+
"modelUsage": {"other-model": {"inputTokens": 1}},
|
|
3066
|
+
}) + "\n", encoding="utf-8")
|
|
1864
3067
|
write_state(state_path, attestation_state)
|
|
1865
3068
|
attestation_before = state_path.read_bytes()
|
|
1866
3069
|
try:
|
|
@@ -2051,8 +3254,11 @@ def self_test() -> int:
|
|
|
2051
3254
|
"verify-merge.summary.json",
|
|
2052
3255
|
"codex-judge.stdout",
|
|
2053
3256
|
"claude-judge.stdout",
|
|
3257
|
+
"claude-judge.stderr",
|
|
3258
|
+
"pair-judge.summary.json",
|
|
2054
3259
|
):
|
|
2055
3260
|
(devlyn / name).write_text("stale\n", encoding="utf-8")
|
|
3261
|
+
(devlyn / "codex-primary-judge.prompt.md").write_text("current\n", encoding="utf-8")
|
|
2056
3262
|
(devlyn / "spec-verify.json").write_text("{}", encoding="utf-8")
|
|
2057
3263
|
clear_verify_round_artifacts(devlyn)
|
|
2058
3264
|
for name in (
|
|
@@ -2063,8 +3269,11 @@ def self_test() -> int:
|
|
|
2063
3269
|
"verify-merge.summary.json",
|
|
2064
3270
|
"codex-judge.stdout",
|
|
2065
3271
|
"claude-judge.stdout",
|
|
3272
|
+
"claude-judge.stderr",
|
|
3273
|
+
"pair-judge.summary.json",
|
|
2066
3274
|
):
|
|
2067
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"
|
|
2068
3277
|
assert (devlyn / "spec-verify.json").exists(), "non-VERIFY-round files must survive"
|
|
2069
3278
|
|
|
2070
3279
|
# Fix-loop respawn of phase-gated IMPLEMENT must preserve `exec`
|
|
@@ -2193,18 +3402,26 @@ def self_test() -> int:
|
|
|
2193
3402
|
else:
|
|
2194
3403
|
raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
|
|
2195
3404
|
assert rejected_state == {"sentinel": True}
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
|
2208
3425
|
do_spawn(
|
|
2209
3426
|
surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
|
|
2210
3427
|
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
@@ -2213,10 +3430,45 @@ def self_test() -> int:
|
|
|
2213
3430
|
validate_surface_inputs(work, work_devlyn, surface_state)
|
|
2214
3431
|
validate_surface_prompt(work_devlyn, surface_state)
|
|
2215
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}")
|
|
2216
3452
|
surface = validate_authorized_surface(
|
|
2217
3453
|
'["allowed.txt", "branchbase", "exact-link", "exact:1", "schedule/**", '
|
|
2218
3454
|
'"test_schedule.py", "tests/**", "x.ts"]'
|
|
2219
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}")
|
|
2220
3472
|
outside = devlyn / "outside.txt"
|
|
2221
3473
|
outside.write_text("outside\n", encoding="utf-8")
|
|
2222
3474
|
(work / "outside-link").symlink_to(outside)
|
|
@@ -2514,6 +3766,78 @@ def self_test() -> int:
|
|
|
2514
3766
|
assert completed_surface["model_effective"] == "sonnet"
|
|
2515
3767
|
assert completed_surface["verdict"] == "PASS"
|
|
2516
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
|
+
|
|
2517
3841
|
recovery_state = loads_strict_json(json.dumps(surface_state))
|
|
2518
3842
|
recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
|
|
2519
3843
|
output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
|
|
@@ -2567,10 +3891,16 @@ def self_test() -> int:
|
|
|
2567
3891
|
assert skipped["verdict"] is None
|
|
2568
3892
|
assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
|
|
2569
3893
|
|
|
2570
|
-
# Effective model evidence:
|
|
3894
|
+
# Effective model evidence: handwritten headers are not provenance;
|
|
3895
|
+
# canonical rollout JSONL and Claude wrapper output remain accepted.
|
|
2571
3896
|
header_log = devlyn / "codex-build.log"
|
|
2572
3897
|
header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
|
|
2573
|
-
|
|
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")
|
|
2574
3904
|
rollout_log = devlyn / "rollout.jsonl"
|
|
2575
3905
|
rollout_log.write_text(json.dumps({
|
|
2576
3906
|
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
@@ -2790,6 +4120,437 @@ def self_test() -> int:
|
|
|
2790
4120
|
assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
|
|
2791
4121
|
assert state["phases"]["implement"]["verdict"] == "PASS"
|
|
2792
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
|
+
|
|
2793
4554
|
retained_log.unlink()
|
|
2794
4555
|
write_state(state_path, {"phases": {}})
|
|
2795
4556
|
state = read_state(state_path)
|
|
@@ -2908,7 +4669,6 @@ def self_test() -> int:
|
|
|
2908
4669
|
["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
|
|
2909
4670
|
check=True, capture_output=True, text=True,
|
|
2910
4671
|
).stdout.strip() == "chore(pipeline): closure-restore round 1"
|
|
2911
|
-
import importlib.util
|
|
2912
4672
|
gate_spec = importlib.util.spec_from_file_location(
|
|
2913
4673
|
"f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
|
|
2914
4674
|
/ "benchmark/ceiling/scripts/f7-carrier-gate.py",
|
|
@@ -3183,6 +4943,8 @@ def main() -> int:
|
|
|
3183
4943
|
ap.add_argument("--devlyn-dir", default=".devlyn")
|
|
3184
4944
|
ap.add_argument("--phase", choices=sorted(PHASE_NAMES))
|
|
3185
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")
|
|
3186
4948
|
sub = ap.add_subparsers(dest="event")
|
|
3187
4949
|
|
|
3188
4950
|
spawn_p = sub.add_parser("spawn")
|
|
@@ -3236,6 +4998,19 @@ def main() -> int:
|
|
|
3236
4998
|
if args.self_test:
|
|
3237
4999
|
return self_test()
|
|
3238
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
|
+
|
|
3239
5014
|
surface_events = {
|
|
3240
5015
|
"surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
|
|
3241
5016
|
}
|
|
@@ -3251,6 +5026,7 @@ def main() -> int:
|
|
|
3251
5026
|
if args.event == "durability-enforce":
|
|
3252
5027
|
if args.phase != "implement":
|
|
3253
5028
|
ap.error("durability-enforce is valid only for --phase implement")
|
|
5029
|
+
validate_plan_output(state, devlyn, args.phase)
|
|
3254
5030
|
_persist_durability_event(
|
|
3255
5031
|
pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
|
|
3256
5032
|
)
|
|
@@ -3260,6 +5036,7 @@ def main() -> int:
|
|
|
3260
5036
|
if args.event in surface_events:
|
|
3261
5037
|
if args.phase != "surface_close":
|
|
3262
5038
|
ap.error(f"{args.event} is valid only for --phase surface_close")
|
|
5039
|
+
validate_plan_output(state, devlyn, args.phase)
|
|
3263
5040
|
if args.event == "surface-skip":
|
|
3264
5041
|
do_surface_skip(state)
|
|
3265
5042
|
write_state(state_path, state)
|
|
@@ -3332,6 +5109,7 @@ def main() -> int:
|
|
|
3332
5109
|
input_patch_sha256=args.input_patch_sha256,
|
|
3333
5110
|
prompt_sha256=args.prompt_sha256,
|
|
3334
5111
|
untracked_before=untracked_before,
|
|
5112
|
+
devlyn=devlyn,
|
|
3335
5113
|
)
|
|
3336
5114
|
else:
|
|
3337
5115
|
next_untracked_before = (
|
|
@@ -3349,6 +5127,7 @@ def main() -> int:
|
|
|
3349
5127
|
next_input_patch_sha256=args.next_input_patch_sha256,
|
|
3350
5128
|
next_prompt_sha256=args.next_prompt_sha256,
|
|
3351
5129
|
next_untracked_before=next_untracked_before,
|
|
5130
|
+
work=pathlib.Path.cwd(),
|
|
3352
5131
|
)
|
|
3353
5132
|
if spawn_phase == "surface_close":
|
|
3354
5133
|
validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
|
|
@@ -3380,6 +5159,7 @@ def main() -> int:
|
|
|
3380
5159
|
attestation_error = do_complete(
|
|
3381
5160
|
state, args.phase, args.verdict, args.post_sha, args.findings_file,
|
|
3382
5161
|
args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
|
|
5162
|
+
pathlib.Path.cwd(),
|
|
3383
5163
|
)
|
|
3384
5164
|
if (
|
|
3385
5165
|
args.phase == "plan"
|