devlyn-cli 3.1.2 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -6
- package/CLAUDE.md +7 -7
- package/README.md +34 -13
- package/bin/devlyn.js +44 -65
- package/bin/instruction-templates.json +82 -0
- package/bin/instructions.js +187 -0
- package/config/skills/_shared/adapters/claude.md +1 -5
- package/config/skills/_shared/adapters/codex.md +1 -5
- package/config/skills/_shared/adapters/omp.md +0 -4
- package/config/skills/_shared/archive_run.py +5 -1
- package/config/skills/_shared/codex-config.md +3 -14
- package/config/skills/_shared/engine-preflight.md +3 -3
- package/config/skills/_shared/expected.schema.json +2 -2
- package/config/skills/_shared/platform-support.py +4 -1
- package/config/skills/_shared/resolve-bootstrap.py +22 -2
- package/config/skills/_shared/role-config.py +14 -2
- package/config/skills/_shared/spec-verify-check.py +427 -101
- package/config/skills/_shared/state-phase-write.py +497 -49
- package/config/skills/_shared/task-complete.py +46 -5
- package/config/skills/_shared/terminal-claim-check.py +2 -2
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:ideate/references/from-spec-mode.md +1 -1
- package/config/skills/devlyn:ideate/references/spec-template.md +2 -2
- package/config/skills/devlyn:resolve/SKILL.md +29 -51
- package/config/skills/devlyn:resolve/references/free-form-mode.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +8 -7
- package/config/skills/devlyn:resolve/references/phases/cleanup.md +5 -11
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -0
- package/config/skills/devlyn:resolve/references/phases/plan.md +3 -3
- package/config/skills/devlyn:resolve/references/state-schema.md +10 -9
- package/package.json +2 -1
- package/scripts/lint-skills.sh +14 -4
- package/scripts/test-owner-phases.py +193 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const legacyTemplates = require('./instruction-templates.json');
|
|
5
|
+
|
|
6
|
+
const BEGIN = '<!-- devlyn:instructions:begin';
|
|
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;
|
|
9
|
+
const normalize = (text) => text.replace(/\r\n/g, '\n');
|
|
10
|
+
const digest = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
11
|
+
|
|
12
|
+
class InstructionError extends Error {}
|
|
13
|
+
|
|
14
|
+
function managedBlock(template, eol) {
|
|
15
|
+
const body = 'Project-specific instructions outside this managed block take precedence over these defaults.\n\n'
|
|
16
|
+
+ normalize(template).replace(/\n?$/, '\n');
|
|
17
|
+
return `${BEGIN} sha256=${digest(body)} -->\n${body}${END}\n`.replace(/\n/g, eol);
|
|
18
|
+
}
|
|
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
|
+
|
|
28
|
+
// A digest is ownership evidence; a familiar heading alone is not.
|
|
29
|
+
function legacyRanges(text, templates) {
|
|
30
|
+
const starts = [...new Set([0, ...(text.startsWith('\uFEFF') ? [1] : []),
|
|
31
|
+
...Array.from(text.matchAll(/^# (?:Codex )?Project Instructions\r?$/gm), (m) => m.index)])].sort((a, b) => a - b);
|
|
32
|
+
const ranges = [];
|
|
33
|
+
for (const start of starts) {
|
|
34
|
+
for (const template of templates) {
|
|
35
|
+
let end = matchedEnd(text, start, template);
|
|
36
|
+
let custom = '';
|
|
37
|
+
if (end === null && template.body) {
|
|
38
|
+
const prefix = text.slice(start, starts.find((offset) => offset > start));
|
|
39
|
+
const body = /^## North Star\r?$/m.exec(prefix);
|
|
40
|
+
const header = template.preamble.slice(0, template.preamble.indexOf('\n\n') + 2);
|
|
41
|
+
if (body && normalize(prefix).startsWith(header)) {
|
|
42
|
+
const bodyStart = start + body.index;
|
|
43
|
+
end = matchedEnd(text, bodyStart, template.body);
|
|
44
|
+
// Remove only an exact original preamble; preserve an edited one in full.
|
|
45
|
+
const preambleEnd = matchedEnd(text, start, { length: template.preamble.length, sha256: digest(template.preamble) });
|
|
46
|
+
custom = text.slice(preambleEnd ?? start, bodyStart);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (end !== null) {
|
|
50
|
+
const existing = ranges.find((range) => range.start === start && range.end === end);
|
|
51
|
+
if (!existing) ranges.push({ start, end, custom });
|
|
52
|
+
// Releases may share a body. Prefer the exact owned preamble, if found.
|
|
53
|
+
else if (custom.length < existing.custom.length) existing.custom = custom;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ranges;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function retainFile(file, bytes) {
|
|
61
|
+
for (const directory of [path.dirname(path.dirname(file)), path.dirname(file)]) {
|
|
62
|
+
const stat = fs.lstatSync(directory, { throwIfNoEntry: false });
|
|
63
|
+
if (stat && !stat.isDirectory()) {
|
|
64
|
+
throw new InstructionError(`Instruction recovery needs a real directory: ${directory}. Move it aside and rerun installation.`);
|
|
65
|
+
}
|
|
66
|
+
if (!stat) fs.mkdirSync(directory);
|
|
67
|
+
}
|
|
68
|
+
const ignore = path.join(path.dirname(file), '.gitignore');
|
|
69
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '*\n', { flag: 'wx', mode: 0o600 });
|
|
70
|
+
if (fs.existsSync(file)) {
|
|
71
|
+
if (!fs.lstatSync(file).isFile() || !fs.readFileSync(file).equals(bytes)) {
|
|
72
|
+
throw new InstructionError(`Instruction recovery file differs; preserved it: ${file}. Move it aside before retrying.`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function updateInstructions(name) {
|
|
80
|
+
const templatePath = path.join(__dirname, '..', name);
|
|
81
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
82
|
+
if (template.includes(BEGIN) || template.includes(END)) {
|
|
83
|
+
throw new Error(`Packaged instruction template contains managed markers: ${templatePath}`);
|
|
84
|
+
}
|
|
85
|
+
const dest = path.join(process.cwd(), name);
|
|
86
|
+
const stat = fs.lstatSync(dest, { throwIfNoEntry: false });
|
|
87
|
+
const sourceStat = fs.statSync(templatePath);
|
|
88
|
+
if (stat?.isFile() && stat.dev === sourceStat.dev && stat.ino === sourceStat.ino) {
|
|
89
|
+
console.log(` → Using source ${name}; packaged template left unchanged`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const exists = Boolean(stat);
|
|
93
|
+
// Do not replace a shared instruction symlink or follow it outside the project.
|
|
94
|
+
if (stat && !stat.isFile()) {
|
|
95
|
+
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.`);
|
|
96
|
+
}
|
|
97
|
+
const before = exists ? fs.readFileSync(dest) : Buffer.alloc(0);
|
|
98
|
+
let current;
|
|
99
|
+
try {
|
|
100
|
+
current = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(before);
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
throw new InstructionError(`${name} must be UTF-8; original preserved. Convert its encoding and rerun installation.`, { cause });
|
|
103
|
+
}
|
|
104
|
+
const eol = current.match(/\r?\n/)?.[0] || '\n';
|
|
105
|
+
const block = managedBlock(template, eol);
|
|
106
|
+
const recovery = path.join(process.cwd(), '.devlyn', 'instructions');
|
|
107
|
+
const conflict = (reason) => {
|
|
108
|
+
const incoming = path.join(recovery, `${name}.${digest(block)}.incoming`);
|
|
109
|
+
const backup = path.join(recovery, `${name}.${digest(before)}.backup`);
|
|
110
|
+
const guide = path.join(recovery, `${name}.${digest(before)}.${digest(block)}.merge.md`);
|
|
111
|
+
retainFile(backup, before);
|
|
112
|
+
retainFile(incoming, Buffer.from(block));
|
|
113
|
+
retainFile(guide, Buffer.from(`# Merge ${name}\n\n`
|
|
114
|
+
+ `Installation stopped: ${reason}. The original file is unchanged.\n\n`
|
|
115
|
+
+ `- Backup before this install attempt: ${backup}\n- New Devlyn defaults: ${incoming}\n- File to update: ${dest}\n\n`
|
|
116
|
+
+ `1. Compare the backup and new defaults side by side. Identify all project-specific additions and edits; do not discard them.\n`
|
|
117
|
+
+ `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`
|
|
118
|
+
+ `3. Review the result, then rerun the same install command (for example, npx devlyn-cli). Other selected agents may still need installation.\n\n`
|
|
119
|
+
+ `To undo this manual merge, restore that backup to ${dest}. Earlier backups are retained too.\n`));
|
|
120
|
+
throw new InstructionError(`${name} needs merge (${reason}).\n`
|
|
121
|
+
+ `Original preserved: ${dest}\nBackup: ${backup}\nNew defaults: ${incoming}\n`
|
|
122
|
+
+ `Merge instructions: ${guide}\n`
|
|
123
|
+
+ `Keep custom rules outside the managed block, then rerun the same install command.\n`
|
|
124
|
+
+ `Installation is incomplete; earlier selected agents may already have been updated.`);
|
|
125
|
+
};
|
|
126
|
+
let content;
|
|
127
|
+
if (current.includes(BEGIN) || current.includes(END)) {
|
|
128
|
+
const starts = [...current.matchAll(/^\uFEFF?<!-- devlyn:instructions:begin sha256=([a-f0-9]{64}) -->\r?\n/gm)];
|
|
129
|
+
const ends = [...current.matchAll(/^<!-- devlyn:instructions:end -->(?:\r?\n|$)/gm)];
|
|
130
|
+
if (starts.length !== 1 || ends.length !== 1
|
|
131
|
+
|| current.split(BEGIN).length !== 2 || current.split(END).length !== 2) {
|
|
132
|
+
return conflict('ambiguous managed markers');
|
|
133
|
+
}
|
|
134
|
+
const start = starts[0];
|
|
135
|
+
const end = ends[0];
|
|
136
|
+
const bodyStart = start.index + start[0].length;
|
|
137
|
+
if (end.index < bodyStart || digest(normalize(current.slice(bodyStart, end.index))) !== start[1]) {
|
|
138
|
+
return conflict('managed defaults were edited');
|
|
139
|
+
}
|
|
140
|
+
content = current.slice(0, start.index + (start[0].startsWith('\uFEFF') ? 1 : 0)) + block + current.slice(end.index + end[0].length);
|
|
141
|
+
} else {
|
|
142
|
+
const normalized = normalize(template);
|
|
143
|
+
const bodyStart = normalized.indexOf('## North Star\n');
|
|
144
|
+
const preamble = normalized.slice(0, bodyStart);
|
|
145
|
+
const body = normalized.slice(bodyStart);
|
|
146
|
+
const ranges = legacyRanges(current, [
|
|
147
|
+
{ length: normalized.length, sha256: digest(normalized),
|
|
148
|
+
...(bodyStart > 0 ? { preamble, body: { length: body.length, sha256: digest(body) } } : {}) },
|
|
149
|
+
...(legacyTemplates[name] || []),
|
|
150
|
+
]);
|
|
151
|
+
if (ranges.length > 1) return conflict('multiple legacy templates');
|
|
152
|
+
if (ranges.length === 1) {
|
|
153
|
+
const { start, end, custom } = ranges[0];
|
|
154
|
+
if (LEGACY.test(current.slice(0, start) + custom + current.slice(end))) {
|
|
155
|
+
return conflict('edited legacy introduction or extra template text');
|
|
156
|
+
}
|
|
157
|
+
content = current.slice(0, start) + custom + block + current.slice(end);
|
|
158
|
+
} else {
|
|
159
|
+
if (LEGACY.test(current)) {
|
|
160
|
+
return conflict('unrecognized or edited legacy template');
|
|
161
|
+
}
|
|
162
|
+
content = current + (current ? eol + eol : '') + block;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const bytes = Buffer.from(content);
|
|
166
|
+
if (bytes.equals(before)) return false;
|
|
167
|
+
if (exists) {
|
|
168
|
+
const backup = path.join(recovery, `${name}.${digest(before)}.backup`);
|
|
169
|
+
retainFile(backup, before);
|
|
170
|
+
console.log(` → Saved previous ${name}: ${backup}`);
|
|
171
|
+
}
|
|
172
|
+
const temp = `${dest}.${crypto.randomUUID()}.tmp`;
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(temp, bytes, { flag: 'wx', mode: exists ? stat.mode & 0o777 : 0o644 });
|
|
175
|
+
if (exists) fs.chmodSync(temp, stat.mode & 0o777);
|
|
176
|
+
if (fs.existsSync(dest) !== exists || (exists && !fs.readFileSync(dest).equals(before))) {
|
|
177
|
+
throw new InstructionError(`${name} changed during installation; preserved it. Rerun installation.`);
|
|
178
|
+
}
|
|
179
|
+
fs.renameSync(temp, dest);
|
|
180
|
+
} finally {
|
|
181
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
182
|
+
}
|
|
183
|
+
console.log(` → Updated Devlyn defaults in ${name}; project-specific instructions preserved`);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { updateInstructions, InstructionError };
|
|
@@ -55,7 +55,7 @@ python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 --stdin-file "<judge-prompt-file
|
|
|
55
55
|
|
|
56
56
|
## Identity
|
|
57
57
|
|
|
58
|
-
You are Claude by Anthropic. Anthropic's prompt-engineering guide for this model governs your behavior on top of the canonical phase prompt below.
|
|
58
|
+
You are Claude by Anthropic. Anthropic's prompt-engineering guide for this model governs your behavior on top of the canonical phase prompt below.
|
|
59
59
|
|
|
60
60
|
## Output discipline
|
|
61
61
|
|
|
@@ -73,10 +73,6 @@ When the canonical body lists tools, use them when their result would change you
|
|
|
73
73
|
|
|
74
74
|
For long-horizon coding, review, and agentic runs, assume the harness selected `high` or `xhigh` effort unless told otherwise. Spend that depth on upfront task/constraint understanding and end-state verification, not on verbose narration. If the user or orchestrator gives a complete task in one turn, proceed autonomously instead of requiring progressive clarification.
|
|
75
75
|
|
|
76
|
-
## Validation pattern
|
|
77
|
-
|
|
78
|
-
When the canonical body asks you to verify your output before declaring done ("self-check" instructions), execute that step literally — re-read the spec's acceptance criteria, run the listed verification commands if available, list any gap. This is not optional. Mechanical gates owned by the harness (spec-verify-check.py, build-gate.py) are the primary correctness guard; your self-check is the secondary layer that catches what regex cannot.
|
|
79
|
-
|
|
80
76
|
## Anti-patterns
|
|
81
77
|
|
|
82
78
|
You interpret instructions literally and explicitly. The official guide is explicit about three failure modes:
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Identity
|
|
6
6
|
|
|
7
|
-
You are OpenAI's Codex model. OpenAI's prompt-guidance for this model governs your behavior on top of the canonical phase prompt below.
|
|
7
|
+
You are OpenAI's Codex model. OpenAI's prompt-guidance for this model governs your behavior on top of the canonical phase prompt below.
|
|
8
8
|
|
|
9
9
|
## Output discipline
|
|
10
10
|
|
|
@@ -19,10 +19,6 @@ 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.
|
|
21
21
|
|
|
22
|
-
## Validation pattern
|
|
23
|
-
|
|
24
|
-
Validation is concrete commands and tools, not self-belief. When the canonical body lists verification commands, execute them and trust their output. Do not substitute your judgment for a deterministic check the harness has provided. When validation tools are available (test runners, lint, type-check, the harness's `spec-verify-check.py`), run them before declaring success. The minimum evidence sufficient to answer correctly, cited precisely — then stop.
|
|
25
|
-
|
|
26
22
|
## Anti-patterns
|
|
27
23
|
|
|
28
24
|
The official guide warns explicitly about carrying over instructions from older prompt stacks — earlier models needed more help, and process-heavy directives now narrow GPT-5.5's search space.
|
|
@@ -8,10 +8,6 @@ You are the model running behind the `omp` CLI. Follow the canonical phase promp
|
|
|
8
8
|
|
|
9
9
|
Keep phase output machine-consumable and concise. Write the artifacts the canonical body names, update `.devlyn/pipeline.state.json` only where that phase owns the state write, and avoid narrative outside the required phase result.
|
|
10
10
|
|
|
11
|
-
## Validation Pattern
|
|
12
|
-
|
|
13
|
-
When the canonical body names deterministic commands, run those commands and trust their exit codes. Do not replace BUILD_GATE, phase gates, or VERIFY mechanical checks with same-context judgment.
|
|
14
|
-
|
|
15
11
|
## Fresh Context
|
|
16
12
|
|
|
17
13
|
Each native `task` tool invocation with a fresh `context` is a fresh phase worker. Do not call `/devlyn:resolve` recursively from inside a phase prompt; execute only the phase body you received.
|
|
@@ -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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
|
@@ -147,13 +147,13 @@
|
|
|
147
147
|
},
|
|
148
148
|
"required_files": {
|
|
149
149
|
"type": "array",
|
|
150
|
-
"description": "Files that must exist after the arm runs.",
|
|
150
|
+
"description": "Files specified by literal paths that must exist after the arm runs; globs are not expanded.",
|
|
151
151
|
"items": { "type": "string", "minLength": 1 },
|
|
152
152
|
"default": []
|
|
153
153
|
},
|
|
154
154
|
"forbidden_files": {
|
|
155
155
|
"type": "array",
|
|
156
|
-
"description": "
|
|
156
|
+
"description": "Exact Git-relative paths (forward slashes, no leading ./) that must NOT appear in the diff, including both rename names; globs and directory prefixes are not expanded.",
|
|
157
157
|
"items": { "type": "string", "minLength": 1 },
|
|
158
158
|
"default": []
|
|
159
159
|
},
|
|
@@ -107,6 +107,7 @@ def system_exit_code(code):
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
if os.name == "nt":
|
|
110
|
+
import _winapi
|
|
110
111
|
import ctypes
|
|
111
112
|
from ctypes import wintypes
|
|
112
113
|
import msvcrt
|
|
@@ -226,7 +227,9 @@ if os.name == "nt":
|
|
|
226
227
|
try:
|
|
227
228
|
if self.child.poll() is None:
|
|
228
229
|
self.child.kill()
|
|
229
|
-
|
|
230
|
+
# kill() can cache an exit code before the native handle is signaled.
|
|
231
|
+
if _winapi.WaitForSingleObject(self.child._handle, 5000) == _winapi.WAIT_TIMEOUT:
|
|
232
|
+
raise subprocess.TimeoutExpired(self.child.args, 5)
|
|
230
233
|
finally:
|
|
231
234
|
self.child._handle.Close()
|
|
232
235
|
finally:
|
|
@@ -289,7 +289,7 @@ def init_spec_source(
|
|
|
289
289
|
block("BLOCKED:invalid-flags", error or f"expected contract not found: {expected}")
|
|
290
290
|
else:
|
|
291
291
|
run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
|
|
292
|
-
_staged, error = module.stage_from_source(path, staging_dir)
|
|
292
|
+
_found, _staged, error = module.stage_from_source(path, staging_dir)
|
|
293
293
|
if error:
|
|
294
294
|
block("BLOCKED:invalid-flags", error)
|
|
295
295
|
staged_path = staging_dir / "spec-verify.json"
|
|
@@ -333,7 +333,7 @@ def capture_external_diff(cwd: pathlib.Path, ref: str) -> bytes:
|
|
|
333
333
|
if source.is_file():
|
|
334
334
|
raw = source.read_bytes()
|
|
335
335
|
else:
|
|
336
|
-
proc = subprocess.run(["git", "diff", "--binary", ref], cwd=cwd, capture_output=True)
|
|
336
|
+
proc = subprocess.run(["git", "diff", "--binary", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/", ref], cwd=cwd, capture_output=True)
|
|
337
337
|
if proc.returncode != 0:
|
|
338
338
|
block("BLOCKED:invalid-flags", os.fsdecode(proc.stderr or proc.stdout).strip())
|
|
339
339
|
raw = proc.stdout
|
|
@@ -1184,6 +1184,16 @@ def self_test() -> int:
|
|
|
1184
1184
|
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text(encoding="utf-8"))
|
|
1185
1185
|
assert staged["verification_commands"][0]["cmd"] == "printf ok"
|
|
1186
1186
|
assert spec_result["source"]["spec_sha256"] == sha256(spec_raw)
|
|
1187
|
+
pure_spec = spec_dir / "design.md"
|
|
1188
|
+
pure_spec.write_text(
|
|
1189
|
+
"# Design\n\n<!-- devlyn:verification -->\n## Verification\n\n"
|
|
1190
|
+
'```json\n{"pure_design":true,"verification_commands":[]}\n```\n',
|
|
1191
|
+
encoding="utf-8",
|
|
1192
|
+
)
|
|
1193
|
+
complete_prior(work)
|
|
1194
|
+
pure_result = bootstrap(["--spec", str(pure_spec.relative_to(work))], work, script_shared)
|
|
1195
|
+
assert pure_result["source"]["spec_sha256"] == sha256(pure_spec.read_bytes())
|
|
1196
|
+
assert not (work / ".devlyn/spec-verify.json").exists()
|
|
1187
1197
|
external_patch.write_bytes(b"stale free-form patch\n")
|
|
1188
1198
|
complete_prior(work)
|
|
1189
1199
|
bootstrap(["fresh", "goal"], work, script_shared)
|
|
@@ -1205,6 +1215,16 @@ def self_test() -> int:
|
|
|
1205
1215
|
assert verify_result["mode"] == "verify-only"
|
|
1206
1216
|
assert external_patch.read_bytes() == patch_raw
|
|
1207
1217
|
subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
|
|
1218
|
+
# Ambient no-prefix config must not change the reader's a/b contract.
|
|
1219
|
+
(work / "app.py").write_text("print('prefix control')\n", encoding="utf-8")
|
|
1220
|
+
settings = ["diff.noprefix"] + (["diff.external"] if os.name != "nt" else [])
|
|
1221
|
+
for setting in settings:
|
|
1222
|
+
subprocess.run(["git", "config", setting, "true"], cwd=work, check=True)
|
|
1223
|
+
try:
|
|
1224
|
+
assert b"diff --git a/app.py b/app.py\n" in capture_external_diff(work, "HEAD")
|
|
1225
|
+
finally:
|
|
1226
|
+
subprocess.run(["git", "config", "--unset", setting], cwd=work, check=True)
|
|
1227
|
+
subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
|
|
1208
1228
|
print("PASS bootstrap self-test patch lifecycle: full-mode removal + dirty verify-only exact capture")
|
|
1209
1229
|
|
|
1210
1230
|
for valid_named in (True, False):
|
|
@@ -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:
|