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.
Files changed (77) hide show
  1. package/AGENTS.md +8 -9
  2. package/CLAUDE.md +17 -17
  3. package/README.md +33 -9
  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/bin/devlyn.js +9 -2
  41. package/config/skills/_shared/.ruff.toml +2 -0
  42. package/config/skills/_shared/adapters/claude.md +9 -1
  43. package/config/skills/_shared/adapters/codex.md +5 -0
  44. package/config/skills/_shared/adapters/grok.md +4 -2
  45. package/config/skills/_shared/archive_run.py +738 -56
  46. package/config/skills/_shared/codex-config.md +19 -4
  47. package/config/skills/_shared/codex-monitored.sh +81 -12
  48. package/config/skills/_shared/collect-codex-findings.py +311 -128
  49. package/config/skills/_shared/engine-preflight.md +8 -7
  50. package/config/skills/_shared/expected.schema.json +61 -0
  51. package/config/skills/_shared/finish-gate.py +12 -11
  52. package/config/skills/_shared/grok-anchor-guard.py +18 -1
  53. package/config/skills/_shared/invocation-receipt.py +1041 -0
  54. package/config/skills/_shared/judge-output-parser.py +200 -0
  55. package/config/skills/_shared/judge-role-evidence.py +273 -0
  56. package/config/skills/_shared/process-evidence.py +909 -0
  57. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  58. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  59. package/config/skills/_shared/role-config.py +552 -0
  60. package/config/skills/_shared/run-bounded.py +1 -1
  61. package/config/skills/_shared/spec-verify-check.py +749 -181
  62. package/config/skills/_shared/state-phase-write.py +1838 -58
  63. package/config/skills/_shared/terminal-claim-check.py +15 -0
  64. package/config/skills/_shared/verify-merge-findings.py +958 -51
  65. package/config/skills/devlyn:engines/SKILL.md +8 -16
  66. package/config/skills/devlyn:ideate/SKILL.md +2 -2
  67. package/config/skills/devlyn:ideate/references/spec-template.md +6 -5
  68. package/config/skills/devlyn:queue/SKILL.md +7 -4
  69. package/config/skills/devlyn:resolve/SKILL.md +93 -35
  70. package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -0
  71. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  72. package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
  73. package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
  74. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
  75. package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
  76. package/package.json +5 -1
  77. package/scripts/lint-skills.sh +314 -285
@@ -12,24 +12,31 @@ import tempfile
12
12
  from typing import Any
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
15
  MERGE_CONTRACT = runpy.run_path(pathlib.Path(__file__).with_name("verify-merge-findings.py"))
16
+ PARSER = runpy.run_path(pathlib.Path(__file__).with_name("judge-output-parser.py"))
19
17
  VERDICT_RANK = MERGE_CONTRACT["VERDICT_RANK"]
20
18
  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))
25
19
 
26
20
 
27
21
  def reject_json_constant(token: str) -> None:
28
22
  raise ValueError(f"invalid JSON numeric constant: {token}")
29
23
 
30
24
 
25
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
26
+ result = {}
27
+ for key, value in pairs:
28
+ if key in result:
29
+ raise ValueError(f"duplicate JSON key: {key}")
30
+ result[key] = value
31
+ return result
32
+
33
+
31
34
  def loads_strict_json(text: str) -> Any:
32
- return json.loads(text, parse_constant=reject_json_constant)
35
+ return json.loads(
36
+ text,
37
+ parse_constant=reject_json_constant,
38
+ object_pairs_hook=reject_duplicate_keys,
39
+ )
33
40
 
34
41
 
35
42
  def atomic_write(path: pathlib.Path, text: str) -> None:
@@ -45,110 +52,34 @@ def atomic_write(path: pathlib.Path, text: str) -> None:
45
52
  def collect_text(
46
53
  text: str, source: pathlib.Path
47
54
  ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
48
- findings: list[dict[str, Any]] = []
49
- summary: dict[str, Any] | None = None
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}")
57
- try:
58
- item = loads_strict_json(raw.removeprefix("# SUMMARY ").strip())
59
- except ValueError as exc:
60
- raise SystemExit(f"error: invalid SUMMARY JSON at {source}:{line_no}: {exc}")
61
- if not isinstance(item, dict):
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")
55
+ findings, summary = PARSER["collect_text"](text, source)
56
+ if findings and summary is None:
57
+ raise SystemExit("error: findings without terminal verdict")
58
+ if summary is not None and summary["verdict"] == "PASS" and any(finding_rank(finding) == 2 for finding in findings):
59
+ raise SystemExit("error: verdict-binding finding cannot have a PASS verdict")
90
60
  if not findings and (summary is None or summary.get("verdict") != "PASS"):
91
- raise SystemExit("error: non-PASS SUMMARY without JSONL findings")
61
+ raise SystemExit("error: non-PASS verdict without JSONL findings")
92
62
  return findings, summary
93
63
 
94
64
 
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
65
  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
66
+ findings, summary = PARSER["collect_stdout"](stdout_path)
67
+ if findings and summary is None:
68
+ raise SystemExit("error: findings without terminal verdict")
69
+ if summary is not None and summary["verdict"] == "PASS" and any(finding_rank(finding) == 2 for finding in findings):
70
+ raise SystemExit("error: verdict-binding finding cannot have a PASS verdict")
71
+ if not findings and (summary is None or summary.get("verdict") != "PASS"):
72
+ raise SystemExit("error: non-PASS verdict without JSONL findings")
73
+ return findings, summary
149
74
 
150
75
 
151
76
  def self_test() -> int:
77
+ try:
78
+ loads_strict_json('{"verdict":"PASS","verdict":"BLOCKED"}')
79
+ except ValueError as exc:
80
+ assert "duplicate JSON key" in str(exc)
81
+ else:
82
+ raise AssertionError("duplicate judge result key was accepted")
152
83
  with tempfile.TemporaryDirectory() as tmp:
153
84
  root = pathlib.Path(tmp)
154
85
  stdout_path = root / "pair-judge.stdout"
@@ -169,7 +100,7 @@ def self_test() -> int:
169
100
  plain_summary = {"verdict": "NEEDS_WORK"}
170
101
  stdout_path.write_text(
171
102
  json.dumps(plain_finding) + "\n"
172
- + "# SUMMARY " + json.dumps(plain_summary) + "\n",
103
+ + plain_summary["verdict"] + "\n",
173
104
  encoding="utf-8",
174
105
  )
175
106
  findings, summary = collect_stdout(stdout_path)
@@ -183,11 +114,16 @@ def self_test() -> int:
183
114
  "NaN pair-JUDGE stdout finding",
184
115
  "invalid JSON numeric constant: NaN",
185
116
  )
117
+ assert_rejected(
118
+ '# SUMMARY {"verdict":"PASS","verdict":"BLOCKED"}\n',
119
+ "duplicate pair-JUDGE summary verdict",
120
+ "duplicate JSON key: verdict",
121
+ )
186
122
  rejection_cases = (
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"),
123
+ ("", "no verdict line", "non-PASS verdict without JSONL findings"),
124
+ (json.dumps(plain_finding) + "\n", "finding without terminal verdict", "findings without terminal verdict"),
125
+ ('{"verdict":"pass"}\n', "lowercase pass", "finding missing valid severity"),
126
+ ('{"verdict":"UNKNOWN"}\n', "unknown verdict", "finding missing valid severity"),
191
127
  )
192
128
  for text, label, message in rejection_cases:
193
129
  assert_rejected(
@@ -215,9 +151,7 @@ def self_test() -> int:
215
151
  stdout_path.write_text(
216
152
  envelope(
217
153
  json.dumps(envelope_finding)
218
- + "\n# SUMMARY "
219
- + json.dumps(envelope_summary)
220
- + "\n"
154
+ + "\n" + envelope_summary["verdict"] + "\n"
221
155
  ),
222
156
  encoding="utf-8",
223
157
  )
@@ -228,7 +162,7 @@ def self_test() -> int:
228
162
  assert_rejected(
229
163
  envelope("review completed without findings"),
230
164
  "findings-less envelope text",
231
- "error: envelope text rejected: error: invalid JSONL",
165
+ "invalid JSONL",
232
166
  )
233
167
  assert_rejected(
234
168
  envelope('{"findings":[],"verdict":"PASS"}', "Cancelled"),
@@ -247,37 +181,286 @@ def self_test() -> int:
247
181
  "non-string envelope text",
248
182
  "error: envelope text must be a string",
249
183
  )
250
- welded = (
251
- "I will inspect the result."
252
- + json.dumps(envelope_finding)
253
- + "\n# SUMMARY "
254
- + json.dumps(envelope_summary)
255
- + "\n"
184
+ assert_rejected(
185
+ envelope("I will inspect the result.\n" + envelope_summary["verdict"] + "\n"),
186
+ "narrative envelope preamble",
187
+ "invalid JSONL",
256
188
  )
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
189
  dual_document = (
262
190
  json.dumps(envelope_finding)
263
191
  + json.dumps({"id": "second", "severity": "LOW"})
264
- + "\n# SUMMARY "
265
- + json.dumps(envelope_summary)
266
- + "\n"
192
+ + "\n" + envelope_summary["verdict"] + "\n"
267
193
  )
268
194
  assert_rejected(
269
195
  envelope(dual_document),
270
196
  "concatenated envelope documents",
271
- "error: envelope text rejected: error: invalid JSONL",
197
+ "invalid JSONL",
272
198
  )
273
199
  clean_summary = {"verdict": "PASS"}
274
200
  stdout_path.write_text(
275
- envelope("# SUMMARY " + json.dumps(clean_summary) + "\n"),
201
+ envelope(clean_summary["verdict"] + "\n"),
276
202
  encoding="utf-8",
277
203
  )
278
204
  findings, summary = collect_stdout(stdout_path)
279
205
  assert findings == []
280
206
  assert summary == clean_summary
207
+ stdout_path.write_text(
208
+ json.dumps(
209
+ {
210
+ "type": "system",
211
+ "text": clean_summary["verdict"] + "\n",
212
+ "stopReason": "EndTurn",
213
+ "sessionId": "session",
214
+ "requestId": "request",
215
+ }
216
+ ),
217
+ encoding="utf-8",
218
+ )
219
+ findings, summary = collect_stdout(stdout_path)
220
+ assert findings == []
221
+ assert summary == clean_summary
222
+
223
+ # R4: a valid plain finding may carry an unrelated `type` field. A
224
+ # first-line `type: system` finding is not a stream discriminator.
225
+ system_finding = {"id": "system-finding", "severity": "LOW", "type": "system"}
226
+ stdout_path.write_text(
227
+ json.dumps(system_finding) + "\n" + clean_summary["verdict"] + "\n",
228
+ encoding="utf-8",
229
+ )
230
+ findings, summary = collect_stdout(stdout_path)
231
+ assert findings == [system_finding]
232
+ assert summary == clean_summary
233
+
234
+ # iter-0106 — Grok whole-message NDJSON carrier (R2/R3).
235
+ def init_record(session: str = "s1") -> dict[str, Any]:
236
+ return {"type": "system", "subtype": "init", "session_id": session, "model": "grok-build"}
237
+
238
+ def assistant_record(
239
+ blocks: list[Any], stop_reason: str = "end_turn", session: str = "s1"
240
+ ) -> dict[str, Any]:
241
+ return {
242
+ "type": "assistant",
243
+ "message": {"id": "msg", "role": "assistant", "content": blocks, "stop_reason": stop_reason},
244
+ "parent_tool_use_id": None,
245
+ "session_id": session,
246
+ }
247
+
248
+ def result_record(
249
+ text: str,
250
+ session: str = "s1",
251
+ subtype: str = "success",
252
+ is_error: Any = False,
253
+ stop_reason: str = "end_turn",
254
+ ) -> dict[str, Any]:
255
+ return {
256
+ "type": "result",
257
+ "subtype": subtype,
258
+ "is_error": is_error,
259
+ "result": text,
260
+ "stop_reason": stop_reason,
261
+ "session_id": session,
262
+ }
263
+
264
+ def text_block(value: str) -> dict[str, Any]:
265
+ return {"type": "text", "text": value}
266
+
267
+ def stream(*records: Any) -> str:
268
+ return "".join(json.dumps(record) + "\n" for record in records)
269
+
270
+ pass_text = "PASS\n"
271
+ stdout_path.write_text(
272
+ stream(init_record(), assistant_record([text_block(pass_text)]), result_record(pass_text)),
273
+ encoding="utf-8",
274
+ )
275
+ findings, summary = collect_stdout(stdout_path)
276
+ assert findings == []
277
+ assert summary == {"verdict": "PASS"}
278
+
279
+ # A real tool turn precedes the contract, and the terminal message arrives
280
+ # as two text blocks whose in-order concatenation is the result text.
281
+ stream_finding = {"id": "stream-finding", "severity": "HIGH"}
282
+ head = json.dumps(stream_finding) + "\n"
283
+ tail = "# SUMMARY " + json.dumps({"verdict": "NEEDS_WORK"}) + "\n"
284
+ contract = head + tail
285
+ narration = assistant_record(
286
+ [
287
+ text_block("Let me read the file."),
288
+ {"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "src/main.rs"}},
289
+ ],
290
+ stop_reason="tool_use",
291
+ )
292
+ tool_result = {
293
+ "type": "user",
294
+ "message": {
295
+ "role": "user",
296
+ "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "fn main() {}"}],
297
+ },
298
+ "parent_tool_use_id": None,
299
+ "session_id": "s1",
300
+ }
301
+ stdout_path.write_text(
302
+ stream(
303
+ init_record(),
304
+ narration,
305
+ tool_result,
306
+ assistant_record([text_block(head), text_block(tail)]),
307
+ result_record(contract),
308
+ ),
309
+ encoding="utf-8",
310
+ )
311
+ findings, summary = collect_stdout(stdout_path)
312
+ assert findings == [stream_finding]
313
+ assert summary == {"verdict": "NEEDS_WORK"}
314
+
315
+ # The welded body is exactly the shape envelope recovery accepts, so this
316
+ # also proves the stream branch never falls through to that recovery.
317
+ welded = "I reviewed the diff.\n" + contract
318
+ stream_rejections = (
319
+ (
320
+ stream(init_record(), assistant_record([text_block(welded)]), result_record(welded)),
321
+ "narration welded into the terminal message",
322
+ "invalid JSONL",
323
+ ),
324
+ (
325
+ stream(
326
+ init_record(),
327
+ assistant_record([text_block(contract)]),
328
+ assistant_record([text_block(contract)]),
329
+ result_record(contract),
330
+ ),
331
+ "two terminal end_turn messages",
332
+ "exactly one final end_turn assistant message",
333
+ ),
334
+ (
335
+ stream(
336
+ init_record(),
337
+ assistant_record([text_block(contract)]),
338
+ assistant_record([text_block("more")], stop_reason="tool_use"),
339
+ result_record(contract),
340
+ ),
341
+ "end_turn message that is not the final assistant message",
342
+ "exactly one final end_turn assistant message",
343
+ ),
344
+ (
345
+ # The malformed line is the only defect: skipping it leaves a valid stream.
346
+ stream(init_record(), assistant_record([text_block(pass_text)]))
347
+ + '{"type":"assistant"\n'
348
+ + stream(result_record(pass_text)),
349
+ "malformed stream NDJSON inside an otherwise valid stream",
350
+ "invalid stream NDJSON",
351
+ ),
352
+ (
353
+ stream(init_record(), assistant_record([text_block(pass_text)])),
354
+ "stream without a terminal result",
355
+ "exactly one terminal result",
356
+ ),
357
+ (
358
+ stream(
359
+ init_record(),
360
+ assistant_record([text_block(pass_text)]),
361
+ result_record(pass_text),
362
+ tool_result,
363
+ ),
364
+ "data after the terminal result",
365
+ "exactly one terminal result",
366
+ ),
367
+ (
368
+ stream(
369
+ init_record(),
370
+ assistant_record([text_block(pass_text)]),
371
+ tool_result,
372
+ result_record(pass_text),
373
+ ),
374
+ "data between the terminal assistant and result",
375
+ "exactly one final end_turn assistant message",
376
+ ),
377
+ (
378
+ stream(
379
+ init_record(),
380
+ assistant_record([text_block(pass_text)]),
381
+ result_record(pass_text, subtype="error_during_execution"),
382
+ ),
383
+ "error result subtype",
384
+ "not a successful end_turn",
385
+ ),
386
+ (
387
+ stream(
388
+ init_record(),
389
+ assistant_record([text_block(pass_text)]),
390
+ result_record(pass_text, stop_reason="cancelled"),
391
+ ),
392
+ "cancelled result",
393
+ "not a successful end_turn",
394
+ ),
395
+ (
396
+ stream(
397
+ init_record(),
398
+ assistant_record([text_block(pass_text)]),
399
+ result_record(pass_text, is_error=True),
400
+ ),
401
+ "error-flagged result",
402
+ "not a successful end_turn",
403
+ ),
404
+ (
405
+ stream(
406
+ init_record(),
407
+ assistant_record([text_block(pass_text)]),
408
+ result_record("NEEDS_WORK\n"),
409
+ ),
410
+ "result text that disagrees with the terminal message",
411
+ "result text does not match the terminal message",
412
+ ),
413
+ (
414
+ stream(
415
+ init_record(),
416
+ assistant_record([text_block(pass_text)]),
417
+ result_record(pass_text, session="s2"),
418
+ ),
419
+ "result from another session",
420
+ "session identity does not agree",
421
+ ),
422
+ (
423
+ stream(
424
+ init_record(),
425
+ {"type": "stream_event", "event": {"type": "content_block_delta"}, "session_id": "s1"},
426
+ assistant_record([text_block(pass_text)]),
427
+ result_record(pass_text),
428
+ ),
429
+ "partial-message frame",
430
+ "unknown stream record",
431
+ ),
432
+ (
433
+ stream(
434
+ init_record(),
435
+ {"type": "system", "subtype": "compact_boundary", "session_id": "s1"},
436
+ assistant_record([text_block(pass_text)]),
437
+ result_record(pass_text),
438
+ ),
439
+ "compact boundary",
440
+ "second system record",
441
+ ),
442
+ (
443
+ stream(
444
+ init_record(),
445
+ {"type": "unknown", "session_id": "s1"},
446
+ assistant_record([text_block(pass_text)]),
447
+ result_record(pass_text),
448
+ ),
449
+ "unknown record type",
450
+ "unknown stream record",
451
+ ),
452
+ (
453
+ stream(
454
+ {"type": "system", "subtype": "compact_boundary", "session_id": "s1"},
455
+ assistant_record([text_block(pass_text)]),
456
+ result_record(pass_text),
457
+ ),
458
+ "stream that does not open with system/init",
459
+ "must open with system/init",
460
+ ),
461
+ )
462
+ for text, label, message in stream_rejections:
463
+ assert_rejected(text, label, message)
281
464
  return 0
282
465
 
283
466
 
@@ -8,21 +8,22 @@ Each skill resolves the effective engine from its own SKILL.md default plus any
8
8
 
9
9
  Engine requirements have two classes:
10
10
 
11
- - **Explicit routes** — `--engine`, `--risk-probes`, `--pair-verify`. These are promises. If the required engine is unavailable, fail closed with `BLOCKED:<engine>-unavailable` and never downgrade to solo.
11
+ - **Explicit routes** — `--engine`, `--risk-probes`, `--pair-verify`, configured executor/role/priority pins. These are promises. If the required engine is unavailable, fail closed with `BLOCKED:<engine>-unavailable` and never downgrade to solo.
12
12
  - **Automatic routes** — high-risk risk-probes remain conditional; VERIFY pair-JUDGE is selected by default unless `--no-pair` was passed. Both require an available OTHER engine. If it is absent, proceed solo and report the skipped route and reason. This is route selection, not a fallback.
13
13
 
14
14
  ## Role resolution
15
15
 
16
- Two configurable roles; the orchestrator role is whichever CLI the user opened and is not configurable here.
16
+ The orchestrator is the CLI the user opened. PLAN remains orchestrator-fixed. Resolve the legacy executor as explicit `--engine` > `cwd/.devlyn/engines.json` `executor` > the invoking skill's supported default. BUILD_GATE and risk-probe derivation keep their existing routes.
17
17
 
18
- - **Executor** (IMPLEMENT / CLEANUP / primary VERIFY judge): explicit `--engine` flag > `cwd/.devlyn/engines.json` `executor` > built-in default `claude`. No parent-directory or global config lookup. Requires an adapter that is executor-eligible (below).
19
- - **Pair judge** (VERIFY pair-JUDGE, risk-probe derivation): first entry of `engines.json` `pair_judge_priority` that is (a) adapter-valid, (b) pair-judge-eligible (below), (c) not the primary judge engine, (d) available. When the key or file is absent: the binary claude↔codex complement.
18
+ The role profiles and `--role-config` below apply only to `/devlyn:resolve`; `/devlyn:ideate` keeps its existing `--engine` / legacy executor selection and never inherits worker/judge profiles. `role-config.py` is resolve’s deterministic resolver. Optional project `roles.worker`, `roles.primary_judge`, and `roles.pair_judge` each require `engine` and accept exact `model`/`effort`; `/devlyn:resolve --role-config <path>` supplies the same `{"roles": {...}}` object for one run. Each entry replaces the entire lower entry. Worker/primary precedence is run role > `--engine` engine-only > project role > legacy executor. Pair precedence is run role > project role > existing `pair_judge_priority`/binary complement. An absent primary profile follows the legacy executor, not the worker profile. No parent/global devlyn config lookup.
20
19
 
21
- Validation is fail-closed: malformed JSON, any engine name without a `_shared/adapters/<name>.md` adapter file under `DEVLYN_SHARED_DIR`, or an adapter that declares itself ineligible for the requested role, halts with report-level `BLOCKED:invalid-engine-config` naming the offending entry. This is the plug-in point for new engines — ship an adapter file and the name becomes valid with zero skill-body changes. Availability probe default is `command -v <name>`; an adapter may declare a different probe. A pin is an explicit route (same promise class as `--engine`): pinned-but-unavailable → `BLOCKED:<engine>-unavailable`, never a silent downgrade. `engines.json` is machine-local config — not committed, not archived (`archive_run.py` moves only `PER_RUN_PATTERNS`).
20
+ Worker controls IMPLEMENT/CLEANUP and their repair rounds only; primary/pair control VERIFY only. OTHER must differ from the resolved primary engine. Explicit same-engine pair is invalid; a legacy priority list skips the primary. `--no-pair` does not dispatch or check availability of an unused pair. Configured pins remain explicit promises, while an unconfigured unavailable automatic OTHER records its solo reason.
22
21
 
23
- **Role eligibility** — an adapter's optional `## Role eligibility` section (`_shared/adapters/README.md`) declares `executor: yes|no` / `pair_judge: yes|no` as fixed ASCII fields. Absent section = eligible for both roles (every adapter shipped before iter-0051). A completion-only local backend with no tool/file-edit access declares `executor: no`: an `executor <name>` pin against it is invalid config, not an availability gap, and fails the same `BLOCKED:invalid-engine-config` path above rather than `BLOCKED:<name>-unavailable`.
22
+ Before the first phase, `/devlyn:resolve` freezes resolution with `state-phase-write.py --freeze-roles`; subsequent status/dispatch reads that state instead of mutable config. `state.engine` stays the legacy executor. The resolver rejects statically unsupported selected channel fields before PLAN, and validates adapter names and the existing ASCII `executor: yes|no` / `pair_judge: yes|no` eligibility markers; absent eligibility section means both. Malformed configuration fails with `BLOCKED:invalid-engine-config`.
24
23
 
25
- The explicit-vs-automatic split above applies unchanged to resolved engines: explicit routes (`--engine`, `--risk-probes`, `--pair-verify`, config pins) fail closed; automatic escalations select a cross-engine route only when the resolved pair-judge engine is available, else proceed solo and report the skip.
24
+ Before each affected worker/judge, read `role-config.py --state .devlyn/pipeline.state.json --role <role> [--resolved-model <inherited-exact-model>]`. It returns source/channel, requested values and argv additions. Explicit Codex options require current version-matching native model metadata; refresh the native CLI's cache when missing/stale. Explicit Claude judge effort requires the adapter's version/exact-model capability declaration; model-only selection passes the exact ID without that effort declaration and validates the native result. No model/effort fields means unchanged phase defaults. Unknown support fails with `BLOCKED:unsupported-role-option`; no clamp, fallback or invented model identity. Native Agent workers reject unsupported explicit Claude fields without switching to a CLI worker. Show inherited/unknown values honestly; availability is not role fitness.
25
+
26
+ Engine-only adapter routes remain supported according to existing eligibility. New adapters do not automatically gain explicit model/effort mappings. Status reports unavailable pins and unresolved legacy channels without weakening run freeze/dispatch. CLI presence is not authentication. Status never launches a model, changes user configuration or promotes a default.
26
27
 
27
28
  When a run or phase requires engine `<name>` (claude, codex, omp, or any adapter-backed engine), before spawning that phase:
28
29
 
@@ -6,6 +6,67 @@
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
+ "process_evidence": {
10
+ "type": "array",
11
+ "description": "Declared process obligations captured during implementation. Each id is unique within its phase; the shared process-evidence runner records raw streams and validates the expectation before IMPLEMENT can pass.",
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["id", "phase"],
16
+ "oneOf": [
17
+ { "required": ["cmd"], "not": { "required": ["argv"] } },
18
+ { "required": ["argv"], "not": { "required": ["cmd"] } }
19
+ ],
20
+ "properties": {
21
+ "id": {
22
+ "type": "string",
23
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
24
+ "description": "Stable evidence id, unique within its phase and safe as one path component."
25
+ },
26
+ "phase": {
27
+ "const": "implement",
28
+ "description": "The phase that must produce this expected-contract obligation."
29
+ },
30
+ "cmd": {
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "description": "Exact shell command executed by the shared runner."
34
+ },
35
+ "argv": {
36
+ "type": "array",
37
+ "minItems": 1,
38
+ "items": { "type": "string" },
39
+ "description": "Exact argv executed without a shell; argv[0] must be non-empty."
40
+ },
41
+ "exit_code": {
42
+ "type": "integer",
43
+ "minimum": 0,
44
+ "description": "Required exit code. Default 0 if omitted.",
45
+ "default": 0
46
+ },
47
+ "timeout_sec": {
48
+ "type": "integer",
49
+ "minimum": 1,
50
+ "maximum": 600,
51
+ "description": "Per-command timeout in seconds. Default 60 if omitted.",
52
+ "default": 60
53
+ },
54
+ "stdout_contains": {
55
+ "type": "array",
56
+ "items": { "type": "string", "minLength": 1 },
57
+ "description": "Each UTF-8 substring must occur in the concatenated raw stdout and stderr bytes.",
58
+ "default": []
59
+ },
60
+ "stdout_not_contains": {
61
+ "type": "array",
62
+ "items": { "type": "string", "minLength": 1 },
63
+ "description": "No UTF-8 substring may occur in the concatenated raw stdout and stderr bytes.",
64
+ "default": []
65
+ }
66
+ }
67
+ },
68
+ "default": []
69
+ },
9
70
  "verification_commands": {
10
71
  "type": "array",
11
72
  "description": "Each command is executed against the post-BUILD code. Each pass/fail contributes to verify_score. At least one entry is required when the spec has any observable runtime check (CLI, test command, HTTP request).",