devlyn-cli 3.1.3 → 3.2.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.
@@ -5,42 +5,157 @@ const legacyTemplates = require('./instruction-templates.json');
5
5
 
6
6
  const BEGIN = '<!-- devlyn:instructions:begin';
7
7
  const END = '<!-- devlyn:instructions:end -->';
8
+ const LEGACY = /^devlyn-cli installs |^# Devlyn Agent Instructions\r?$|^This contract serves one goal: any capable engine|^Engine roles: orchestrator =/m;
8
9
  const normalize = (text) => text.replace(/\r\n/g, '\n');
9
10
  const digest = (text) => crypto.createHash('sha256').update(text).digest('hex');
10
11
 
12
+ class InstructionError extends Error {}
13
+
11
14
  function managedBlock(template, eol) {
12
15
  const body = 'Project-specific instructions outside this managed block take precedence over these defaults.\n\n'
13
16
  + normalize(template).replace(/\n?$/, '\n');
14
17
  return `${BEGIN} sha256=${digest(body)} -->\n${body}${END}\n`.replace(/\n/g, eol);
15
18
  }
16
19
 
20
+ function matchedEnd(text, start, template) {
21
+ let end = start;
22
+ for (let n = 0; n < template.length && end < text.length; n++, end++) {
23
+ if (text[end] === '\r' && text[end + 1] === '\n') end++;
24
+ }
25
+ return digest(normalize(text.slice(start, end))) === template.sha256 ? end : null;
26
+ }
27
+
17
28
  // A digest is ownership evidence; a familiar heading alone is not.
18
29
  function legacyRanges(text, templates) {
19
- const starts = new Set([0, ...Array.from(text.matchAll(/^# (?:Codex )?Project Instructions\r?$/gm), (m) => m.index)]);
20
- if (text.startsWith('\uFEFF')) starts.add(1);
30
+ const bom = text.startsWith('\uFEFF') ? 1 : 0;
31
+ const starts = [...new Set([0, ...(text.startsWith('\uFEFF') ? [1] : []),
32
+ ...instructionParagraphs(text.slice(bom))
33
+ .filter((p) => p.level === 1 && /^# (?:Codex )?Project Instructions\r?\n/.test(p.text))
34
+ .map((p) => p.start + bom)])].sort((a, b) => a - b);
21
35
  const ranges = [];
22
36
  for (const start of starts) {
23
37
  for (const template of templates) {
24
- let end = start;
25
- for (let n = 0; n < template.length && end < text.length; n++, end++) {
26
- if (text[end] === '\r' && text[end + 1] === '\n') end++;
38
+ let end = matchedEnd(text, start, template);
39
+ let custom = '';
40
+ if (end === null && template.body) {
41
+ const prefix = text.slice(start, starts.find((offset) => offset > start));
42
+ const body = instructionParagraphs(prefix).find((p) => p.level === 2 && /^## North Star\r?\n/.test(p.text));
43
+ const header = template.preamble.slice(0, template.preamble.indexOf('\n\n') + 2);
44
+ if (body && normalize(prefix).startsWith(header)) {
45
+ const bodyStart = start + body.start;
46
+ end = matchedEnd(text, bodyStart, template.body);
47
+ // Remove only an exact original preamble; preserve an edited one in full.
48
+ const preambleEnd = matchedEnd(text, start, { length: template.preamble.length, sha256: digest(template.preamble) });
49
+ custom = text.slice(preambleEnd ?? start, bodyStart);
50
+ }
27
51
  }
28
- if (digest(normalize(text.slice(start, end))) === template.sha256
29
- && !ranges.some((range) => range.start === start && range.end === end)) {
30
- ranges.push({ start, end });
52
+ if (end !== null) {
53
+ const existing = ranges.find((range) => range.start === start && range.end === end);
54
+ if (!existing) ranges.push({ start, end, custom });
55
+ // Releases may share a body. Prefer the exact owned preamble, if found.
56
+ else if (custom.length < existing.custom.length) existing.custom = custom;
31
57
  }
32
58
  }
33
59
  }
34
60
  return ranges;
35
61
  }
36
62
 
63
+ // Keep complete paragraphs (including fenced examples and comments) intact.
64
+ // Matching includes the heading path, so the same words in another section
65
+ // are not evidence that Devlyn owns them.
66
+ function instructionParagraphs(text) {
67
+ const paragraphs = [];
68
+ let pending = '', fence = null, comment = false, html = null, start = 0;
69
+ const flush = () => {
70
+ if (pending) paragraphs.push({ text: pending, start });
71
+ start += pending.length;
72
+ pending = '';
73
+ };
74
+ for (const line of text.match(/[^\n]*\n|[^\n]+$/g) || []) {
75
+ if (!fence && !comment && !html && (/^(?:#{1,6} |(?:[-*+]|\d+[.)]) )/.test(line)
76
+ || (/^#{1,6} [^\n]+\r?\n$/.test(pending) && !/^\r?\n$/.test(line)))) flush();
77
+ pending += line;
78
+ const marker = /^( {0,3})(`{3,}|~{3,})/.exec(line)?.[2];
79
+ if (!comment && marker) {
80
+ if (!fence) fence = marker;
81
+ else if (marker[0] === fence[0] && marker.length >= fence.length
82
+ && /^ {0,3}(?:`+|~+)\s*$/.test(line)) fence = null;
83
+ }
84
+ if (!fence) {
85
+ let markup = '', cursor = 0;
86
+ for (const marker of line.matchAll(/<!--|-->/g)) {
87
+ if (!comment) markup += line.slice(cursor, marker.index);
88
+ comment = marker[0] === '<!--';
89
+ cursor = marker.index + marker[0].length;
90
+ }
91
+ if (!comment) markup += line.slice(cursor);
92
+ const opening = /^ {0,3}<([a-z][\w:-]*)\b[^>]*>/i.exec(markup);
93
+ if (!html && opening && !/\/>$/.test(opening[0])
94
+ && !/^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i.test(opening[1])) {
95
+ html = { tag: opening[1].toLowerCase(), depth: 0 };
96
+ }
97
+ if (html) {
98
+ for (const tag of markup.matchAll(/<(\/?)([a-z][\w:-]*)\b[^>]*>/gi)) {
99
+ if (tag[2].toLowerCase() === html.tag && !/\/>$/.test(tag[0])) html.depth += tag[1] ? -1 : 1;
100
+ }
101
+ if (html.depth <= 0) html = null;
102
+ }
103
+ }
104
+ if (!fence && !comment && !html && /^\r?\n$/.test(line)) flush();
105
+ }
106
+ flush();
107
+ const headings = [];
108
+ for (const [index, paragraph] of paragraphs.entries()) {
109
+ const value = normalize(paragraph.text).replace(/\n+$/, '');
110
+ const heading = /^(#{1,6}) ([^\n]+)$/.exec(value);
111
+ if (heading) {
112
+ paragraph.level = heading[1].length;
113
+ while (headings.length && headings.at(-1).level >= paragraph.level) headings.pop();
114
+ }
115
+ paragraph.parents = headings.map((h) => h.index);
116
+ paragraph.key = digest(JSON.stringify([headings.map((h) => h.title), value]));
117
+ if (heading) headings.push({ level: paragraph.level, title: value, index });
118
+ }
119
+ return paragraphs;
120
+ }
121
+
122
+ function customInstructions(text, name, template, managed = false) {
123
+ const known = new Set([
124
+ ...legacyTemplates.paragraphs[name],
125
+ ...instructionParagraphs(template).map((p) => p.key),
126
+ ]);
127
+ const bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
128
+ const paragraphs = instructionParagraphs(text.slice(bom.length));
129
+ const keep = new Set();
130
+ let legacy = managed;
131
+ for (const [index, paragraph] of paragraphs.entries()) {
132
+ if (!managed && paragraph.level === 1) {
133
+ const next = paragraphs.findIndex((p, i) => i > index && p.level === 1);
134
+ const section = paragraphs.slice(index, next < 0 ? undefined : next);
135
+ legacy = /^# (?:Codex )?Project Instructions\r?\n|^# Devlyn Agent Instructions\r?\n/.test(paragraph.text)
136
+ && section.some((p) => !/^\s*(?:`{3}|~{3}|<!--)/.test(p.text) && LEGACY.test(p.text));
137
+ }
138
+ if (!legacy || !known.has(paragraph.key)) {
139
+ keep.add(index);
140
+ for (const parent of paragraph.parents) keep.add(parent);
141
+ }
142
+ }
143
+ return bom + paragraphs.filter((_, index) => keep.has(index)).map((p) => p.text).join('');
144
+ }
145
+
37
146
  function retainFile(file, bytes) {
38
- fs.mkdirSync(path.dirname(file), { recursive: true });
147
+ for (const directory of [path.dirname(path.dirname(file)), path.dirname(file)]) {
148
+ const stat = fs.lstatSync(directory, { throwIfNoEntry: false });
149
+ if (stat && !stat.isDirectory()) {
150
+ throw new InstructionError(`Instruction recovery needs a real directory: ${directory}. Move it aside and rerun installation.`);
151
+ }
152
+ if (!stat) fs.mkdirSync(directory);
153
+ }
39
154
  const ignore = path.join(path.dirname(file), '.gitignore');
40
155
  if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '*\n', { flag: 'wx', mode: 0o600 });
41
156
  if (fs.existsSync(file)) {
42
157
  if (!fs.lstatSync(file).isFile() || !fs.readFileSync(file).equals(bytes)) {
43
- throw new Error(`Instruction recovery file differs; preserved it: ${file}. Move it aside before retrying.`);
158
+ throw new InstructionError(`Instruction recovery file differs; preserved it: ${file}. Move it aside before retrying.`);
44
159
  }
45
160
  } else {
46
161
  fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
@@ -63,22 +178,36 @@ function updateInstructions(name) {
63
178
  const exists = Boolean(stat);
64
179
  // Do not replace a shared instruction symlink or follow it outside the project.
65
180
  if (stat && !stat.isFile()) {
66
- throw new Error(`Instruction file must be a regular file; preserved: ${dest}. Move it aside and rerun, then merge your shared rules outside the managed block.`);
181
+ throw new InstructionError(`Instruction file must be a regular file; preserved: ${dest}. Move it aside and rerun, then merge your shared rules outside the managed block.`);
67
182
  }
68
183
  const before = exists ? fs.readFileSync(dest) : Buffer.alloc(0);
69
184
  let current;
70
185
  try {
71
186
  current = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(before);
72
187
  } catch (cause) {
73
- throw new Error(`${name} must be UTF-8; original preserved. Convert its encoding and rerun installation.`, { cause });
188
+ throw new InstructionError(`${name} must be UTF-8; original preserved. Convert its encoding and rerun installation.`, { cause });
74
189
  }
75
190
  const eol = current.match(/\r?\n/)?.[0] || '\n';
76
191
  const block = managedBlock(template, eol);
77
192
  const recovery = path.join(process.cwd(), '.devlyn', 'instructions');
78
193
  const conflict = (reason) => {
79
194
  const incoming = path.join(recovery, `${name}.${digest(block)}.incoming`);
195
+ const backup = path.join(recovery, `${name}.${digest(before)}.backup`);
196
+ const guide = path.join(recovery, `${name}.${digest(before)}.${digest(block)}.merge.md`);
197
+ retainFile(backup, before);
80
198
  retainFile(incoming, Buffer.from(block));
81
- throw new Error(`${name} needs merge (${reason}); original preserved. New defaults: ${incoming}. Keep custom rules outside the managed block, then rerun installation.`);
199
+ retainFile(guide, Buffer.from(`# Merge ${name}\n\n`
200
+ + `Installation stopped: ${reason}. The original file is unchanged.\n\n`
201
+ + `- Backup before this install attempt: ${backup}\n- New Devlyn defaults: ${incoming}\n- File to update: ${dest}\n\n`
202
+ + `1. Compare the backup and new defaults side by side. Identify all project-specific additions and edits; do not discard them.\n`
203
+ + `2. Update ${name} with exactly one complete managed block from the new defaults. Keep project-specific rules outside that block; they take precedence. Resolve conflicting rules explicitly. Do not copy the new defaults over the whole file.\n`
204
+ + `3. Review the result, then rerun the same install command (for example, npx devlyn-cli). Other selected agents may still need installation.\n\n`
205
+ + `To undo this manual merge, restore that backup to ${dest}. Earlier backups are retained too.\n`));
206
+ throw new InstructionError(`${name} needs merge (${reason}).\n`
207
+ + `Original preserved: ${dest}\nBackup: ${backup}\nNew defaults: ${incoming}\n`
208
+ + `Merge instructions: ${guide}\n`
209
+ + `Keep custom rules outside the managed block, then rerun the same install command.\n`
210
+ + `Installation is incomplete; earlier selected agents may already have been updated.`);
82
211
  };
83
212
  let content;
84
213
  if (current.includes(BEGIN) || current.includes(END)) {
@@ -91,25 +220,30 @@ function updateInstructions(name) {
91
220
  const start = starts[0];
92
221
  const end = ends[0];
93
222
  const bodyStart = start.index + start[0].length;
94
- if (end.index < bodyStart || digest(normalize(current.slice(bodyStart, end.index))) !== start[1]) {
95
- return conflict('managed defaults were edited');
96
- }
97
- content = current.slice(0, start.index + (start[0].startsWith('\uFEFF') ? 1 : 0)) + block + current.slice(end.index + end[0].length);
223
+ if (end.index < bodyStart) return conflict('reversed managed markers');
224
+ const previous = current.slice(bodyStart, end.index);
225
+ const custom = digest(normalize(previous)) === start[1] ? ''
226
+ : customInstructions(previous, name,
227
+ 'Project-specific instructions outside this managed block take precedence over these defaults.\n\n' + template, true);
228
+ content = current.slice(0, start.index + (start[0].startsWith('\uFEFF') ? 1 : 0))
229
+ + custom + (custom ? eol : '') + block + current.slice(end.index + end[0].length);
98
230
  } else {
99
231
  const normalized = normalize(template);
232
+ const bodyStart = normalized.indexOf('## North Star\n');
233
+ const preamble = normalized.slice(0, bodyStart);
234
+ const body = normalized.slice(bodyStart);
100
235
  const ranges = legacyRanges(current, [
101
- { length: normalized.length, sha256: digest(normalized) },
236
+ { length: normalized.length, sha256: digest(normalized),
237
+ ...(bodyStart > 0 ? { preamble, body: { length: body.length, sha256: digest(body) } } : {}) },
102
238
  ...(legacyTemplates[name] || []),
103
239
  ]);
104
- if (ranges.length > 1) return conflict('multiple legacy templates');
105
- if (ranges.length === 1) {
106
- const { start, end } = ranges[0];
107
- content = current.slice(0, start) + block + current.slice(end);
240
+ if (ranges.length === 1 && !LEGACY.test(current.slice(0, ranges[0].start)
241
+ + ranges[0].custom + current.slice(ranges[0].end))) {
242
+ const { start, end, custom } = ranges[0];
243
+ content = current.slice(0, start) + custom + block + current.slice(end);
108
244
  } else {
109
- if (/^devlyn-cli installs |^# Devlyn Agent Instructions\r?$|^This contract serves one goal: any capable engine|^Engine roles: orchestrator =/m.test(current)) {
110
- return conflict('unrecognized or edited legacy template');
111
- }
112
- content = current + (current ? eol + eol : '') + block;
245
+ const custom = customInstructions(current, name, template);
246
+ content = custom + (custom ? eol + eol : '') + block;
113
247
  }
114
248
  }
115
249
  const bytes = Buffer.from(content);
@@ -124,7 +258,7 @@ function updateInstructions(name) {
124
258
  fs.writeFileSync(temp, bytes, { flag: 'wx', mode: exists ? stat.mode & 0o777 : 0o644 });
125
259
  if (exists) fs.chmodSync(temp, stat.mode & 0o777);
126
260
  if (fs.existsSync(dest) !== exists || (exists && !fs.readFileSync(dest).equals(before))) {
127
- throw new Error(`${name} changed during installation; preserved it. Rerun installation.`);
261
+ throw new InstructionError(`${name} changed during installation; preserved it. Rerun installation.`);
128
262
  }
129
263
  fs.renameSync(temp, dest);
130
264
  } finally {
@@ -134,4 +268,4 @@ function updateInstructions(name) {
134
268
  return true;
135
269
  }
136
270
 
137
- module.exports = { updateInstructions };
271
+ module.exports = { updateInstructions, InstructionError, instructionParagraphs };
@@ -464,7 +464,11 @@ def move_artifacts(devlyn: pathlib.Path, dest: pathlib.Path) -> int:
464
464
  try:
465
465
  for source, target in moves:
466
466
  target.parent.mkdir(parents=True, exist_ok=True)
467
- shutil.move(str(source), str(target))
467
+ try:
468
+ shutil.move(str(source), str(target))
469
+ except OSError:
470
+ target.unlink(missing_ok=True)
471
+ raise
468
472
  completed.append((source, target))
469
473
  except BaseException:
470
474
  for source, target in reversed(completed):
@@ -22,7 +22,7 @@ if [ ! -f "$CODEX_MONITORED_PATH" ]; then
22
22
  fi
23
23
  ```
24
24
 
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.
25
+ **Read-only critique / adversarial review / debate** (`/devlyn:resolve` VERIFY pair-mode, plus any future ideate read-only critique). Codex returns findings on stdout; the orchestrator writes files.
26
26
 
27
27
  ```bash
28
28
  DEVLYN_CODEX_PROMPT_FILE="<prompt-file>" CODEX_MONITORED_ISOLATED=1 CODEX_MONITORED_TIMEOUT_SEC=600 bash "$CODEX_MONITORED_PATH" \
@@ -43,24 +43,13 @@ DEVLYN_CODEX_PROMPT_FILE="<prompt-file>" bash "$CODEX_MONITORED_PATH" \
43
43
  -
44
44
  ```
45
45
 
46
- **CI-equivalent BUILD_GATE** keeps the same write sandbox and enables general
47
- outbound network access inside that sandbox, including the loopback servers and
48
- network-backed test gates that CI may exercise:
49
-
50
- ```bash
51
- DEVLYN_CODEX_PROMPT_FILE="<prompt-file>" bash "$CODEX_MONITORED_PATH" \
52
- -C <project-root> \
53
- -s workspace-write \
54
- -c sandbox_workspace_write.network_access=true \
55
- -c model_reasoning_effort=xhigh \
56
- -
57
- ```
46
+ PLAN uses owner reasoning; BUILD_GATE/CLEANUP run owner commands, without separate Codex invocations. Code/doc cleanup runs inside the selected IMPLEMENT invocation. An already-open historical BUILD span without `execution_kind` keeps its receipt-bound workspace-write/network-true worker route; do not reinterpret or rewrite that evidence.
58
47
 
59
48
  Notes:
60
49
  - `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.
61
50
  - `-C` — project root so Codex's working directory matches.
62
51
  - `-s read-only` / `-s workspace-write` — sandbox policy. Use workspace-write for implementation/probe phases that write tracked files or `.devlyn` artifacts.
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.
52
+ - `-c sandbox_workspace_write.network_access=<true|false>` — required and receipt-bound for mutation phases: `true` only for historical receipt-bound BUILD_GATE and, per call, PROBE-DERIVE when the probe's visible Verification command requires a localhost service; `false` for IMPLEMENT, historical worker PLAN/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.
64
53
  - `-c model_reasoning_effort=xhigh` — config override for reasoning depth. Required for deep critique; skills may choose `high` or `medium` when thoroughness doesn't warrant xhigh.
65
54
  - **For every receipt-bound Codex call, add `--json -m <model_requested>` to the recipes above**, matching the model recorded at spawn; keep the skill’s existing model-selection rules. Other routes keep their omission rules unless an explicit role profile supplies a model: normal workers may inherit user config, while unconfigured isolated VERIFY uses its CLI default. Explicit profiles use role-config.py options and the separate judge-role-evidence.py contract; they do not relax mutation receipts. Omission proves neither model identity nor role fitness. Re-certify seats after model/version changes before re-pinning them.
66
55
  - `CODEX_MONITORED_ISOLATED=1` — required for bounded read-only critique/probe/judge calls. The wrapper adds `--ignore-user-config --ignore-rules --ephemeral --disable codex_hooks --disable hooks` so user config, AGENTS.md, hooks, and project rules cannot add hidden context, tool calls, or transcript side effects. Do not set it for workspace-write implementation phases.
@@ -13,11 +13,11 @@ Engine requirements have two classes:
13
13
 
14
14
  ## Role resolution
15
15
 
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.
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 runs commands in the orchestrator without a separate model; risk-probe derivation keeps its existing route.
17
17
 
18
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.
19
19
 
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.
20
+ Worker controls IMPLEMENT, its included code/doc cleanup, and their repair rounds only; owner CLEANUP performs artifact/invariant checks without a worker dispatch; 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.
21
21
 
22
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`.
23
23
 
@@ -33,7 +33,7 @@ When a run or phase requires engine `<name>` (claude, codex, omp, or any adapter
33
33
 
34
34
  Never prompt the user mid-pipeline. Missing engines for explicit routes are BLOCKED states, not silent fallbacks. Missing OTHER engines for automatic escalations are reported solo-skips, not fallbacks — the auto route was never selected.
35
35
 
36
- Per-skill defaults: `/devlyn:resolve` uses Claude for PLAN/IMPLEMENT when the orchestrator has Claude Code's native Agent primitive; Codex CLI and oh-my-pi orchestrators use their own fresh worker as the default route (Codex child process, omp native `task`) so the pipeline still has structural phase isolation. VERIFY invokes the OTHER engine by default when available unless `--no-pair` was passed. `/devlyn:ideate` defaults to Claude; `--engine` selects the elicitation/normalization adapter, not an automatic cross-model challenge phase. Any future ideate read-only critique must follow `_shared/codex-config.md` isolation rules. Each SKILL.md flag block is source of truth for that skill's default.
36
+ Per-skill defaults: `/devlyn:resolve` uses Claude for IMPLEMENT when the orchestrator has Claude Code's native Agent primitive; Codex CLI and oh-my-pi orchestrators use their own fresh worker as the default route (Codex child process, omp native `task`) so implementation and independent verification retain structural isolation. PLAN uses the owner context; BUILD_GATE/CLEANUP use owner commands. VERIFY invokes the OTHER engine by default when available unless `--no-pair` was passed. `/devlyn:ideate` defaults to Claude; `--engine` selects the elicitation/normalization adapter, not an automatic cross-model challenge phase. Any future ideate read-only critique must follow `_shared/codex-config.md` isolation rules. Each SKILL.md flag block is source of truth for that skill's default.
37
37
 
38
38
  ## What a skill must report after a BLOCKED engine check
39
39
 
@@ -10,6 +10,7 @@ import os
10
10
  from pathlib import Path
11
11
  import re
12
12
  import shutil
13
+ import stat
13
14
  import subprocess
14
15
  import sys
15
16
  import tempfile
@@ -95,9 +96,20 @@ def validate(config, *, run=False, shared=SHARED):
95
96
 
96
97
  def read_config(path, *, run=False, optional=False, shared=SHARED):
97
98
  path = Path(path)
98
- if optional and not path.exists():
99
- return {}, {"path": str(path.absolute()), "sha256": None}
100
99
  try:
100
+ if optional:
101
+ try:
102
+ path.lstat()
103
+ except FileNotFoundError:
104
+ for parent in path.parents:
105
+ try:
106
+ mode = parent.stat().st_mode
107
+ except FileNotFoundError:
108
+ continue
109
+ if not stat.S_ISDIR(mode):
110
+ fail(f"cannot read role configuration {path}: {parent} is not a directory")
111
+ return {}, {"path": str(path.absolute()), "sha256": None}
112
+ raise
101
113
  raw = path.read_bytes()
102
114
  value = validate(loads(raw), run=run, shared=shared)
103
115
  except (OSError, UnicodeError, json.JSONDecodeError) as exc:
@@ -1754,10 +1754,9 @@ def authorized_surface_findings(
1754
1754
  to catch. A persistent finding exhausts the existing BUILD_GATE
1755
1755
  fix-loop budget and halts for user/orchestrator review instead.
1756
1756
 
1757
- Not re-run at VERIFY MECHANICAL time (post-CLEANUP): CLEANUP's own
1758
- allowlist (tooling artifacts, dead code, doc-reference fixes) licenses
1759
- paths PLAN never declared, so re-checking there would false-positive on
1760
- CLEANUP's own sanctioned changes.
1757
+ Not re-run at VERIFY MECHANICAL time: historical worker CLEANUP can
1758
+ license paths outside PLAN. Owner CLEANUP instead enforces an unchanged
1759
+ source checkpoint and the final untracked baseline before VERIFY.
1761
1760
  """
1762
1761
  surface, surface_error = load_authorized_surface(devlyn_dir)
1763
1762
  if surface_error is not None: