devlyn-cli 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,27 @@
1
1
  #!/usr/bin/env python3
2
- """Normalize raw Codex pair-JUDGE stdout into canonical VERIFY JSONL."""
2
+ """Normalize raw pair-JUDGE stdout into canonical VERIFY JSONL."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
8
  import pathlib
9
+ import runpy
9
10
  import sys
10
11
  import tempfile
11
12
  from typing import Any
12
13
 
13
14
 
14
15
  FINDING_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
16
+ ENVELOPE_KEYS = {"text", "stopReason", "sessionId", "requestId"}
17
+ IGNORABLE_FENCES = {"```", "```json", "```jsonl"}
18
+ MERGE_CONTRACT = runpy.run_path(pathlib.Path(__file__).with_name("verify-merge-findings.py"))
19
+ VERDICT_RANK = MERGE_CONTRACT["VERDICT_RANK"]
20
+ finding_rank = MERGE_CONTRACT["finding_rank"]
21
+ NARRATIVE_PREAMBLE_BYTES = frozenset(
22
+ b"\t\n\r !\"$%&'()*+,-./0123456789:;<=>?@"
23
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_abcdefghijklmnopqrstuvwxyz|}~"
24
+ ) | frozenset(range(0x80, 0x100))
15
25
 
16
26
 
17
27
  def reject_json_constant(token: str) -> None:
@@ -32,76 +42,242 @@ def atomic_write(path: pathlib.Path, text: str) -> None:
32
42
  pathlib.Path(tmp_name).replace(path)
33
43
 
34
44
 
35
- def collect(stdout_path: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
45
+ def collect_text(
46
+ text: str, source: pathlib.Path
47
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
36
48
  findings: list[dict[str, Any]] = []
37
49
  summary: dict[str, Any] | None = None
38
- with stdout_path.open(encoding="utf-8") as handle:
39
- for line_no, line in enumerate(handle, 1):
40
- raw = line.strip()
41
- if not raw:
42
- continue
43
- if raw.startswith("# SUMMARY "):
44
- try:
45
- item = loads_strict_json(raw.removeprefix("# SUMMARY ").strip())
46
- except ValueError as exc:
47
- raise SystemExit(f"error: invalid SUMMARY JSON at {stdout_path}:{line_no}: {exc}")
48
- if not isinstance(item, dict):
49
- raise SystemExit(f"error: SUMMARY is not an object at {stdout_path}:{line_no}")
50
- summary = item
51
- continue
52
- if raw.startswith("#"):
53
- continue
50
+ for line_no, line in enumerate(text.splitlines(), 1):
51
+ raw = line.strip()
52
+ if not raw or raw in IGNORABLE_FENCES:
53
+ continue
54
+ if raw.startswith("# SUMMARY "):
55
+ if summary is not None:
56
+ raise SystemExit(f"error: record after terminal SUMMARY at {source}:{line_no}")
54
57
  try:
55
- item = loads_strict_json(raw)
58
+ item = loads_strict_json(raw.removeprefix("# SUMMARY ").strip())
56
59
  except ValueError as exc:
57
- raise SystemExit(f"error: invalid JSONL at {stdout_path}:{line_no}: {exc}")
60
+ raise SystemExit(f"error: invalid SUMMARY JSON at {source}:{line_no}: {exc}")
58
61
  if not isinstance(item, dict):
59
- raise SystemExit(f"error: JSONL item is not an object at {stdout_path}:{line_no}")
60
- severity = str(item.get("severity") or "").upper()
61
- if severity not in FINDING_SEVERITIES:
62
- raise SystemExit(f"error: finding missing valid severity at {stdout_path}:{line_no}")
63
- findings.append(item)
62
+ raise SystemExit(f"error: SUMMARY is not an object at {source}:{line_no}")
63
+ if item.get("verdict") not in VERDICT_RANK:
64
+ raise SystemExit(f"error: SUMMARY has unknown verdict at {source}:{line_no}")
65
+ summary = item
66
+ continue
67
+ if raw.startswith("#"):
68
+ continue
69
+ if summary is not None:
70
+ raise SystemExit(f"error: record after terminal SUMMARY at {source}:{line_no}")
71
+ try:
72
+ item = loads_strict_json(raw)
73
+ except ValueError as exc:
74
+ raise SystemExit(f"error: invalid JSONL at {source}:{line_no}: {exc}")
75
+ if not isinstance(item, dict):
76
+ raise SystemExit(f"error: JSONL item is not an object at {source}:{line_no}")
77
+ if set(item) == {"verdict"}:
78
+ if item["verdict"] not in VERDICT_RANK:
79
+ raise SystemExit(f"error: SUMMARY has unknown verdict at {source}:{line_no}")
80
+ summary = item
81
+ continue
82
+ severity = str(item.get("severity") or "").upper()
83
+ if severity not in FINDING_SEVERITIES:
84
+ raise SystemExit(f"error: finding missing valid severity at {source}:{line_no}")
85
+ findings.append(item)
86
+ if summary is not None and summary["verdict"] == "PASS" and any(
87
+ finding_rank(finding) == 2 for finding in findings
88
+ ):
89
+ raise SystemExit("error: verdict-binding finding cannot have a PASS SUMMARY")
64
90
  if not findings and (summary is None or summary.get("verdict") != "PASS"):
65
91
  raise SystemExit("error: non-PASS SUMMARY without JSONL findings")
66
92
  return findings, summary
67
93
 
68
94
 
95
+ def recover_envelope_text(
96
+ text: str, source: pathlib.Path
97
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
98
+ encoded = text.encode("utf-8")
99
+ for cut, byte in enumerate(encoded[:-1], 1):
100
+ if byte not in NARRATIVE_PREAMBLE_BYTES:
101
+ break
102
+ try:
103
+ candidate = encoded[cut:].decode("utf-8")
104
+ except UnicodeDecodeError:
105
+ continue
106
+ try:
107
+ findings, summary = collect_text(candidate, source)
108
+ except SystemExit:
109
+ continue
110
+ if (
111
+ summary is not None
112
+ and summary["verdict"] == "NEEDS_WORK"
113
+ and any(finding_rank(finding) == 2 for finding in findings)
114
+ ):
115
+ return findings, summary
116
+ raise SystemExit("error: no admissible envelope recovery")
117
+
118
+
119
+ def collect_stdout(stdout_path: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
120
+ stdout_text = stdout_path.read_text(encoding="utf-8")
121
+ try:
122
+ candidate = loads_strict_json(stdout_text)
123
+ except ValueError:
124
+ candidate = None
125
+ if (
126
+ not isinstance(candidate, dict)
127
+ or not ENVELOPE_KEYS.issubset(candidate)
128
+ or "severity" in candidate
129
+ ):
130
+ return collect_text(stdout_text, stdout_path)
131
+
132
+ stop_reason = candidate["stopReason"]
133
+ if stop_reason != "EndTurn":
134
+ raise SystemExit(
135
+ f"error: envelope stopReason must be 'EndTurn' at {stdout_path}; "
136
+ f"got {stop_reason!r}"
137
+ )
138
+ envelope_text = candidate["text"]
139
+ if not isinstance(envelope_text, str):
140
+ raise SystemExit(f"error: envelope text must be a string at {stdout_path}")
141
+
142
+ try:
143
+ return collect_text(envelope_text, stdout_path)
144
+ except SystemExit as exc:
145
+ try:
146
+ return recover_envelope_text(envelope_text, stdout_path)
147
+ except SystemExit:
148
+ raise SystemExit(f"error: envelope text rejected: {exc}") from None
149
+
150
+
69
151
  def self_test() -> int:
70
152
  with tempfile.TemporaryDirectory() as tmp:
71
153
  root = pathlib.Path(tmp)
72
- stdout_path = root / "codex-judge.stdout"
154
+ stdout_path = root / "pair-judge.stdout"
73
155
  out_path = root / "verify.pair.findings.jsonl"
74
- summary_path = root / "codex-judge.summary.json"
156
+ summary_path = root / "pair-judge.summary.json"
157
+
158
+ def assert_rejected(text: str, label: str, *message_parts: str) -> None:
159
+ stdout_path.write_text(text, encoding="utf-8")
160
+ try:
161
+ collect_stdout(stdout_path)
162
+ except SystemExit as exc:
163
+ message = str(exc)
164
+ assert all(part in message for part in message_parts)
165
+ else:
166
+ raise AssertionError(f"{label} must be rejected")
167
+
168
+ plain_finding = {"id": "a", "severity": "HIGH"}
169
+ plain_summary = {"verdict": "NEEDS_WORK"}
75
170
  stdout_path.write_text(
76
- json.dumps({"id": "a", "severity": "HIGH"}) + "\n"
77
- + '# SUMMARY {"verdict":"NEEDS_WORK"}\n',
171
+ json.dumps(plain_finding) + "\n"
172
+ + "# SUMMARY " + json.dumps(plain_summary) + "\n",
78
173
  encoding="utf-8",
79
174
  )
80
- findings, summary = collect(stdout_path)
175
+ findings, summary = collect_stdout(stdout_path)
176
+ assert findings == [plain_finding]
177
+ assert summary == plain_summary
81
178
  write_outputs(findings, summary, out_path, summary_path)
82
179
  assert out_path.read_text(encoding="utf-8").count("\n") == 1
83
180
  assert loads_strict_json(summary_path.read_text(encoding="utf-8"))["verdict"] == "NEEDS_WORK"
84
- stdout_path.write_text('{"id":"nan","severity":NaN}\n', encoding="utf-8")
85
- try:
86
- collect(stdout_path)
87
- except SystemExit as exc:
88
- assert "invalid JSON numeric constant: NaN" in str(exc)
89
- else:
90
- raise AssertionError("NaN Codex stdout finding must not normalize")
181
+ assert_rejected(
182
+ '{"id":"nan","severity":NaN}\n',
183
+ "NaN pair-JUDGE stdout finding",
184
+ "invalid JSON numeric constant: NaN",
185
+ )
91
186
  rejection_cases = (
92
- ("", "no SUMMARY line"),
93
- ('# SUMMARY {}\n', "empty SUMMARY object"),
94
- ('# SUMMARY {"verdict":"pass"}\n', "lowercase pass"),
95
- ('# SUMMARY {"verdict":"UNKNOWN"}\n', "unknown verdict"),
187
+ ("", "no SUMMARY line", "non-PASS SUMMARY without JSONL findings"),
188
+ ('# SUMMARY {}\n', "empty SUMMARY object", "SUMMARY has unknown verdict"),
189
+ ('# SUMMARY {"verdict":"pass"}\n', "lowercase pass", "SUMMARY has unknown verdict"),
190
+ ('# SUMMARY {"verdict":"UNKNOWN"}\n', "unknown verdict", "SUMMARY has unknown verdict"),
96
191
  )
97
- for text, label in rejection_cases:
98
- stdout_path.write_text(text, encoding="utf-8")
99
- try:
100
- collect(stdout_path)
101
- except SystemExit as exc:
102
- assert str(exc) == "error: non-PASS SUMMARY without JSONL findings"
103
- else:
104
- raise AssertionError(f"{label} must not normalize to PASS")
192
+ for text, label, message in rejection_cases:
193
+ assert_rejected(
194
+ text,
195
+ label,
196
+ message,
197
+ )
198
+
199
+ def envelope(text: Any, stop_reason: str = "EndTurn") -> str:
200
+ return json.dumps(
201
+ {
202
+ "text": text,
203
+ "stopReason": stop_reason,
204
+ "sessionId": "session",
205
+ "requestId": "request",
206
+ }
207
+ )
208
+
209
+ envelope_finding = {
210
+ "id": "envelope-finding",
211
+ "severity": "HIGH",
212
+ "detail": {"preserved": True},
213
+ }
214
+ envelope_summary = {"verdict": "NEEDS_WORK"}
215
+ stdout_path.write_text(
216
+ envelope(
217
+ json.dumps(envelope_finding)
218
+ + "\n# SUMMARY "
219
+ + json.dumps(envelope_summary)
220
+ + "\n"
221
+ ),
222
+ encoding="utf-8",
223
+ )
224
+ findings, summary = collect_stdout(stdout_path)
225
+ assert findings == [envelope_finding]
226
+ assert summary == envelope_summary
227
+
228
+ assert_rejected(
229
+ envelope("review completed without findings"),
230
+ "findings-less envelope text",
231
+ "error: envelope text rejected: error: invalid JSONL",
232
+ )
233
+ assert_rejected(
234
+ envelope('{"findings":[],"verdict":"PASS"}', "Cancelled"),
235
+ "Cancelled envelope",
236
+ "envelope stopReason must be 'EndTurn'",
237
+ "'Cancelled'",
238
+ )
239
+ assert_rejected(
240
+ envelope("", "UnknownStop"),
241
+ "unknown envelope stopReason",
242
+ "envelope stopReason must be 'EndTurn'",
243
+ "'UnknownStop'",
244
+ )
245
+ assert_rejected(
246
+ envelope(None),
247
+ "non-string envelope text",
248
+ "error: envelope text must be a string",
249
+ )
250
+ welded = (
251
+ "I will inspect the result."
252
+ + json.dumps(envelope_finding)
253
+ + "\n# SUMMARY "
254
+ + json.dumps(envelope_summary)
255
+ + "\n"
256
+ )
257
+ stdout_path.write_text(envelope(welded), encoding="utf-8")
258
+ findings, summary = collect_stdout(stdout_path)
259
+ assert findings == [envelope_finding]
260
+ assert summary == envelope_summary
261
+ dual_document = (
262
+ json.dumps(envelope_finding)
263
+ + json.dumps({"id": "second", "severity": "LOW"})
264
+ + "\n# SUMMARY "
265
+ + json.dumps(envelope_summary)
266
+ + "\n"
267
+ )
268
+ assert_rejected(
269
+ envelope(dual_document),
270
+ "concatenated envelope documents",
271
+ "error: envelope text rejected: error: invalid JSONL",
272
+ )
273
+ clean_summary = {"verdict": "PASS"}
274
+ stdout_path.write_text(
275
+ envelope("# SUMMARY " + json.dumps(clean_summary) + "\n"),
276
+ encoding="utf-8",
277
+ )
278
+ findings, summary = collect_stdout(stdout_path)
279
+ assert findings == []
280
+ assert summary == clean_summary
105
281
  return 0
106
282
 
107
283
 
@@ -122,9 +298,9 @@ def write_outputs(
122
298
  def main() -> int:
123
299
  parser = argparse.ArgumentParser(description=__doc__)
124
300
  parser.add_argument("--devlyn-dir", default=".devlyn")
125
- parser.add_argument("--stdout-file", default="codex-judge.stdout")
301
+ parser.add_argument("--stdout-file", default="pair-judge.stdout")
126
302
  parser.add_argument("--out", default="verify.pair.findings.jsonl")
127
- parser.add_argument("--summary-out", default="codex-judge.summary.json")
303
+ parser.add_argument("--summary-out", default="pair-judge.summary.json")
128
304
  parser.add_argument("--self-test", action="store_true")
129
305
  args = parser.parse_args()
130
306
  if args.self_test:
@@ -135,7 +311,7 @@ def main() -> int:
135
311
  if not stdout_path.is_file():
136
312
  sys.stderr.write(f"error: {stdout_path} not found\n")
137
313
  return 1
138
- findings, summary = collect(stdout_path)
314
+ findings, summary = collect_stdout(stdout_path)
139
315
  write_outputs(findings, summary, devlyn / args.out, devlyn / args.summary_out)
140
316
  print(json.dumps({"findings_count": len(findings), "summary": summary}, sort_keys=True))
141
317
  return 0
@@ -24,6 +24,13 @@
24
24
  "description": "Required exit code. Default 0 if omitted.",
25
25
  "default": 0
26
26
  },
27
+ "timeout_sec": {
28
+ "type": "integer",
29
+ "minimum": 1,
30
+ "maximum": 600,
31
+ "description": "Per-command timeout in seconds. Default 60 if omitted; maximum 600.",
32
+ "default": 60
33
+ },
27
34
  "stdout_contains": {
28
35
  "type": "array",
29
36
  "description": "Each substring must appear verbatim in (stdout + stderr) for pass.",
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """Deny pair-judge shell outside the probe anchor, with a model-readable reason.
3
+
4
+ Registered as a native grok `PreToolUse` hook. It fires at step 1 of the
5
+ authorization pipeline, before the permission rules and before the mode policy
6
+ whose auto-deny silently terminates the review at exit 0 (iter-0081).
7
+
8
+ The anchor is supplied via DEVLYN_PROBE_ANCHOR, derived by the orchestrator from
9
+ the same source as the --allow rule.
10
+
11
+ Measured (iter-0081 M-C6 v2, external isolation, admissible cell): Line A 6/6 —
12
+ the denial arrives as a model-visible tool_result, the judge re-routes to
13
+ read_file, the anchor still executes inside the grok process, EndTurn with zero
14
+ PermissionCancelled. Line A-chain 1/1 — `<anchor> && <write>` is denied
15
+ model-visibly, neither segment executes, and the judge then runs the bare anchor.
16
+ """
17
+ import json
18
+ import os
19
+ import sys
20
+
21
+ REASON = (
22
+ "DEVLYN-0081-HOOK-DENY: this shell command is outside the permitted probe "
23
+ "anchor. Use the read_file tool for file reads. The only permitted shell "
24
+ "command is the anchor named in your prompt, unchained."
25
+ )
26
+
27
+ SHELL_TOOLS = {"run_terminal_command", "run_terminal_cmd", "Bash"}
28
+
29
+ # A conservative, quote-OBLIVIOUS lexical veto — deliberately not a shell parser.
30
+ # It rejects these characters even inside quotes, over-denying rather than ever
31
+ # under-denying a chain. Adding quote-, escape-, or operator-aware exceptions is
32
+ # how this becomes a parser; that is a registered falsifier, not a polish.
33
+ FORBIDDEN = set(";&|`$()<>") | {"\n", "\r"}
34
+
35
+
36
+ def main():
37
+ anchor = os.environ.get("DEVLYN_PROBE_ANCHOR", "")
38
+ payload = json.load(sys.stdin)
39
+
40
+ if payload.get("toolName") not in SHELL_TOOLS or not anchor:
41
+ print(json.dumps({"decision": "allow"}))
42
+ return
43
+
44
+ # The anchor, or the anchor plus argv, and nothing that could start a second
45
+ # command. A bare prefix rule would allow `<anchor> && <other>` — the vendor's
46
+ # own documented footgun. Exact match would instead deny `<anchor> --n 5`,
47
+ # mechanically destroying the "bounded input variations" that verify.md
48
+ # requires of every engine. Both were rejected by both seats (iter-0081 v2).
49
+ command = (payload.get("toolInput") or {}).get("command", "").strip()
50
+ if command == anchor or (
51
+ command.startswith(anchor + " ")
52
+ and not any(ch in FORBIDDEN for ch in command)
53
+ ):
54
+ print(json.dumps({"decision": "allow"}))
55
+ return
56
+
57
+ print(json.dumps({"decision": "deny", "reason": REASON}))
58
+
59
+
60
+ if __name__ == "__main__":
61
+ 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())