devlyn-cli 2.10.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +4 -9
  2. package/CLAUDE.md +11 -15
  3. package/README.md +31 -7
  4. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
  5. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
  6. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
  7. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
  8. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
  9. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
  10. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
  11. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
  12. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
  13. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
  14. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
  15. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
  16. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
  17. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
  18. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
  19. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
  20. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
  21. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
  22. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
  23. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
  24. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
  25. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
  26. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
  27. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
  28. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
  29. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
  30. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
  31. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
  32. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
  33. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
  34. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
  35. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
  36. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
  37. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
  38. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
  39. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
  40. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  41. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  42. package/bin/devlyn.js +59 -9
  43. package/config/skills/_shared/.ruff.toml +2 -0
  44. package/config/skills/_shared/adapters/claude.md +9 -1
  45. package/config/skills/_shared/adapters/codex.md +5 -0
  46. package/config/skills/_shared/adapters/grok.md +37 -13
  47. package/config/skills/_shared/archive_run.py +738 -56
  48. package/config/skills/_shared/codex-config.md +19 -4
  49. package/config/skills/_shared/codex-monitored.sh +81 -12
  50. package/config/skills/_shared/collect-codex-findings.py +419 -60
  51. package/config/skills/_shared/engine-preflight.md +8 -7
  52. package/config/skills/_shared/expected.schema.json +68 -0
  53. package/config/skills/_shared/finish-gate.py +12 -11
  54. package/config/skills/_shared/grok-anchor-guard.py +78 -0
  55. package/config/skills/_shared/invocation-receipt.py +1041 -0
  56. package/config/skills/_shared/judge-output-parser.py +200 -0
  57. package/config/skills/_shared/judge-role-evidence.py +273 -0
  58. package/config/skills/_shared/phase-prompt-render.py +181 -0
  59. package/config/skills/_shared/process-evidence.py +909 -0
  60. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  61. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  62. package/config/skills/_shared/role-config.py +552 -0
  63. package/config/skills/_shared/run-bounded.py +1 -1
  64. package/config/skills/_shared/spec-verify-check.py +1037 -192
  65. package/config/skills/_shared/state-phase-write.py +2551 -83
  66. package/config/skills/_shared/terminal-claim-check.py +15 -0
  67. package/config/skills/_shared/verify-merge-findings.py +1127 -50
  68. package/config/skills/devlyn:engines/SKILL.md +8 -16
  69. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  70. package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
  71. package/config/skills/devlyn:queue/SKILL.md +7 -4
  72. package/config/skills/devlyn:resolve/SKILL.md +94 -35
  73. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  74. package/config/skills/devlyn:resolve/references/phases/build-gate.md +33 -4
  75. package/config/skills/devlyn:resolve/references/phases/implement.md +4 -3
  76. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  77. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  78. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -80
  79. package/config/skills/devlyn:resolve/references/state-schema.md +23 -9
  80. package/package.json +5 -1
  81. package/scripts/lint-fixtures.sh +4 -1
  82. package/scripts/lint-skills.sh +447 -290
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """Parse the one permitted pair-JUDGE stdout emission contract."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import pathlib
8
+ from typing import Any
9
+
10
+
11
+ FINDING_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
12
+ VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
13
+ ENVELOPE_KEYS = {"text", "stopReason", "sessionId", "requestId"}
14
+ IGNORABLE_FENCES = {"```", "```json", "```jsonl"}
15
+ NARRATIVE_PREAMBLE_BYTES = frozenset(
16
+ b"\t\n\r !\"$%&'()*+,-./0123456789:;<=>?@"
17
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_abcdefghijklmnopqrstuvwxyz|}~"
18
+ ) | frozenset(range(0x80, 0x100))
19
+ STREAM_RECORD_TYPES = {"system", "assistant", "user", "result"}
20
+
21
+
22
+ def reject_json_constant(token: str) -> None:
23
+ raise ValueError(f"invalid JSON numeric constant: {token}")
24
+
25
+
26
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
27
+ result = {}
28
+ for key, value in pairs:
29
+ if key in result:
30
+ raise ValueError(f"duplicate JSON key: {key}")
31
+ result[key] = value
32
+ return result
33
+
34
+
35
+ def loads_strict_json(text: str) -> Any:
36
+ return json.loads(
37
+ text,
38
+ parse_constant=reject_json_constant,
39
+ object_pairs_hook=reject_duplicate_keys,
40
+ )
41
+
42
+
43
+ def collect_text(text: str, source: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
44
+ findings: list[dict[str, Any]] = []
45
+ summary: dict[str, Any] | None = None
46
+ for line_no, line in enumerate(text.splitlines(), 1):
47
+ raw = line.strip()
48
+ if not raw or raw in IGNORABLE_FENCES:
49
+ continue
50
+ if raw.startswith("# SUMMARY "):
51
+ if summary is not None:
52
+ raise SystemExit(f"error: record after terminal verdict at {source}:{line_no}")
53
+ try:
54
+ item = loads_strict_json(raw.removeprefix("# SUMMARY ").strip())
55
+ except ValueError as exc:
56
+ raise SystemExit(f"error: invalid SUMMARY JSON at {source}:{line_no}: {exc}")
57
+ if not isinstance(item, dict) or item.get("verdict") not in VERDICTS:
58
+ raise SystemExit(f"error: verdict has unknown value at {source}:{line_no}")
59
+ summary = item
60
+ continue
61
+ if summary is not None:
62
+ raise SystemExit(f"error: record after terminal verdict at {source}:{line_no}")
63
+ if raw in VERDICTS:
64
+ summary = {"verdict": raw}
65
+ continue
66
+ try:
67
+ item = loads_strict_json(raw)
68
+ except ValueError as exc:
69
+ raise SystemExit(f"error: invalid JSONL at {source}:{line_no}: {exc}")
70
+ if not isinstance(item, dict):
71
+ raise SystemExit(f"error: JSONL item is not an object at {source}:{line_no}")
72
+ severity = str(item.get("severity") or "").upper()
73
+ if severity not in FINDING_SEVERITIES:
74
+ raise SystemExit(f"error: finding missing valid severity at {source}:{line_no}")
75
+ findings.append(item)
76
+ return findings, summary
77
+
78
+
79
+ def recover_envelope_text(text: str, source: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
80
+ encoded = text.encode("utf-8")
81
+ for cut, byte in enumerate(encoded[:-1], 1):
82
+ if byte not in NARRATIVE_PREAMBLE_BYTES:
83
+ break
84
+ try:
85
+ findings, summary = collect_text(encoded[cut:].decode("utf-8"), source)
86
+ except (UnicodeDecodeError, SystemExit):
87
+ continue
88
+ if summary is not None and summary.get("verdict") == "NEEDS_WORK" and findings:
89
+ return findings, summary
90
+ raise SystemExit("error: no admissible envelope recovery")
91
+
92
+
93
+ def collect_message_stream(
94
+ text: str, source: pathlib.Path
95
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None] | None:
96
+ """Bind adjudication to a whole-message NDJSON stream's terminal assistant turn.
97
+
98
+ Returns None when the first record does not declare that stream. Once it does,
99
+ this returns or rejects: tool and narration turns are never forwarded, and no
100
+ envelope recovery is attempted.
101
+ """
102
+ lines = [(no, raw) for no, line in enumerate(text.splitlines(), 1) if (raw := line.strip())]
103
+ if not lines:
104
+ return None
105
+ try:
106
+ first = loads_strict_json(lines[0][1])
107
+ except ValueError:
108
+ return None
109
+ if (
110
+ not isinstance(first, dict)
111
+ or first.get("type") != "system"
112
+ or str(first.get("severity") or "").upper() in FINDING_SEVERITIES
113
+ ):
114
+ return None
115
+ if first.get("subtype") != "init":
116
+ raise SystemExit(f"error: stream must open with system/init at {source}:{lines[0][0]}")
117
+ records = [first]
118
+ for line_no, raw in lines[1:]:
119
+ try:
120
+ item = loads_strict_json(raw)
121
+ except ValueError as exc:
122
+ raise SystemExit(f"error: invalid stream NDJSON at {source}:{line_no}: {exc}")
123
+ if not isinstance(item, dict) or item.get("type") not in STREAM_RECORD_TYPES:
124
+ raise SystemExit(f"error: unknown stream record at {source}:{line_no}")
125
+ if item["type"] == "system":
126
+ raise SystemExit(f"error: stream has a second system record at {source}:{line_no}")
127
+ records.append(item)
128
+
129
+ results = [item for item in records if item["type"] == "result"]
130
+ if len(results) != 1 or results[0] is not records[-1]:
131
+ raise SystemExit(f"error: stream needs exactly one terminal result at {source}")
132
+ result = results[0]
133
+ if (
134
+ result.get("subtype") != "success"
135
+ or result.get("is_error") is not False
136
+ or result.get("stop_reason") != "end_turn"
137
+ ):
138
+ raise SystemExit(f"error: stream result is not a successful end_turn at {source}")
139
+
140
+ assistants = [item for item in records if item["type"] == "assistant"]
141
+ terminals = [
142
+ item
143
+ for item in assistants
144
+ if isinstance(item.get("message"), dict) and item["message"].get("stop_reason") == "end_turn"
145
+ ]
146
+ if (
147
+ len(terminals) != 1
148
+ or terminals[0] is not assistants[-1]
149
+ or terminals[0] is not records[-2]
150
+ ):
151
+ raise SystemExit(f"error: stream needs exactly one final end_turn assistant message at {source}")
152
+
153
+ session = first.get("session_id")
154
+ if not isinstance(session, str) or any(
155
+ item.get("session_id") != session for item in (terminals[0], result)
156
+ ):
157
+ raise SystemExit(f"error: stream session identity does not agree at {source}")
158
+
159
+ blocks = terminals[0]["message"].get("content")
160
+ if not isinstance(blocks, list):
161
+ raise SystemExit(f"error: stream terminal message has no content blocks at {source}")
162
+ parts: list[str] = []
163
+ for block in blocks:
164
+ if not isinstance(block, dict):
165
+ raise SystemExit(f"error: stream content block is not an object at {source}")
166
+ if block.get("type") == "text":
167
+ if not isinstance(block.get("text"), str):
168
+ raise SystemExit(f"error: stream text block is not a string at {source}")
169
+ parts.append(block["text"])
170
+ message_text = "".join(parts)
171
+ if result.get("result") != message_text:
172
+ raise SystemExit(f"error: stream result text does not match the terminal message at {source}")
173
+ return collect_text(message_text, source)
174
+
175
+
176
+ def collect_stdout(stdout_path: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
177
+ stdout_text = stdout_path.read_text(encoding="utf-8")
178
+ try:
179
+ candidate = loads_strict_json(stdout_text)
180
+ except ValueError:
181
+ candidate = None
182
+ if isinstance(candidate, dict) and ENVELOPE_KEYS.issubset(candidate) and "severity" not in candidate:
183
+ if candidate["stopReason"] != "EndTurn":
184
+ raise SystemExit(
185
+ f"error: envelope stopReason must be 'EndTurn' at {stdout_path}; "
186
+ f"got {candidate['stopReason']!r}"
187
+ )
188
+ if not isinstance(candidate["text"], str):
189
+ raise SystemExit(f"error: envelope text must be a string at {stdout_path}")
190
+ try:
191
+ return collect_text(candidate["text"], stdout_path)
192
+ except SystemExit as exc:
193
+ try:
194
+ return recover_envelope_text(candidate["text"], stdout_path)
195
+ except SystemExit:
196
+ raise SystemExit(f"error: envelope text rejected: {exc}") from None
197
+ stream = collect_message_stream(stdout_text, stdout_path)
198
+ if stream is not None:
199
+ return stream
200
+ return collect_text(stdout_text, stdout_path)
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ """Authenticate explicit read-only judge transport; never accept mutation receipts."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ import runpy
10
+ import sys
11
+ import tempfile
12
+
13
+ sys.dont_write_bytecode = True
14
+ ROLE = runpy.run_path(Path(__file__).with_name("role-config.py"))
15
+ loads, encoded, digest = (ROLE[key] for key in ("loads", "encoded", "digest"))
16
+
17
+
18
+ def require(ok, detail):
19
+ if not ok:
20
+ raise ValueError("BLOCKED:judge-role-evidence: " + detail)
21
+
22
+
23
+ def option(argv, *names):
24
+ values = []
25
+ for index, item in enumerate(argv):
26
+ for name in names:
27
+ if item == name:
28
+ require(index + 1 < len(argv), f"missing {name}")
29
+ values.append(argv[index + 1])
30
+ elif item.startswith(name + "="):
31
+ values.append(item[len(name) + 1:])
32
+ require(len(values) <= 1, f"duplicate {names[0]}")
33
+ return values[0] if values else None
34
+
35
+
36
+ def seal(devlyn, name):
37
+ path = devlyn / name
38
+ require(path.parent == devlyn and not path.is_symlink() and path.is_file(), f"not a regular canonical artifact: {name}")
39
+ raw = path.read_bytes()
40
+ return {"path": ".devlyn/" + name, "sha256": digest(raw), "bytes": len(raw)}, raw
41
+
42
+
43
+ def claude_result(raw, exit_code):
44
+ value = loads(raw)
45
+ require(type(exit_code) is int and exit_code == 0 and isinstance(value, dict), "unsuccessful Claude execution")
46
+ require(value.get("type") == "result" and value.get("subtype") == "success"
47
+ and value.get("is_error") is False and value.get("stop_reason") == "end_turn"
48
+ and isinstance(value.get("session_id"), str) and bool(value["session_id"].strip())
49
+ and isinstance(value.get("result"), str), "unsuccessful or malformed Claude terminal envelope")
50
+ model = runpy.run_path(Path(__file__).with_name("state-phase-write.py"))["select_claude_primary_model"](value)
51
+ return value["result"].encode(), model, value["session_id"]
52
+
53
+
54
+ def codex_header(stderr):
55
+ prefix, separator, _ = stderr.partition("\nuser\n")
56
+ require(bool(separator), "native header must precede the echoed user prompt")
57
+ starts = list(re.finditer(r"^OpenAI Codex v[^\n]+\n--------\n", prefix, re.M))
58
+ require(len(starts) == 1, "missing or conflicting first native Codex header")
59
+ header = prefix[starts[0].end():]
60
+ require(header.endswith("\n--------"), "malformed native Codex header boundary")
61
+ fields = {"cli_version": "codex-cli " + starts[0].group(0).splitlines()[0].removeprefix("OpenAI Codex v")}
62
+ for key in ("model", "workdir", "sandbox", "session id", "reasoning effort"):
63
+ values = re.findall(r"^" + re.escape(key) + r": (.+)$", header, re.M)
64
+ require(len(values) == 1 and bool(values[0].strip()), f"missing/conflicting native {key}")
65
+ fields[key] = values[0]
66
+ return fields
67
+
68
+
69
+ def describe(devlyn, state, role, exit_code):
70
+ require(type(exit_code) is int and exit_code == 0, "judge process did not complete successfully")
71
+ resolution = ROLE["snapshot"](state)
72
+ require(resolution is not None and role in ("primary_judge", "pair_judge"), "missing frozen judge selection")
73
+ entry = resolution["roles"][role]
74
+ engine = entry["engine"]
75
+ require(engine in {"codex", "claude"}, "this judge channel has no explicit-field evidence contract")
76
+ require(not entry.get("skipped_reason"), "cannot authenticate a skipped judge")
77
+ verify = state.get("phases", {}).get("verify", {})
78
+ require(isinstance(verify, dict) and type(verify.get("round")) is int, "missing VERIFY round")
79
+ require(ROLE["primary_engine"](state) == resolution["roles"]["primary_judge"]["engine"], "primary differs from frozen selection")
80
+ stem = engine + "-judge.r" + str(verify["round"])
81
+ artifacts = {}
82
+ for key, suffix in (("argv", ".argv.json"), ("prompt", ".prompt"), ("stderr", ".stderr")):
83
+ artifacts[key], raw = seal(devlyn, stem + suffix)
84
+ if key == "argv":
85
+ argv = loads(raw)
86
+ elif key == "prompt":
87
+ prompt = raw.decode()
88
+ else:
89
+ stderr = raw.decode()
90
+ require(isinstance(argv, list) and all(isinstance(x, str) for x in argv), "argv must be an array of strings")
91
+ require(prompt in argv, "canonical prompt is not an exact dispatched argument")
92
+ diagnostics = stderr.partition("\nuser\n")[0] if engine == "codex" else stderr
93
+ require(not re.search(r"(?im)^.*(?:model|effort).*\b(?:ignor\w*|clamp\w*|unsupported|not supported)\b", diagnostics), "native diagnostic rejected or ignored an explicit option")
94
+ requested_model, requested_effort = entry.get("model_requested"), entry.get("effort_requested")
95
+ supplied_model = option(argv, "-m", "--model")
96
+ if requested_model is not None:
97
+ require(supplied_model == requested_model, "requested model differs from argv")
98
+ if engine == "claude":
99
+ for flag, expected in (("--permission-mode", "dontAsk"), ("--tools", "Read,Grep,Glob"),
100
+ ("--allowedTools", "Read,Grep,Glob"), ("--setting-sources", "project"), ("--output-format", "json")):
101
+ require(option(argv, flag) == expected, f"Claude {flag} differs from read-only contract")
102
+ require("--strict-mcp-config" in argv and loads(option(argv, "--mcp-config") or "null") == {"mcpServers": {}}, "Claude MCP isolation missing")
103
+ require(any(Path(arg).name == "run-bounded.py" and argv[i + 1:i + 3] == ["600", "--"] for i, arg in enumerate(argv)), "Claude 600s bound missing")
104
+ if requested_effort:
105
+ require(option(argv, "--effort") == requested_effort, "requested effort differs from argv")
106
+ artifacts["raw"], raw = seal(devlyn, stem + ".output.json")
107
+ derived, observed, session = claude_result(raw, exit_code)
108
+ effort = None
109
+ basis = "native-Claude-result-modelUsage"
110
+ else:
111
+ require(option(argv, "-s", "--sandbox") == "read-only", "judge sandbox is not read-only")
112
+ cwd = option(argv, "-C", "--cd")
113
+ require(isinstance(cwd, str) and Path(cwd).is_absolute() and Path(cwd).resolve() == devlyn.parent.resolve(), "judge cwd differs from actual worktree")
114
+ require(not any(arg in argv for arg in ("--dangerously-bypass-approvals-and-sandbox", "--yolo", "--full-auto")), "judge bypass is forbidden")
115
+ flags = [argv[i + 1] for i, item in enumerate(argv[:-1]) if item in {"-c", "--config"}]
116
+ efforts = [flag.split("=", 1)[1].strip('"') for flag in flags if flag.startswith("model_reasoning_effort=")]
117
+ require(len(efforts) == 1 and (not requested_effort or efforts[0] == requested_effort), "missing/conflicting requested effort")
118
+ require("[codex-monitored] isolated=1\n" in diagnostics, "missing actual isolation marker")
119
+ require(re.search(r"^\[codex-monitored\] start: .* timeout=600s ", diagnostics, re.M) is not None, "missing actual 600s bound")
120
+ header = codex_header(stderr)
121
+ require(Path(header["workdir"]).is_absolute() and Path(header["workdir"]).resolve() == devlyn.parent.resolve()
122
+ and header["sandbox"] == "read-only", "observed workdir/sandbox mismatch")
123
+ observed, effort, session = header["model"], header["reasoning effort"], header["session id"]
124
+ require(effort == efforts[0], "native effort differs from dispatched effort")
125
+ artifacts["raw"], derived = seal(devlyn, stem + ".stdout")
126
+ basis = "native-Codex-configuration-header"
127
+ require(requested_model is None or observed == requested_model, "observed model differs from explicit requested model")
128
+ artifacts["stdout"] = {"path": ".devlyn/" + stem + ".stdout", "sha256": digest(derived), "bytes": len(derived)}
129
+ record = {"schema": 1, "run_id": state.get("run_id"), "round": verify["round"], "role": role,
130
+ "engine": engine, "model_requested": requested_model, "effort_requested": requested_effort,
131
+ "model_observed": observed, "effort_observed": effort, "session_id": session,
132
+ "identity_basis": basis, "exit_code": exit_code, "resolution_sha256": resolution["sha256"], "artifacts": artifacts}
133
+ return record, derived
134
+
135
+
136
+ def authenticate(devlyn, state, role):
137
+ resolution = ROLE["snapshot"](state)
138
+ engine = resolution["roles"][role]["engine"]
139
+ stem = engine + "-judge.r" + str(state["phases"]["verify"]["round"])
140
+ binding, raw = seal(devlyn, stem + ".role-evidence.json")
141
+ document = loads(raw)
142
+ actual, derived = describe(devlyn, state, role, document.get("exit_code"))
143
+ require(actual == document, "retained role evidence differs from current raw/selection")
144
+ _, stdout = seal(devlyn, stem + ".stdout")
145
+ require(stdout == derived, "derived stdout differs from retained native result")
146
+ _, canonical = seal(devlyn, engine + "-judge.stdout")
147
+ require(canonical == derived, "canonical collector stdout differs from retained native result")
148
+ return {**binding, "model_observed": actual["model_observed"], "effort_observed": actual["effort_observed"],
149
+ "identity_basis": actual["identity_basis"], "artifacts": list(actual["artifacts"].values())}
150
+
151
+
152
+ def required_roles(state):
153
+ resolution = ROLE["snapshot"](state)
154
+ if resolution is None:
155
+ return []
156
+ return [role for role in ("primary_judge", "pair_judge")
157
+ if not resolution["roles"][role].get("skipped_reason")
158
+ and any(resolution["roles"][role].get(key) is not None for key in ("model_requested", "effort_requested"))]
159
+
160
+
161
+ def self_test():
162
+ with tempfile.TemporaryDirectory() as temp:
163
+ work = Path(temp).resolve(); devlyn = work / ".devlyn"; devlyn.mkdir()
164
+ config = {"roles": {"primary_judge": {"engine": "claude", "model": "fixture-claude-model", "effort": "high"},
165
+ "pair_judge": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"}}}
166
+ (devlyn / "engines.json").write_bytes(encoded(config))
167
+ selection = ROLE["resolve"](work, "codex", available=lambda e: True)
168
+ state = {"run_id": "fixture", "role_resolution": selection, "engine": "codex", "phases": {"verify": {"engine": "claude", "round": 0}}}
169
+ envelope = {"type": "result", "subtype": "success", "is_error": False, "stop_reason": "end_turn",
170
+ "session_id": "fixture-claude", "result": "PASS", "modelUsage": {"fixture-claude-model": {}}}
171
+ argv = ["python3", "run-bounded.py", "600", "--", "claude", "-p", "review", "--model", "fixture-claude-model", "--effort", "high", "--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob", "--allowedTools", "Read,Grep,Glob", "--setting-sources", "project", "--output-format", "json", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}']
172
+ for suffix, raw in (("argv.json", encoded(argv)), ("prompt", b"review"), ("stderr", b""), ("output.json", encoded(envelope))):
173
+ (devlyn / ("claude-judge.r0." + suffix)).write_bytes(raw)
174
+ receipt, text = describe(devlyn, state, "primary_judge", 0)
175
+ assert text == b"PASS" and receipt["effort_observed"] is None
176
+ (devlyn / "claude-judge.stdout").write_bytes(text)
177
+ (devlyn / "claude-judge.r0.stdout").write_bytes(text)
178
+ (devlyn / "claude-judge.r0.role-evidence.json").write_bytes(encoded(receipt))
179
+ authenticate(devlyn, state, "primary_judge")
180
+ for field, value in (("subtype", "error_max_turns"), ("stop_reason", "tool_use"), ("is_error", True), ("session_id", ""), ("result", None)):
181
+ bad = {**envelope, field: value}
182
+ try:
183
+ claude_result(encoded(bad), 0)
184
+ except ValueError:
185
+ pass
186
+ else:
187
+ raise AssertionError(field)
188
+ for status in (1, 124, True):
189
+ try:
190
+ describe(devlyn, state, "primary_judge", status)
191
+ except ValueError:
192
+ pass
193
+ else:
194
+ raise AssertionError("failed exit accepted")
195
+ (devlyn / "claude-judge.stdout").write_bytes(b"forged PASS")
196
+ try:
197
+ authenticate(devlyn, state, "primary_judge")
198
+ except ValueError:
199
+ pass
200
+ else:
201
+ raise AssertionError("derived tampering accepted")
202
+ prompt = "review unsupported model/effort warning handling\n"
203
+ argv = ["bash", "codex-monitored.sh", "-C", str(work), "-s", "read-only", "-m", "gpt-6-astra", "-c", "model_reasoning_effort=high", prompt]
204
+ header = f"[codex-monitored] start: ts=fixture timeout=600s bin=codex\n[codex-monitored] isolated=1\nOpenAI Codex v0.153.4\n--------\nworkdir: {work}\nmodel: gpt-6-astra\nsandbox: read-only\nreasoning effort: high\nsession id: fixture-codex\n--------\nuser\nreview\n"
205
+ header = header.replace("\nuser\nreview\n", "\nuser\n" + prompt)
206
+ for suffix, raw in (("argv.json", encoded(argv)), ("prompt", prompt.encode()), ("stderr", header.encode()), ("stdout", b"PASS\n")):
207
+ (devlyn / ("codex-judge.r0." + suffix)).write_bytes(raw)
208
+ receipt, _ = describe(devlyn, state, "pair_judge", 0)
209
+ assert receipt["model_observed"] == "gpt-6-astra"
210
+ alias = work / "logical-cwd"
211
+ alias.symlink_to(work, target_is_directory=True)
212
+ alias_argv = [str(alias) if arg == str(work) else arg for arg in argv]
213
+ alias_header = header.replace(f"workdir: {work}\n", f"workdir: {alias}\n")
214
+ (devlyn / "codex-judge.r0.argv.json").write_bytes(encoded(alias_argv))
215
+ (devlyn / "codex-judge.r0.stderr").write_text(alias_header)
216
+ receipt, _ = describe(devlyn, state, "pair_judge", 0)
217
+ assert receipt["artifacts"]["argv"]["sha256"] == digest(encoded(alias_argv))
218
+ assert (devlyn / "codex-judge.r0.stderr").read_text() == alias_header
219
+ elsewhere = work / "elsewhere"; elsewhere.mkdir()
220
+ (devlyn / "codex-judge.r0.argv.json").write_bytes(encoded([str(elsewhere) if arg == str(work) else arg for arg in argv]))
221
+ try:
222
+ describe(devlyn, state, "pair_judge", 0)
223
+ except ValueError:
224
+ pass
225
+ else:
226
+ raise AssertionError("different physical worktree accepted")
227
+ (devlyn / "codex-judge.r0.argv.json").write_bytes(encoded(argv))
228
+ for bad in ("Warning: model effort unsupported and ignored\n" + header, "\nuser\n" + header, header.replace("\nmodel:", "\nmodel: gpt-5.6-sol\nmodel:"), header.replace("sandbox: read-only", "sandbox: workspace-write"), header.replace("model: gpt-6-astra", "model: gpt-5.6-sol"), header.replace("[codex-monitored] isolated=1\n", ""), header.replace("timeout=600s", "timeout=300s")):
229
+ (devlyn / "codex-judge.r0.stderr").write_text(bad)
230
+ try:
231
+ describe(devlyn, state, "pair_judge", 0)
232
+ except ValueError:
233
+ pass
234
+ else:
235
+ raise AssertionError("invalid native header accepted")
236
+ print("PASS judge-role-evidence self-test: success/error/timeout, raw derivation, tampering and native header identity")
237
+ return 0
238
+
239
+
240
+ def main():
241
+ parser = argparse.ArgumentParser(description=__doc__)
242
+ parser.add_argument("--self-test", action="store_true")
243
+ parser.add_argument("--devlyn-dir", type=Path, default=Path(".devlyn"))
244
+ parser.add_argument("--role", choices=("primary_judge", "pair_judge"))
245
+ parser.add_argument("--exit-code", type=int)
246
+ args = parser.parse_args()
247
+ if args.self_test:
248
+ return self_test()
249
+ if args.role is None or args.exit_code is None:
250
+ parser.error("--role and --exit-code are required")
251
+ try:
252
+ devlyn = args.devlyn_dir.resolve()
253
+ state = loads((devlyn / "pipeline.state.json").read_bytes())
254
+ record, derived = describe(devlyn, state, args.role, args.exit_code)
255
+ stem = record["engine"] + "-judge.r" + str(record["round"])
256
+ target = devlyn / (stem + ".role-evidence.json")
257
+ require(not target.exists(), "role evidence already exists for this round")
258
+ stdout = devlyn / (record["engine"] + "-judge.stdout")
259
+ require(not stdout.exists(), "canonical stdout already exists")
260
+ if record["engine"] == "claude":
261
+ require(not (devlyn / (stem + ".stdout")).exists(), "derived stdout already exists")
262
+ (devlyn / (stem + ".stdout")).write_bytes(derived)
263
+ stdout.write_bytes(derived)
264
+ target.write_bytes(encoded(record))
265
+ print(json.dumps(authenticate(devlyn, state, args.role), sort_keys=True))
266
+ return 0
267
+ except (OSError, ValueError, TypeError, KeyError) as exc:
268
+ print(str(exc), file=sys.stderr)
269
+ return 1
270
+
271
+
272
+ if __name__ == "__main__":
273
+ raise SystemExit(main())
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Render exact phase-prompt bytes and print their SHA-256 digest."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import os
8
+ import pathlib
9
+ import re
10
+ import tempfile
11
+
12
+
13
+ EXCLUDED_ADAPTER_SECTION = re.compile(
14
+ rb"^## (?:Role eligibility|Invocation)(?:\r?\n|\Z).*?(?=^## |\Z)",
15
+ re.MULTILINE | re.DOTALL,
16
+ )
17
+ EXCLUDED_ADAPTER_HEADING = re.compile(
18
+ rb"^## (?:Role eligibility|Invocation)\r?$", re.MULTILINE
19
+ )
20
+
21
+
22
+ def project_adapter(content: bytes) -> bytes:
23
+ projected = EXCLUDED_ADAPTER_SECTION.sub(b"", content)
24
+ assert EXCLUDED_ADAPTER_HEADING.search(projected) is None
25
+ return projected
26
+
27
+
28
+ def validate_plan_context(task_context: pathlib.Path, content: bytes) -> None:
29
+ if task_context.name != "plan.task-context":
30
+ return
31
+ working_directory = pathlib.Path.cwd()
32
+ expected = (
33
+ b"Working directory: "
34
+ + os.fsencode(working_directory)
35
+ + b"\nPlan output: "
36
+ + os.fsencode(working_directory / ".devlyn" / "plan.md")
37
+ + b"\n"
38
+ )
39
+ if not content.startswith(expected):
40
+ raise SystemExit("error: invalid PLAN task-context header")
41
+
42
+
43
+ def render_prompt(
44
+ adapter: pathlib.Path,
45
+ canonical_body: pathlib.Path,
46
+ task_context: pathlib.Path,
47
+ output: pathlib.Path,
48
+ ) -> str:
49
+ try:
50
+ adapter_bytes = adapter.read_bytes()
51
+ body_bytes = canonical_body.read_bytes()
52
+ context_bytes = task_context.read_bytes()
53
+ except OSError as exc:
54
+ raise SystemExit(f"error: prompt input unreadable: {exc}") from exc
55
+ projected_adapter = project_adapter(adapter_bytes)
56
+ validate_plan_context(task_context, context_bytes)
57
+ rendered = projected_adapter + body_bytes + context_bytes
58
+ rendered = rendered.rstrip(b"\n")
59
+ if not output.parent.is_dir():
60
+ raise SystemExit(f"error: prompt output parent is not a directory: {output.parent}")
61
+ fd, temporary = tempfile.mkstemp(dir=output.parent, prefix=output.name + ".tmp.")
62
+ temporary_path = pathlib.Path(temporary)
63
+ try:
64
+ with os.fdopen(fd, "wb") as handle:
65
+ handle.write(rendered)
66
+ handle.flush()
67
+ os.fsync(handle.fileno())
68
+ temporary_path.replace(output)
69
+ except BaseException:
70
+ temporary_path.unlink(missing_ok=True)
71
+ raise
72
+ return hashlib.sha256(rendered).hexdigest()
73
+
74
+
75
+ def self_test() -> int:
76
+ with tempfile.TemporaryDirectory(prefix="phase-prompt-render-") as raw:
77
+ root = pathlib.Path(raw)
78
+ adapter = root / "adapter.md"
79
+ body = root / "plan.md"
80
+ context = root / "plan.task-context"
81
+ output = root / ".devlyn" / "plan.prompt"
82
+ output.parent.mkdir()
83
+ original_directory = pathlib.Path.cwd()
84
+ os.chdir(root)
85
+ try:
86
+ working_directory = pathlib.Path.cwd()
87
+ adapter_bytes = (
88
+ b"# Claude adapter\r\n"
89
+ b"## Identity\r\nkept-identity-\xff\r\n"
90
+ b"## Role eligibility\nremove-role\r\n"
91
+ b"## Output discipline\r\nkept-output-\x80\n"
92
+ b"## Invocation\r\nremove-invocation\n"
93
+ b"## Anti-patterns\nkept-tail-without-newline"
94
+ )
95
+ projected_adapter = (
96
+ b"# Claude adapter\r\n"
97
+ b"## Identity\r\nkept-identity-\xff\r\n"
98
+ b"## Output discipline\r\nkept-output-\x80\n"
99
+ b"## Anti-patterns\nkept-tail-without-newline"
100
+ )
101
+ for metadata_free in (
102
+ b"# Codex adapter\r\n## Identity\nkept-\xfe",
103
+ b"# omp adapter\n## Output discipline\r\nkept\x81",
104
+ ):
105
+ assert project_adapter(metadata_free) == metadata_free
106
+
107
+ body_bytes = b"# PHASE 1 \xe2\x80\x94 PLAN\n"
108
+ context_bytes = (
109
+ b"Working directory: "
110
+ + os.fsencode(working_directory)
111
+ + b"\nPlan output: "
112
+ + os.fsencode(working_directory / ".devlyn" / "plan.md")
113
+ + b"\ncontext-without-newline"
114
+ )
115
+ for path, content in (
116
+ (adapter, adapter_bytes),
117
+ (body, body_bytes),
118
+ (context, context_bytes),
119
+ ):
120
+ path.write_bytes(content)
121
+ expected = projected_adapter + body_bytes + context_bytes
122
+ digest = render_prompt(adapter, body, context, output)
123
+ assert output.read_bytes() == expected
124
+ assert digest == hashlib.sha256(expected).hexdigest()
125
+ assert render_prompt(adapter, body, context, output) == digest
126
+ assert output.read_bytes() == expected
127
+
128
+ context_prefix = context_bytes.removesuffix(b"context-without-newline")
129
+ for terminal_lfs in (b"", b"\n", b"\n\n"):
130
+ case_context = context_prefix + b"context-terminal-lf-case" + terminal_lfs
131
+ context.write_bytes(case_context)
132
+ expected = (projected_adapter + body_bytes + case_context).rstrip(b"\n")
133
+ digest = render_prompt(adapter, body, context, output)
134
+ written = output.read_bytes()
135
+ assert not written.endswith(b"\n")
136
+ assert digest == hashlib.sha256(written).hexdigest()
137
+ assert written == expected
138
+
139
+ invalid_contexts = (
140
+ b"context-without-header",
141
+ b"Working directory: relative\nPlan output: relative/.devlyn/plan.md\n",
142
+ b"Working directory: /mismatch\nPlan output: /mismatch/.devlyn/plan.md\n",
143
+ )
144
+ for invalid_context in invalid_contexts:
145
+ context.write_bytes(invalid_context)
146
+ output.write_bytes(b"unchanged")
147
+ try:
148
+ render_prompt(adapter, body, context, output)
149
+ except SystemExit:
150
+ pass
151
+ else:
152
+ raise AssertionError("invalid PLAN task-context header accepted")
153
+ assert output.read_bytes() == b"unchanged"
154
+ finally:
155
+ os.chdir(original_directory)
156
+ print("SELFTEST PASS: projected exact bytes + PLAN context validation")
157
+ return 0
158
+
159
+
160
+ def main() -> int:
161
+ parser = argparse.ArgumentParser(description=__doc__)
162
+ parser.add_argument("--adapter", type=pathlib.Path)
163
+ parser.add_argument("--canonical-body", type=pathlib.Path)
164
+ parser.add_argument("--task-context", type=pathlib.Path)
165
+ parser.add_argument("--output", type=pathlib.Path)
166
+ parser.add_argument("--self-test", action="store_true")
167
+ args = parser.parse_args()
168
+ if args.self_test:
169
+ if any((args.adapter, args.canonical_body, args.task_context, args.output)):
170
+ parser.error("render paths are not allowed with --self-test")
171
+ return self_test()
172
+ if not all((args.adapter, args.canonical_body, args.task_context, args.output)):
173
+ parser.error(
174
+ "--adapter, --canonical-body, --task-context, and --output are required"
175
+ )
176
+ print(render_prompt(args.adapter, args.canonical_body, args.task_context, args.output))
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main())