devlyn-cli 3.1.0 → 3.1.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 (28) hide show
  1. package/README.md +6 -1
  2. package/bin/devlyn.js +69 -23
  3. package/config/skills/_shared/adapters/claude.md +3 -2
  4. package/config/skills/_shared/adapters/codex.md +1 -1
  5. package/config/skills/_shared/archive_run.py +5 -3
  6. package/config/skills/_shared/codex-config.md +13 -8
  7. package/config/skills/_shared/codex-monitored.sh +12 -19
  8. package/config/skills/_shared/collect-codex-findings.py +1 -0
  9. package/config/skills/_shared/engine-preflight.md +2 -0
  10. package/config/skills/_shared/finish-gate.py +4 -1
  11. package/config/skills/_shared/invocation-receipt.py +194 -5
  12. package/config/skills/_shared/judge-role-evidence.py +33 -6
  13. package/config/skills/_shared/phase-prompt-render.py +2 -0
  14. package/config/skills/_shared/platform-support.py +370 -0
  15. package/config/skills/_shared/process-evidence.py +4 -2
  16. package/config/skills/_shared/resolve-bootstrap.py +102 -59
  17. package/config/skills/_shared/resolve-stop-hook.py +3 -1
  18. package/config/skills/_shared/role-config.py +7 -5
  19. package/config/skills/_shared/run-bounded.py +35 -21
  20. package/config/skills/_shared/spec-verify-check.py +274 -157
  21. package/config/skills/_shared/state-phase-write.py +26 -9
  22. package/config/skills/_shared/task-complete.py +87 -83
  23. package/config/skills/_shared/verify-merge-findings.py +9 -8
  24. package/config/skills/devlyn:resolve/SKILL.md +10 -7
  25. package/config/skills/devlyn:resolve/references/phases/verify.md +5 -3
  26. package/config/skills/devlyn:resolve/references/task-completion.md +1 -1
  27. package/package.json +1 -1
  28. package/scripts/lint-skills.sh +6 -5
package/README.md CHANGED
@@ -305,9 +305,14 @@ Selected during install. Run `npx devlyn-cli` again to add more.
305
305
 
306
306
  ## Requirements
307
307
 
308
- - **Node.js 18+**
308
+ - **Node.js 18+** and npm
309
+ - **Python 3.11+** available as `python3`, and Git for the resolve harness
309
310
  - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** installed and configured
310
311
 
312
+ On native Windows, use native Node/npm and Python plus Git for Windows Bash for the shipped shell wrapper. Run `npx devlyn-cli -y` in the project; add Codex with `npx devlyn-cli agents codex`. npm extraction and installed directories use the legal U+F03A colon alias (for example `devlyn\uf03aresolve`); logical skill names remain `devlyn:resolve`. Do not rename them by hand. Harness text is UTF-8 without requiring `PYTHONUTF8`.
313
+
314
+ Windows completion preserves the workspace, owned refs and recovery receipt when writer cessation cannot be proved, even after merge; resume guidance and delivery status remain separate from product verification. The portability workflow tests a POSIX-packed npm artifact on native Windows without checking out colon paths. A POSIX pass alone does not establish Windows support: acceptance requires the passing Windows job for the exact source SHA/artifact hashes.
315
+
311
316
  ## Contributing
312
317
 
313
318
  - **Add a skill** — directory in `config/skills/` with `SKILL.md`
package/bin/devlyn.js CHANGED
@@ -18,6 +18,33 @@ const DEVLYN_CORE_SKILLS = ['devlyn:resolve', 'devlyn:ideate', 'devlyn:design-ui
18
18
  const DEVLYN_SKILL_DIR_STAMP = '__DEVLYN_SKILL_DIR__';
19
19
  const DEVLYN_INSTALL_MARKER = '.devlyn-install.json';
20
20
 
21
+ function logicalSkillName(name) {
22
+ return name.replace(/\uF03A/g, ':');
23
+ }
24
+
25
+ function physicalSkillName(name) {
26
+ return process.platform === 'win32' ? name.replace(/:/g, '\uF03A') : name;
27
+ }
28
+
29
+ function skillPath(root, name) {
30
+ name = logicalSkillName(name);
31
+ const aliases = [...new Set([name, name.replace(/:/g, '\uF03A')])];
32
+ const entries = fs.existsSync(root) ? fs.readdirSync(root) : [];
33
+ const found = aliases.filter((alias) => entries.includes(alias));
34
+ if (found.length > 1) throw new Error(`Ambiguous skill aliases in ${root}: ${found.join(', ')}`);
35
+ return path.join(root, found[0] || physicalSkillName(name));
36
+ }
37
+
38
+ function preflightSkillDirs(source, target, names) {
39
+ for (const name of names) {
40
+ skillPath(source, name);
41
+ skillPath(target, name);
42
+ }
43
+ for (const relative of DEPRECATED_DIRS) {
44
+ skillPath(target, path.basename(relative));
45
+ }
46
+ }
47
+
21
48
  // Cross-agent shared skills directory read by BOTH oh-my-pi and Pi. Verified
22
49
  // from the omp binary's skill-provider strings ("skills from .agents/skills —
23
50
  // project walk-up + user home") and Pi's docs ("~/.agents/skills/"). Installing
@@ -313,6 +340,8 @@ function listContents() {
313
340
 
314
341
  function cleanupDeprecated(targetDir) {
315
342
  let removed = 0;
343
+ const deprecated = DEPRECATED_DIRS.map((relPath) =>
344
+ [relPath, skillPath(path.join(targetDir, 'skills'), path.basename(relPath))]);
316
345
  for (const relPath of DEPRECATED_FILES) {
317
346
  const fullPath = path.join(targetDir, relPath);
318
347
  if (fs.existsSync(fullPath)) {
@@ -321,8 +350,7 @@ function cleanupDeprecated(targetDir) {
321
350
  removed++;
322
351
  }
323
352
  }
324
- for (const relPath of DEPRECATED_DIRS) {
325
- const fullPath = path.join(targetDir, relPath);
353
+ for (const [relPath, fullPath] of deprecated) {
326
354
  if (fs.existsSync(fullPath)) {
327
355
  fs.rmSync(fullPath, { recursive: true });
328
356
  log(` ✕ ${relPath}/ (removed)`, 'dim');
@@ -337,13 +365,14 @@ function copyRecursive(src, dest, baseDir) {
337
365
 
338
366
  if (stats.isDirectory()) {
339
367
  // Never install dev workspaces, even when running from source repo.
340
- if (UNSHIPPED_SKILL_DIRS.has(path.basename(src))) return;
368
+ if (UNSHIPPED_SKILL_DIRS.has(logicalSkillName(path.basename(src)))) return;
341
369
  if (!fs.existsSync(dest)) {
342
370
  fs.mkdirSync(dest, { recursive: true });
343
371
  }
344
372
 
345
373
  for (const item of fs.readdirSync(src)) {
346
- copyRecursive(path.join(src, item), path.join(dest, item), baseDir);
374
+ const name = src === path.join(CONFIG_SOURCE, 'skills') ? physicalSkillName(logicalSkillName(item)) : item;
375
+ copyRecursive(path.join(src, item), path.join(dest, name), baseDir);
347
376
  }
348
377
  } else {
349
378
  const destDir = path.dirname(dest);
@@ -360,9 +389,19 @@ function clearInstallMarker(skillsDir) {
360
389
  }
361
390
 
362
391
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
363
- const missing = skillNames.filter((skillName) =>
364
- !fs.existsSync(path.join(sourceSkillsDir, skillName))
365
- || !fs.existsSync(path.join(skillsDir, skillName)));
392
+ function complete(src, dest) {
393
+ if (!fs.existsSync(src) || !fs.existsSync(dest)) return false;
394
+ if (fs.statSync(src).isDirectory()) {
395
+ return fs.statSync(dest).isDirectory() && fs.readdirSync(src).every((name) =>
396
+ complete(path.join(src, name), path.join(dest, name)));
397
+ }
398
+ return fs.statSync(dest).isFile();
399
+ }
400
+ const missing = skillNames.filter((name) => {
401
+ const src = skillPath(sourceSkillsDir, name);
402
+ const dest = skillPath(skillsDir, name);
403
+ return !complete(src, dest) || (name !== '_shared' && !fs.existsSync(path.join(src, 'SKILL.md')));
404
+ });
366
405
  if (missing.length > 0) {
367
406
  throw new Error(`Incomplete devlyn skill install; missing: ${missing.join(', ')}`);
368
407
  }
@@ -440,11 +479,12 @@ const UNSHIPPED_SKILL_DIRS = new Set([
440
479
  // no counterpart in source. Dev workspaces are skipped entirely.
441
480
  function cleanManagedSkillDirs(sourceSkillsDir, targetSkillsDir) {
442
481
  if (!fs.existsSync(sourceSkillsDir) || !fs.existsSync(targetSkillsDir)) return 0;
482
+ preflightSkillDirs(sourceSkillsDir, targetSkillsDir, fs.readdirSync(sourceSkillsDir));
443
483
  let cleaned = 0;
444
484
  for (const entry of fs.readdirSync(sourceSkillsDir, { withFileTypes: true })) {
445
485
  if (!entry.isDirectory()) continue;
446
- if (UNSHIPPED_SKILL_DIRS.has(entry.name)) continue;
447
- const targetPath = path.join(targetSkillsDir, entry.name);
486
+ if (UNSHIPPED_SKILL_DIRS.has(logicalSkillName(entry.name))) continue;
487
+ const targetPath = skillPath(targetSkillsDir, entry.name);
448
488
  if (fs.existsSync(targetPath)) {
449
489
  fs.rmSync(targetPath, { recursive: true, force: true });
450
490
  cleaned++;
@@ -547,9 +587,11 @@ function multiSelect(items, preselectedIndices = []) {
547
587
  }
548
588
 
549
589
  function installLocalSkill(skillName) {
550
- const src = path.join(OPTIONAL_SKILLS_SOURCE, skillName);
590
+ const src = skillPath(OPTIONAL_SKILLS_SOURCE, skillName);
551
591
  const targetDir = getTargetDir();
552
- const dest = path.join(targetDir, 'skills', skillName);
592
+ const targets = new Set([path.join(targetDir, 'skills'), ...Object.values(CLI_TARGETS)
593
+ .map((cli) => cli.skillsDir).filter((dir) => dir && fs.existsSync(dir))]);
594
+ for (const target of targets) skillPath(target, skillName);
553
595
 
554
596
  if (!fs.existsSync(src)) {
555
597
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
@@ -557,7 +599,6 @@ function installLocalSkill(skillName) {
557
599
  }
558
600
 
559
601
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
560
- copyRecursive(src, dest, targetDir);
561
602
 
562
603
  // Mirror to every CLI skill-loader directory that already exists so optional
563
604
  // skills are picked up by Codex/omp/Pi (and any future CLI with a skillsDir)
@@ -565,13 +606,12 @@ function installLocalSkill(skillName) {
565
606
  // create an agent install just because someone opted into a Claude-side skill.
566
607
  // De-dup by directory: omp and Pi share ~/.agents/skills, so the mirror runs
567
608
  // once per unique destination.
568
- const mirrored = new Set();
569
- for (const cli of Object.values(CLI_TARGETS)) {
570
- if (!cli.skillsDir || mirrored.has(cli.skillsDir) || !fs.existsSync(cli.skillsDir)) continue;
571
- mirrored.add(cli.skillsDir);
572
- const cliDest = path.join(cli.skillsDir, skillName);
573
- if (fs.existsSync(cliDest)) fs.rmSync(cliDest, { recursive: true, force: true });
574
- copyRecursive(src, cliDest, cli.skillsDir);
609
+ for (const target of targets) {
610
+ fs.rmSync(skillPath(target, skillName), { recursive: true, force: true });
611
+ const dest = path.join(target, physicalSkillName(skillName));
612
+ copyRecursive(src, dest, target);
613
+ stampInstalledSkillDir(dest, dest);
614
+ assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
575
615
  }
576
616
  return true;
577
617
  }
@@ -632,6 +672,7 @@ function installSkillsForCLI(cliKey) {
632
672
  fs.mkdirSync(cli.skillsDir, { recursive: true });
633
673
  }
634
674
  clearInstallMarker(cli.skillsDir);
675
+ preflightSkillDirs(sourceSkillsDir, cli.skillsDir, cli.skillsToInstall);
635
676
 
636
677
  const removed = cleanupDeprecated(path.dirname(cli.skillsDir));
637
678
  if (removed > 0) {
@@ -640,13 +681,14 @@ function installSkillsForCLI(cliKey) {
640
681
 
641
682
  let copied = 0;
642
683
  for (const skillName of cli.skillsToInstall) {
643
- const src = path.join(sourceSkillsDir, skillName);
644
- const dest = path.join(cli.skillsDir, skillName);
684
+ const src = skillPath(sourceSkillsDir, skillName);
685
+ const previous = skillPath(cli.skillsDir, skillName);
686
+ const dest = path.join(cli.skillsDir, physicalSkillName(skillName));
645
687
  if (!fs.existsSync(src)) continue;
646
688
  // Full replace per cleanManagedSkillDirs semantics: stale files in the
647
689
  // installed mirror would otherwise persist forever.
648
- if (fs.existsSync(dest)) {
649
- fs.rmSync(dest, { recursive: true, force: true });
690
+ if (fs.existsSync(previous)) {
691
+ fs.rmSync(previous, { recursive: true, force: true });
650
692
  }
651
693
  copyRecursive(src, dest, cli.skillsDir);
652
694
  stampInstalledSkillDir(dest, dest);
@@ -777,6 +819,10 @@ function installClaudeCore() {
777
819
  }
778
820
  copyRecursive(CONFIG_SOURCE, targetDir, targetDir);
779
821
  assertCompleteSkillInstall(path.join(CONFIG_SOURCE, 'skills'), skillsDir, DEVLYN_CORE_SKILLS);
822
+ for (const name of DEVLYN_CORE_SKILLS) {
823
+ const dest = skillPath(skillsDir, name);
824
+ stampInstalledSkillDir(dest, dest);
825
+ }
780
826
  writeInstallMarker(skillsDir);
781
827
 
782
828
  // Remove deprecated files from previous versions
@@ -22,7 +22,7 @@ two targeted probes; the invoking phase sets `--effort`, pair-JUDGE uses
22
22
  `medium`):
23
23
 
24
24
  ```bash
25
- python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 -- claude -p "<judge prompt>" \
25
+ python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 --stdin-file "<judge-prompt-file>" --record-transport -- claude -p \
26
26
  --permission-mode dontAsk \
27
27
  --tools "Read,Grep,Glob" --allowedTools "Read,Grep,Glob" \
28
28
  --setting-sources project --strict-mcp-config --mcp-config '{"mcpServers":{}}' \
@@ -30,6 +30,7 @@ python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 -- claude -p "<judge prompt>" \
30
30
  > .devlyn/claude-judge.stdout 2> .devlyn/claude-judge.stderr
31
31
  ```
32
32
 
33
+ - Write the complete prompt as exact UTF-8 file bytes; no positional prompt or shell substitution. With `--record-transport`, the runner snapshots stdin and exclusively creates `<judge-prompt-file>.transport.json` to bind actual native argv and delivered bytes; an existing carrier rejects dispatch. Preserve it for explicit judge-role authentication. Ordinary `--stdin-file` reads require no output or writable input directory; without it, stdin remains DEVNULL.
33
34
  - Omit `--model` — the CLI's configured default is used (zero-touch, same
34
35
  rule as codex-config.md's "omit `-m`").
35
36
  - `dontAsk` denies anything not allowlisted (official guide: "denies
@@ -46,7 +47,7 @@ python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 -- claude -p "<judge prompt>" \
46
47
  `.devlyn/verify.pair.findings.jsonl`. Raw stdout stays diagnostic at
47
48
  `.devlyn/claude-judge.stdout`; `verify-merge-findings.py` blocks the run
48
49
  if stdout contains findings the canonical file lacks.
49
- - Exit 124 is a wall-budget abort (kills the process group) → the orchestrator
50
+ - Exit 124 is a wall-budget abort (terminates the native child process tree; POSIX process group) → the orchestrator
50
51
  writes `.devlyn/verify.pair.timeout.json`; budget abort ≠ availability failure
51
52
  and the fail-closed availability rules above are unchanged.
52
53
  - Do not pipe stdout (`| tail`, `| grep`); capture to file. Non-zero exit other
@@ -14,7 +14,7 @@ Your default is efficient, direct, task-oriented. The canonical body specifies t
14
14
 
15
15
  Resolve the request in the fewest useful tool loops without sacrificing correctness. For retrieval tasks: start with one broad search using short discriminative keywords; make another retrieval call only when the top results don't answer the core question or a required fact / parameter / source is missing. For tool-heavy tasks, start with a brief preamble: a one-line acknowledgment of the request and the first step you'll take.
16
16
 
17
- For VERIFY JUDGE retrieval, batch related reads. After sealed parity proves a
17
+ For a constrained-read VERIFY packet, judge the supplied material without tools; missing or unbound inputs require a verdict-binding BLOCKED finding. Keep the read-only sandbox and bounded review. Otherwise, for VERIFY JUDGE retrieval, batch related reads. After sealed parity proves a
18
18
  canonical file and its `.agents` mirror are byte-identical, inspect the
19
19
  canonical file only. Open self-test bodies or MECHANICAL raw streams only for a
20
20
  named clause that remains unresolved after the broad pass.
@@ -535,7 +535,7 @@ def completion_self_test() -> None:
535
535
  for i, final in enumerate(invalid):
536
536
  path = runs / f"indeterminate-{i:02d}" / "pipeline.state.json"
537
537
  path.parent.mkdir()
538
- path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": final}}))
538
+ path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": final}}), encoding="utf-8")
539
539
  preserved.append(path)
540
540
  for i, raw in enumerate((b"{", b"\xff", b"null", b'{"phases":{}}', b'{"run_id":[]}')):
541
541
  path = runs / f"malformed-{i}" / "pipeline.state.json"
@@ -545,14 +545,14 @@ def completion_self_test() -> None:
545
545
  for i, pattern in enumerate(BOOTSTRAP_TEMP_PATTERNS):
546
546
  path = runs / f"residue-{i}" / "pipeline.state.json"
547
547
  path.parent.mkdir()
548
- path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": valid[0]}}))
548
+ path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": valid[0]}}), encoding="utf-8")
549
549
  residue = path.with_name(pattern.replace("*", "interrupted"))
550
550
  residue.write_bytes(b"interrupted initialization")
551
551
  preserved.extend((path, residue))
552
552
  for i in range(12):
553
553
  path = runs / f"complete-{i:02d}" / "pipeline.state.json"
554
554
  path.parent.mkdir()
555
- path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": valid[i % 8]}}))
555
+ path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": valid[i % 8]}}), encoding="utf-8")
556
556
  before = {p: p.read_bytes() for p in preserved}
557
557
  assert prune(runs) == 2
558
558
  assert {p: p.read_bytes() for p in preserved} == before
@@ -901,6 +901,7 @@ def self_test() -> int:
901
901
  [sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", str(bad)],
902
902
  capture_output=True,
903
903
  text=True,
904
+ encoding="utf-8",
904
905
  )
905
906
  assert blocked_cli.returncode == 1
906
907
  assert "error: archive blocked:" in blocked_cli.stderr
@@ -948,4 +949,5 @@ def main() -> int:
948
949
 
949
950
 
950
951
  if __name__ == "__main__":
952
+ runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
951
953
  raise SystemExit(main())
@@ -4,7 +4,7 @@ Single source of truth for how every skill calls Codex. **MCP is not used.** Ski
4
4
 
5
5
  ## Canonical invocations
6
6
 
7
- All long-running Codex calls go through `codex-monitored.sh` — a thin wrapper that closes stdin (codex 0.124.0 hangs when both stdin is open and a prompt arg is given), streams Codex stdout fully (no `tail -n` truncation), and prints a `[codex-monitored] heartbeat` line every 30s so the outer `claude -p` byte-watchdog stays fed during long reasoning gaps. The wrapper passes its arguments through verbatim to the underlying CLI, so the canonical flag set is unchanged from a raw call — only the launcher differs.
7
+ All long-running Codex calls go through `codex-monitored.sh`. It streams full stdout and emits a `[codex-monitored] heartbeat` every 30s on stderr. Write multiline prompts as exact UTF-8 bytes to a task-local file; set `DEVLYN_CODEX_PROMPT_FILE` and pass the sole prompt argument `-`. The wrapper snapshots those bytes before dispatch and seals actual argv, prompt digest and completion in `<prompt-file>.transport.json`. Missing/unreadable files, competing prompt arguments and receipt/prompt mismatches fail before launch. Without file transport, stdin remains DEVNULL (avoiding the open-stdin plus argument-prompt hang).
8
8
 
9
9
  Before the first Codex call, resolve the wrapper from the invoked skill directory:
10
10
 
@@ -25,22 +25,22 @@ fi
25
25
  **Read-only critique / adversarial review / debate** (`/devlyn:resolve` VERIFY pair-mode, plus any future ideate read-only critique). Security review stays native to Claude Code BUILD_GATE. Codex returns findings on stdout; the orchestrator writes files.
26
26
 
27
27
  ```bash
28
- CODEX_MONITORED_ISOLATED=1 bash "$CODEX_MONITORED_PATH" \
28
+ DEVLYN_CODEX_PROMPT_FILE="<prompt-file>" CODEX_MONITORED_ISOLATED=1 CODEX_MONITORED_TIMEOUT_SEC=600 bash "$CODEX_MONITORED_PATH" \
29
29
  -C <project-root> \
30
30
  -s read-only \
31
31
  -c model_reasoning_effort=xhigh \
32
- "<inlined-prompt>"
32
+ -
33
33
  ```
34
34
 
35
35
  **Workspace-write implementation** (`/devlyn:resolve` IMPLEMENT phase when `--engine codex` or `--engine auto` routes to Codex, plus codex-routed `/devlyn:ideate` phases):
36
36
 
37
37
  ```bash
38
- bash "$CODEX_MONITORED_PATH" \
38
+ DEVLYN_CODEX_PROMPT_FILE="<prompt-file>" bash "$CODEX_MONITORED_PATH" \
39
39
  -C <project-root> \
40
40
  -s workspace-write \
41
41
  -c sandbox_workspace_write.network_access=false \
42
42
  -c model_reasoning_effort=xhigh \
43
- "<inlined-prompt>"
43
+ -
44
44
  ```
45
45
 
46
46
  **CI-equivalent BUILD_GATE** keeps the same write sandbox and enables general
@@ -48,15 +48,16 @@ outbound network access inside that sandbox, including the loopback servers and
48
48
  network-backed test gates that CI may exercise:
49
49
 
50
50
  ```bash
51
- bash "$CODEX_MONITORED_PATH" \
51
+ DEVLYN_CODEX_PROMPT_FILE="<prompt-file>" bash "$CODEX_MONITORED_PATH" \
52
52
  -C <project-root> \
53
53
  -s workspace-write \
54
54
  -c sandbox_workspace_write.network_access=true \
55
55
  -c model_reasoning_effort=xhigh \
56
- "<inlined-prompt>"
56
+ -
57
57
  ```
58
58
 
59
59
  Notes:
60
+ - `DEVLYN_CODEX_PROMPT_FILE` — use the same file as `DEVLYN_INVOCATION_PROMPT_FILE` for receipt-bound calls. Retain the generated transport carrier with the prompt/session/argv evidence; a path or claimed environment value alone does not prove delivery. Do not use shell command substitution for prompt bytes.
60
61
  - `-C` — project root so Codex's working directory matches.
61
62
  - `-s read-only` / `-s workspace-write` — sandbox policy. Use workspace-write for implementation/probe phases that write tracked files or `.devlyn` artifacts.
62
63
  - `-c sandbox_workspace_write.network_access=<true|false>` — required and receipt-bound for mutation phases: `true` only for BUILD_GATE and, per call, PROBE-DERIVE when the probe's visible Verification command requires a localhost service; `false` for PLAN, IMPLEMENT, CLEANUP, and PROBE-DERIVE by default. This allows CI-equivalent loopback/network tests without widening to `danger-full-access` and prevents user configuration from silently changing other phases.
@@ -66,6 +67,10 @@ Notes:
66
67
  - Wrapper calls are **foreground-blocking**. Never launch them via a backgrounded shell (`run_in_background`, `&`, `nohup`) and never end the orchestrator message while one runs: a headless print-mode session kills backgrounded children at wind-down (observed 2026-07-07: an FS1 A-arm IMPLEMENT codex call was killed at turn end → 0-byte delivery). The heartbeat stream is the observability channel; block on the call. Interactive Claude Code caps a foreground Bash call at `BASH_MAX_TIMEOUT_MS` (the installer sets at least 3600000 ms, IMPLEMENT's effective outer ceiling), so every foreground Codex wrapper call must pass the Bash `timeout` explicitly at that ceiling or the phase's own budget (for example, 600s judges) — never rely on the 120 s default.
67
68
  - Raw `codex exec ...` invocations are **forbidden** in skill prompts. The benchmark variant arm runs a PATH shim (`scripts/codex-shim/codex`) that transparently re-routes any raw `codex exec` to the wrapper as a safety net, but skills should always emit the wrapper form directly so the orchestrator's first-attempt has the right shape. Two prior iterations (iter-0006 universal foreground ban, iter-0008 prompt-level kill-shape contract) failed because the orchestrator picked starvation-prone shapes (`codex exec ... 2>&1 | tail -200`) from its own pattern prior — the wrapper plus the shim is the runtime binding layer those iters lacked. See `autoresearch/iterations/0009-wrapper-and-hook.md`.
68
69
 
70
+ ## Constrained Windows judge reads
71
+
72
+ Codex supports [native Windows sandboxing](https://learn.chatgpt.com/docs/windows/windows-sandbox). A policy-denied read selects a constrained-read route for that judge; it does not establish that Windows lacks sandbox support or denies every read. The orchestrator supplies the complete spec, sibling expected contract, accepted source SHA and cumulative diff, relevant source/tests with file:line locators, and validated sealed MECHANICAL results, manifests and required raw streams inline in the prompt file. Tell the fresh judge: "Judge only the supplied evidence; run no tools. Missing, truncated or unbound inputs require a verdict-binding BLOCKED finding." Keep `-s read-only`, isolation, freshness, selected model/effort, the 600s budget and normal findings/timeout handling. Never widen sandbox permissions or invent evidence to complete the packet.
73
+
69
74
  ## Availability check
70
75
 
71
76
  Before the first Codex call in a run, verify the CLI is on PATH:
@@ -84,4 +89,4 @@ The local Codex CLI, fronted by `codex-monitored.sh`, is the integration boundar
84
89
 
85
90
  Skills write the invocation as a Bash command the runtime executes. Example shape from `/devlyn:resolve` PHASE 2 IMPLEMENT when routed to Codex:
86
91
 
87
- > Run `bash "$CODEX_MONITORED_PATH" --json -C <state.base_ref.repo_root> -s workspace-write -m <model_requested> -c sandbox_workspace_write.network_access=false -c model_reasoning_effort=xhigh "<IMPLEMENT prompt>"`. Capture stdout as the IMPLEMENT reply; non-zero exit → treat as subagent failure. The wrapper emits `[codex-monitored]` heartbeat and lifecycle lines on **stderr** — stdout stays clean for Codex output, so the orchestrator can parse the reply without filtering. Heartbeat-on-stderr keeps the orchestrator's combined-output stream non-silent (defeats the iter-0008 byte-watchdog kill) without polluting the codex-reply view of stdout. Do not pipe the wrapper; direct capture or file redirection preserves streaming and avoids the pipe-refusal exit.
92
+ > Run `DEVLYN_CODEX_PROMPT_FILE="<IMPLEMENT-prompt-file>" bash "$CODEX_MONITORED_PATH" --json -C <state.base_ref.repo_root> -s workspace-write -m <model_requested> -c sandbox_workspace_write.network_access=false -c model_reasoning_effort=xhigh -`. Capture stdout as the IMPLEMENT reply; non-zero exit → treat as subagent failure. The wrapper emits `[codex-monitored]` heartbeat and lifecycle lines on **stderr** — stdout stays clean for Codex output, so the orchestrator can parse the reply without filtering. Heartbeat-on-stderr keeps the orchestrator's combined-output stream non-silent (defeats the iter-0008 byte-watchdog kill) without polluting the codex-reply view of stdout. Do not pipe the wrapper; direct capture or file redirection preserves streaming and avoids the pipe-refusal exit.
@@ -21,7 +21,7 @@
21
21
  # clear message so the orchestrator can self-correct on retry.
22
22
  # (Round 2 finding #1 fix: shim alone does not defeat `| tail`; the
23
23
  # wrapper must reject the pipe shape directly.)
24
- # 2. Closes stdin (`< /dev/null`) — kills the codex 0.124.0 stdin hang.
24
+ # 2. Closes stdin unless DEVLYN_CODEX_PROMPT_FILE supplies the sole prompt.
25
25
  # 3. Streams codex stdout to OUR stdout line-by-line — the orchestrator reads
26
26
  # stdout as the subagent reply (per `_shared/codex-config.md`) so we MUST
27
27
  # NOT swallow it (e.g. `tail -n 200`). codex stderr forwards to OUR stderr.
@@ -54,6 +54,7 @@
54
54
  # CODEX_MONITORED_ALLOW_PIPED — set non-empty to skip the pipe-stdout
55
55
  # refusal. Reserved for tests; don't use
56
56
  # in skill prompts.
57
+ # DEVLYN_CODEX_PROMPT_FILE — exact binary stdin prompt; requires sole `-`.
57
58
  # DEVLYN_INVOCATION_* — run/phase/round/workdir/prompt/session/
58
59
  # receipt identity. When any is set, all
59
60
  # are required and the wrapper seals a
@@ -269,22 +270,17 @@ if [ -n "${CODEX_MONITORED_ISOLATED:-}" ]; then
269
270
  printf '[codex-monitored] isolated=1\n' >&2
270
271
  fi
271
272
 
272
- if [ -n "$RECEIPT_ENABLED" ]; then
273
- RECEIPT_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/invocation-receipt.py"
274
- python3 "$RECEIPT_HELPER" start \
275
- --workdir "$DEVLYN_INVOCATION_WORKDIR" \
276
- --receipt "$DEVLYN_INVOCATION_RECEIPT" \
277
- --run-id "$DEVLYN_INVOCATION_RUN_ID" \
278
- --phase "$DEVLYN_INVOCATION_PHASE" \
279
- --round "$DEVLYN_INVOCATION_ROUND" \
280
- --prompt-file "$DEVLYN_INVOCATION_PROMPT_FILE" \
281
- --session-file "$DEVLYN_INVOCATION_SESSION_FILE" \
282
- -- "${CODEX_ARGS[@]}" || exit 64
273
+ RECEIPT_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/invocation-receipt.py"
274
+ # Native Python owns Windows PIDs and the timeout tree; MSYS PIDs are different.
275
+ if [ "${OS:-}" = Windows_NT ]; then
276
+ exec python3 "$RECEIPT_HELPER" dispatch --binary "$CODEX_BIN" \
277
+ --timeout "$TIMEOUT_SEC" --heartbeat "$HEARTBEAT_SEC" -- "${CODEX_ARGS[@]}" < /dev/null
283
278
  fi
284
279
 
285
- # Launch codex with stdin closed; output streams directly to OUR stdout/stderr.
280
+ # The dispatcher snapshots file input, seals actual argv, then execs in this group.
286
281
  set -m
287
- "$CODEX_BIN" exec "${CODEX_ARGS[@]}" < /dev/null &
282
+ python3 "$RECEIPT_HELPER" dispatch --binary "$CODEX_BIN" \
283
+ --timeout "$TIMEOUT_SEC" --heartbeat "$HEARTBEAT_SEC" -- "${CODEX_ARGS[@]}" < /dev/null &
288
284
  CODEX_PID=$!
289
285
  printf '[codex-monitored] codex pid=%d\n' "$CODEX_PID" >&2
290
286
 
@@ -308,11 +304,8 @@ if [ -n "$TIMEOUT_FLAG" ] && [ -f "$TIMEOUT_FLAG" ]; then
308
304
  EXIT=124
309
305
  fi
310
306
 
311
- if [ -n "$RECEIPT_ENABLED" ]; then
312
- python3 "$RECEIPT_HELPER" finish \
313
- --workdir "$DEVLYN_INVOCATION_WORKDIR" \
314
- --receipt "$DEVLYN_INVOCATION_RECEIPT" \
315
- --exit-code "$EXIT" || exit 64
307
+ if [ -n "$RECEIPT_ENABLED" ] || [ -n "${DEVLYN_CODEX_PROMPT_FILE:-}" ]; then
308
+ python3 "$RECEIPT_HELPER" complete-dispatch --exit-code "$EXIT" || exit 64
316
309
  fi
317
310
 
318
311
  printf '[codex-monitored] codex exited: code=%d elapsed=%ds\n' \
@@ -501,4 +501,5 @@ def main() -> int:
501
501
 
502
502
 
503
503
  if __name__ == "__main__":
504
+ runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
504
505
  raise SystemExit(main())
@@ -49,6 +49,8 @@ Do not report a downgraded successful run when a required engine is missing.
49
49
 
50
50
  ## Canonical cross-engine invocations
51
51
 
52
+ For native Windows, use native Node/npm and Python (`python3` on PATH), with Git for Windows Bash for the shipped shell wrapper. Supported npm engine shims resolve to native argv; unsupported/malformed shims fail visibly. Deliver multiline prompts from files: Codex uses `DEVLYN_CODEX_PROMPT_FILE` with explicit `-`; Claude uses `run-bounded.py 600 --stdin-file <prompt-file> --record-transport -- claude -p`. Retain generated transport evidence. A policy-denied Codex judge read uses the complete inline no-tools packet in `_shared/codex-config.md#constrained-windows-judge-reads`, keeping read-only isolation and the selected role.
53
+
52
54
  After the availability check passes, Codex keeps the monitored `codex-monitored.sh`
53
55
  path; every other resolved OTHER engine follows `_shared/adapters/<name>.md`
54
56
  `## Invocation`. Capture `.devlyn/<name>-judge.stdout`, then normalize it with
@@ -2,6 +2,7 @@
2
2
  """Deterministic PHASE 6 final-diff gate for /devlyn:resolve."""
3
3
  from __future__ import annotations
4
4
 
5
+ import runpy
5
6
  import argparse
6
7
  import importlib.util
7
8
  import json
@@ -43,6 +44,7 @@ def git(work: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
43
44
  cwd=str(work),
44
45
  capture_output=True,
45
46
  text=True,
47
+ encoding="utf-8",
46
48
  )
47
49
 
48
50
 
@@ -329,7 +331,7 @@ def write_state(devlyn: pathlib.Path, state: dict) -> None:
329
331
 
330
332
  def read_findings(devlyn: pathlib.Path) -> list[dict]:
331
333
  path = devlyn / FINDINGS_NAME
332
- return [SPEC_VERIFY.loads_strict_json(line) for line in path.read_text().splitlines() if line.strip()]
334
+ return [SPEC_VERIFY.loads_strict_json(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
333
335
 
334
336
 
335
337
  def read_summary(devlyn: pathlib.Path) -> dict:
@@ -515,4 +517,5 @@ def main() -> int:
515
517
 
516
518
 
517
519
  if __name__ == "__main__":
520
+ runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
518
521
  raise SystemExit(main())