@lyupro/codex-bridge 0.5.5 → 0.6.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.
@@ -32,40 +32,40 @@ function addPath(paths, value) {
32
32
  && !paths.includes(candidate)) paths.push(candidate);
33
33
  }
34
34
 
35
- function tokens(segment) {
36
- return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g) ?? [];
37
- }
38
-
39
- // A `>` inside a quoted argument is text, not a redirect. The 2026-08-28 worktree-lock refusal
40
- // named `#'` from `sed -E 's#(A ).*#\1<redacted>#'` because this scan previously read raw text.
41
- function quotedCharacters(command) {
42
- const quoted = new Uint8Array(command.length);
43
- let quote = null;
44
- for (let index = 0; index < command.length; index += 1) {
45
- const character = command[index];
46
- if (quote === "'") {
47
- if (character === "'") quote = null;
48
- else quoted[index] = 1;
49
- } else if (quote === '"') {
50
- if (character === '\\' && index + 1 < command.length) {
51
- quoted[index] = 1;
52
- quoted[index + 1] = 1;
53
- index += 1;
54
- } else if (character === '"') quote = null;
55
- else quoted[index] = 1;
56
- } else if (character === '\\' && index + 1 < command.length) {
57
- index += 1;
58
- } else if (character === "'" || character === '"') {
59
- quote = character;
60
- quoted[index] = 1;
61
- }
62
- }
63
- return quote ? null : quoted;
64
- }
65
-
66
- function positional(args) {
67
- return args.filter((arg) => !arg.startsWith('-'));
68
- }
35
+ function tokens(segment) {
36
+ return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g) ?? [];
37
+ }
38
+
39
+ // A `>` inside a quoted argument is text, not a redirect. The 2026-08-28 worktree-lock refusal
40
+ // named `#'` from `sed -E 's#(A ).*#\1<redacted>#'` because this scan previously read raw text.
41
+ function quotedCharacters(command) {
42
+ const quoted = new Uint8Array(command.length);
43
+ let quote = null;
44
+ for (let index = 0; index < command.length; index += 1) {
45
+ const character = command[index];
46
+ if (quote === "'") {
47
+ if (character === "'") quote = null;
48
+ else quoted[index] = 1;
49
+ } else if (quote === '"') {
50
+ if (character === '\\' && index + 1 < command.length) {
51
+ quoted[index] = 1;
52
+ quoted[index + 1] = 1;
53
+ index += 1;
54
+ } else if (character === '"') quote = null;
55
+ else quoted[index] = 1;
56
+ } else if (character === '\\' && index + 1 < command.length) {
57
+ index += 1;
58
+ } else if (character === "'" || character === '"') {
59
+ quote = character;
60
+ quoted[index] = 1;
61
+ }
62
+ }
63
+ return quote ? null : quoted;
64
+ }
65
+
66
+ function positional(args) {
67
+ return args.filter((arg) => !arg.startsWith('-'));
68
+ }
69
69
 
70
70
  /**
71
71
  * Strip the directory and the Windows executable suffix before matching the command name.
@@ -103,23 +103,28 @@ function heredoc(command) {
103
103
  || /[\\/]/.test(value)
104
104
  || /(?:^|[\\/])[^\\/]+\.[A-Za-z0-9_-]+$/.test(value)) addPath(paths, value);
105
105
  }
106
- return { header: header[1], paths, writes: true };
106
+ return { shell, paths, writes: true };
107
107
  }
108
108
 
109
109
  /** Returns `{ writes, paths }` for the deliberately obvious write forms this guard recognises. */
110
110
  export function shellWriteIntent(command) {
111
111
  if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
112
112
  const paths = [];
113
- const document = heredoc(command);
114
- const shell = document?.header ?? command;
115
- let writes = document?.writes ?? false;
116
- const quoted = quotedCharacters(shell);
117
-
118
- for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
119
- if (quoted && quoted[match.index]) continue;
120
- writes = true;
121
- addPath(paths, match[1]);
122
- }
113
+ const document = heredoc(command);
114
+ // The document body is prose until an interpreter makes it code. Reading `header` here while
115
+ // heredoc() returned `shell` meant the whole command, body included, went through shell parsing
116
+ // whenever the writer was plain `cat`: on 2026-09-06 the lock refused four harmless commands,
117
+ // the last of them because a sentence in a task file said "Do not touch the installer" and
118
+ // `touch` is in COMMANDS, so every following word became a write target under the repository.
119
+ const shell = document?.shell ?? command;
120
+ let writes = document?.writes ?? false;
121
+ const quoted = quotedCharacters(shell);
122
+
123
+ for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
124
+ if (quoted && quoted[match.index]) continue;
125
+ writes = true;
126
+ addPath(paths, match[1]);
127
+ }
123
128
 
124
129
  // A real output redirect identifies the writer's destination. Body text is only evidence when
125
130
  // the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Defines each delegated agent's role, wall-clock budget, write access and result file.
3
+ *
4
+ * The three agents were listed four times: twice in run-config.mjs for budgets and models,
5
+ * once in codex-args.mjs for role lookup, and once in order-gate.mjs for guarded names.
6
+ * Adding an agent could silently leave one copy behind. Shared data lives here so config
7
+ * and hooks can read one registry without importing reply formatting or creating a cycle.
8
+ */
9
+ export const AGENTS = {
10
+ 'codex-scout': { role: 'scout', budget: 15, writes: false, result: 'result.json' },
11
+ 'codex-build': { role: 'build', budget: 25, writes: true, result: 'result.json' },
12
+ 'codex-review': { role: 'review', budget: 20, writes: false, result: 'review.json' },
13
+ };
14
+
15
+ export const agentRole = (agent) => AGENTS[agent]?.role;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Plan_56 D27: persist only the operator's edit, never the reader's merged defaults.
3
+ * Keep unrelated JSON text intact: normalization on read is not permission to rewrite it.
4
+ * Plan_56 D40/D41: caller-side reads lost edits during catalogue waits. Transform fresh bytes
5
+ * here and retry if they change. Plan_56 D44: three separate model-set processes all reported
6
+ * success while one edit vanished; hold the shared lock across transforms, retries, and publish.
7
+ * Transformers may run three times, so network work belongs before this boundary.
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import fsSync from 'node:fs';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { BRAND_CONFIG_PATH } from './brand-home.mjs';
14
+ import { parseJsonText } from './json-file.mjs';
15
+ import { validateRunConfig } from './config-validate.mjs';
16
+ import { withFileLock } from './file-lock.mjs';
17
+
18
+ function replaceValue(file, raw, key, value) {
19
+ // The shared reader has already checked JSON syntax; tokens locate top-level value spans.
20
+ const tokens = [...raw.matchAll(/"(?:\\[\s\S]|[^"\\])*"|[{}[\],:]|[^\s{}[\],:]+/g)];
21
+ const replacements = [];
22
+ let lastEnd = tokens[0].index + 1;
23
+ for (let index = 1; index < tokens.length - 1;) {
24
+ const name = parseJsonText(file, tokens[index][0]);
25
+ const start = index + 2;
26
+ let end = start;
27
+ if (tokens[start][0] === '{' || tokens[start][0] === '[') {
28
+ let depth = 1;
29
+ while (depth > 0) {
30
+ end += 1;
31
+ if (tokens[end][0] === '{' || tokens[end][0] === '[') depth += 1;
32
+ if (tokens[end][0] === '}' || tokens[end][0] === ']') depth -= 1;
33
+ }
34
+ }
35
+ lastEnd = tokens[end].index + tokens[end][0].length;
36
+ if (name === key) replacements.push([tokens[start].index, lastEnd]);
37
+ index = end + 2;
38
+ }
39
+ if (replacements.length) {
40
+ for (const [start, end] of replacements.reverse()) {
41
+ raw = raw.slice(0, start) + indented(raw, start, value) + raw.slice(end);
42
+ }
43
+ return raw;
44
+ }
45
+ const newline = raw.includes('\r\n') ? '\r\n' : '\n';
46
+ const trailing = raw.slice(lastEnd, tokens.at(-1).index);
47
+ const addition = `${tokens.length > 2 ? ',' : ''}${newline} ${JSON.stringify(key)}: `
48
+ + value.replaceAll('\n', `${newline} `)
49
+ + (trailing.includes('\n') ? '' : newline);
50
+ return raw.slice(0, lastEnd) + addition + raw.slice(lastEnd);
51
+ }
52
+
53
+ /**
54
+ * A serialized object knows its own shape but not how deep it is being pasted, so every line
55
+ * after the first came out flush against the margin: writing a role profile left `"models"`
56
+ * indented by two and its `"build"` by none. The file stayed valid JSON and became unreadable
57
+ * for the operator who edits it by hand, which is the audience this whole path exists for.
58
+ */
59
+ function indented(raw, start, value) {
60
+ const lineStart = raw.lastIndexOf('\n', start) + 1;
61
+ const indent = /^[ \t]*/.exec(raw.slice(lineStart, start))[0];
62
+ const newline = raw.includes('\r\n') ? '\r\n' : '\n';
63
+ return indent ? value.replaceAll('\n', `${newline}${indent}`) : value;
64
+ }
65
+
66
+ async function readBytes(file) {
67
+ try {
68
+ return await fs.readFile(file);
69
+ } catch (error) {
70
+ if (error.code !== 'ENOENT') throw error;
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function readBytesSync(file) {
76
+ try {
77
+ return fsSync.readFileSync(file);
78
+ } catch (error) {
79
+ if (error.code !== 'ENOENT') throw error;
80
+ return null;
81
+ }
82
+ }
83
+
84
+ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
85
+ const reset = key !== null && typeof key === 'object' && !Array.isArray(key)
86
+ && key.reset === true && Object.keys(key).length === 1 && transform === undefined;
87
+ if (!reset && (typeof key !== 'string' || !key || typeof transform !== 'function')) {
88
+ throw new Error('Config edit requires a non-empty string key and a transformer function or { reset: true }');
89
+ }
90
+ const directory = path.dirname(file);
91
+ const created = await fs.mkdir(directory, { recursive: true });
92
+ try {
93
+ return await withFileLock(`${file}.lock`, async () => {
94
+ let collision;
95
+ for (let attempt = 0; attempt < 3; attempt += 1) {
96
+ const original = await readBytes(file);
97
+ const raw = original === null ? '{}\n' : original.toString('utf8');
98
+ const parsed = parseJsonText(file, raw);
99
+ let text;
100
+ if (reset) {
101
+ text = '{}\n';
102
+ } else {
103
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
104
+ validateRunConfig(file, parsed);
105
+ }
106
+ const value = await transform(Object.hasOwn(parsed, key) ? parsed[key] : undefined);
107
+ validateRunConfig(file, { ...parsed, [key]: value });
108
+ text = replaceValue(file, raw, key, JSON.stringify(value, null, 2));
109
+ }
110
+ // Validate the exact bytes to be persisted, before even creating a temporary file (D27).
111
+ const config = validateRunConfig(file, parseJsonText(file, text));
112
+ await fs.mkdir(path.dirname(file), { recursive: true });
113
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
114
+ const handle = await fs.open(temporary, 'wx');
115
+ try {
116
+ await handle.writeFile(text, 'utf8');
117
+ await handle.close();
118
+ // Comparing and publishing must be one step. Awaiting between them hands the event loop to
119
+ // a sibling edit, and a live probe of three concurrent `model set` calls lost one of them
120
+ // silently: every comparison passed before the first rename ran. Synchronous calls leave no
121
+ // suspension point between the two, so a competing edit is seen by whoever compares second.
122
+ const current = readBytesSync(file);
123
+ if (original === null ? current !== null : current === null || !original.equals(current)) {
124
+ await fs.rm(temporary, { force: true });
125
+ continue;
126
+ }
127
+ fsSync.renameSync(temporary, file);
128
+ return config;
129
+ } catch (error) {
130
+ await handle.close().catch(() => {});
131
+ await fs.rm(temporary, { force: true });
132
+ // Windows refuses the rename outright when another process is publishing onto the same name
133
+ // at that instant: two concurrent `model set` calls returned EPERM here on a live probe, and
134
+ // one operator edit was lost with the file system's wording instead of an answer. A collision
135
+ // is retried like a byte change; a lasting one is reported in its own words, not as drift.
136
+ if ((error.code === 'EPERM' || error.code === 'EBUSY') && attempt < 2) {
137
+ collision = error;
138
+ continue;
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+ if (collision) throw collision;
144
+ throw new Error(`${file}: config kept changing under the edit; nothing was written. Run the command again.`);
145
+ });
146
+ } catch (error) {
147
+ // D44 needs the parent before validation to lock even a new config. Undo only empty parents
148
+ // this call created, preserving D27's refusal contract and anything a competing writer added.
149
+ if (created) {
150
+ // Windows mkdir returns a namespaced path; compare that form on both sides of the boundary.
151
+ const boundary = path.toNamespacedPath(path.resolve(created));
152
+ for (let current = path.toNamespacedPath(path.resolve(directory));
153
+ current === boundary || current.startsWith(`${boundary}${path.sep}`); current = path.dirname(current)) {
154
+ try { await fs.rmdir(current); } catch { break; }
155
+ }
156
+ }
157
+ throw error;
158
+ }
159
+ }
@@ -0,0 +1,227 @@
1
+ /** Shared by reading and editing so Plan_56 D27 cannot persist an unreadable config. */
2
+ import { AGENTS } from './agents.mjs';
3
+
4
+ const BUDGET_KEY = 'budgets';
5
+ export const ROLES = Object.values(AGENTS).map(({ role }) => role);
6
+ const RETENTION_KEY = 'retention';
7
+ const RETENTION_FIELDS = ['enabled', 'days'];
8
+ const DEFAULT_RETENTION = { enabled: true, days: 30 };
9
+
10
+ /**
11
+ * Files the surrounding tooling writes on its own, in any repository a run touches.
12
+ *
13
+ * A run is graded by comparing the worktree either side of it, which silently assumes the
14
+ * run is the only writer. It is not: OMC rewrites .omc/ and Claude Code rewrites its own
15
+ * caches while Codex works, and on 2026-08-02 an honest run was failed for
16
+ * “out-of-scope changes: .omc/project-memory.json” — a file Codex never opened. These paths are
17
+ * therefore attributed to the environment rather than to the run. They are not hidden: the
18
+ * reply and meta.json list them separately, so a run that really did edit them is still visible.
19
+ */
20
+ export const DEFAULT_ENVIRONMENT_PATHS = [
21
+ '.omc/**',
22
+ '.claude/settings.local.json',
23
+ // Claude Code's own state, which only exists when the repository under a run is ~/.claude.
24
+ 'mcp-needs-auth-cache.json',
25
+ 'plugins/installed_plugins.json',
26
+ 'plugins/known_marketplaces.json',
27
+ ];
28
+
29
+ /** Off means the flag is passed to Codex as `--disable <key>`. */
30
+ export const DEFAULTS = {
31
+ hooks: false,
32
+ plugins: false,
33
+ models: {},
34
+ budgets: Object.fromEntries(Object.values(AGENTS).map(({ role, budget }) => [role, budget])),
35
+ retention: DEFAULT_RETENTION,
36
+ environmentPaths: DEFAULT_ENVIRONMENT_PATHS,
37
+ answerLanguage: 'English',
38
+ };
39
+
40
+ /** Booleans the operator flips from the command line; structured keys are edited in the file. */
41
+ export const SWITCH_KEYS = ['hooks', 'plugins'];
42
+ export const LIST_KEYS = ['environmentPaths'];
43
+ export const OBJECT_KEYS = ['models'];
44
+ /**
45
+ * A role is configured as a pair, not as a model alone: reasoning depth is half of what a
46
+ * model is worth. A cheap model at its default depth is a different worker from the same
47
+ * model at "max", and pinning only the name would have silently kept every run at the
48
+ * fallback depth the dispatcher happens to pass.
49
+ */
50
+ const PROFILE_KEYS = ['model', 'effort', 'speed'];
51
+ // Plan_56 D24: delegated runs check form offline; model and speed commands check catalogue
52
+ // support on write, keeping network access out of the config reader used at every run start.
53
+ /**
54
+ * The language a run answers in. Left to the model it followed the task, the surrounding docs or
55
+ * its own default: an English order came back in Russian, and artifacts of one project ended up in
56
+ * two languages. English is the default because the package is read by strangers.
57
+ */
58
+ const STRING_KEYS = ['answerLanguage'];
59
+ const KEYS = [...SWITCH_KEYS, ...LIST_KEYS, ...OBJECT_KEYS, ...STRING_KEYS, BUDGET_KEY, RETENTION_KEY];
60
+
61
+ function readRetention(file, value) {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
63
+ throw new Error(
64
+ `${file}: key “${RETENTION_KEY}” must be an object with enabled and days, not ${JSON.stringify(value)}`,
65
+ );
66
+ }
67
+ for (const key of Object.keys(value)) {
68
+ if (!RETENTION_FIELDS.includes(key)) {
69
+ throw new Error(
70
+ `${file}: key “${RETENTION_KEY}” has unknown field “${key}”. ` +
71
+ `Only ${RETENTION_FIELDS.join(', ')} are allowed`,
72
+ );
73
+ }
74
+ }
75
+ if (typeof value.enabled !== 'boolean') {
76
+ throw new Error(
77
+ `${file}: key “${RETENTION_KEY}.enabled” must be true or false, not ${JSON.stringify(value.enabled)}`,
78
+ );
79
+ }
80
+ // Plan_17 step 4 does not read days when disabled, so stale values cannot revive deletion after
81
+ // the step 3 incident where a copied default silently turned cleanup back on.
82
+ if (!value.enabled) return { enabled: false };
83
+ if (typeof value.days !== 'number' || !Number.isFinite(value.days) || value.days <= 0) {
84
+ throw new Error(
85
+ `${file}: key “${RETENTION_KEY}.days” must be a positive number of days, not ${JSON.stringify(value.days)}`,
86
+ );
87
+ }
88
+ return { enabled: true, days: value.days };
89
+ }
90
+
91
+ /**
92
+ * A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
93
+ * on 2026-08-03 one order restarted six times and spent 170,293 accounted tokens while four
94
+ * killed callers left their Codex processes and token spend unrecorded.
95
+ */
96
+ function readBudgets(file, value) {
97
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
98
+ throw new Error(
99
+ `${file}: key “${BUDGET_KEY}” must be an object keyed by ${ROLES.join(', ')}, ` +
100
+ `each holding a positive number of minutes, not ${JSON.stringify(value)}`,
101
+ );
102
+ }
103
+ const budgets = { ...DEFAULTS.budgets };
104
+ for (const [role, minutes] of Object.entries(value)) {
105
+ if (!ROLES.includes(role)) {
106
+ throw new Error(
107
+ `${file}: key “${BUDGET_KEY}” has unknown role “${role}”. ` +
108
+ `Only ${ROLES.join(', ')} are allowed`,
109
+ );
110
+ }
111
+ if (typeof minutes === 'string' && !minutes.trim()) {
112
+ throw new Error(
113
+ `${file}: key “${BUDGET_KEY}.${role}” is empty; remove the field to use the default ` +
114
+ `(${DEFAULTS.budgets[role]} minutes), or give it a positive number of minutes`,
115
+ );
116
+ }
117
+ if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) {
118
+ throw new Error(
119
+ `${file}: key “${BUDGET_KEY}.${role}” must be a positive number of minutes, ` +
120
+ `not ${JSON.stringify(minutes)}`,
121
+ );
122
+ }
123
+ budgets[role] = minutes;
124
+ }
125
+ return budgets;
126
+ }
127
+
128
+ export function validateRunConfig(file, parsed) {
129
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
130
+ throw new Error(`${file} must contain an object like {"hooks": false, "plugins": false}`);
131
+ }
132
+ const config = { ...DEFAULTS };
133
+ for (const [key, value] of Object.entries(parsed)) {
134
+ if (!KEYS.includes(key)) {
135
+ throw new Error(`${file}: unknown key “${key}”. Only ${KEYS.join(', ')} are allowed`);
136
+ }
137
+ if (STRING_KEYS.includes(key)) {
138
+ if (typeof value !== 'string' || !value.trim()) {
139
+ throw new Error(
140
+ `${file}: key “${key}” must be a non-empty string, not ${JSON.stringify(value)}. ` +
141
+ `Remove the key to keep the default (${DEFAULTS[key]}).`,
142
+ );
143
+ }
144
+ config[key] = value.trim();
145
+ continue;
146
+ }
147
+ if (key === BUDGET_KEY) {
148
+ config[key] = readBudgets(file, value);
149
+ continue;
150
+ }
151
+ if (key === RETENTION_KEY) {
152
+ config[key] = readRetention(file, value);
153
+ continue;
154
+ }
155
+ if (LIST_KEYS.includes(key)) {
156
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
157
+ throw new Error(
158
+ `${file}: key “${key}” must be a list of string patterns, not ${JSON.stringify(value)}. ` +
159
+ 'An empty list means “the environment writes nothing”; an absent key uses the default.',
160
+ );
161
+ }
162
+ config[key] = value.map((item) => item.trim()).filter(Boolean);
163
+ continue;
164
+ }
165
+ if (OBJECT_KEYS.includes(key)) {
166
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
167
+ throw new Error(
168
+ `${file}: key “${key}” must be an object keyed by ${ROLES.join(', ')}, ` +
169
+ `each holding {"model": "...", "effort": "..."}, not ${JSON.stringify(value)}`,
170
+ );
171
+ }
172
+ const models = {};
173
+ for (const [role, profile] of Object.entries(value)) {
174
+ if (!ROLES.includes(role)) {
175
+ throw new Error(
176
+ `${file}: key “${key}” has unknown role “${role}”. Only ${ROLES.join(', ')} are allowed`,
177
+ );
178
+ }
179
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
180
+ throw new Error(
181
+ `${file}: key “${key}.${role}” must be an object like ` +
182
+ `{"model": "...", "effort": "..."}, not ${JSON.stringify(profile)}`,
183
+ );
184
+ }
185
+ const resolved = {};
186
+ for (const [field, fieldValue] of Object.entries(profile)) {
187
+ if (!PROFILE_KEYS.includes(field)) {
188
+ throw new Error(
189
+ `${file}: key “${key}.${role}” has unknown field “${field}”. ` +
190
+ `Only ${PROFILE_KEYS.join(', ')} are allowed`,
191
+ );
192
+ }
193
+ if (typeof fieldValue !== 'string') {
194
+ throw new Error(
195
+ `${file}: key “${key}.${role}.${field}” must be a string, not ${JSON.stringify(fieldValue)}`,
196
+ );
197
+ }
198
+ const trimmed = fieldValue.trim();
199
+ // A field written as an empty string is a mistake, not an absence: dropping it silently
200
+ // would send the run to another profile's depth past every check below.
201
+ if (!trimmed) {
202
+ throw new Error(
203
+ `${file}: key “${key}.${role}.${field}” is empty; remove the field to fall back, ` +
204
+ 'or give it a value',
205
+ );
206
+ }
207
+ resolved[field] = trimmed;
208
+ }
209
+ for (const field of ['effort', 'speed']) {
210
+ if (resolved[field] && /\s/.test(resolved[field])) {
211
+ throw new Error(
212
+ `${file}: key “${key}.${role}.${field}” must be a non-empty single word with no whitespace`,
213
+ );
214
+ }
215
+ }
216
+ if (Object.keys(resolved).length) models[role] = resolved;
217
+ }
218
+ config[key] = models;
219
+ continue;
220
+ }
221
+ if (typeof value !== 'boolean') {
222
+ throw new Error(`${file}: key “${key}” must be true or false, not ${JSON.stringify(value)}`);
223
+ }
224
+ config[key] = value;
225
+ }
226
+ return config;
227
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Plan_56 D44: three concurrent model-set processes lost an edit after every byte comparison
3
+ * passed. Reuse the registry's Windows-hardened lock so both writers share one exclusion contract.
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ function waitForLock(delayMs) {
9
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
10
+ }
11
+
12
+ /**
13
+ * A lock nobody released is worse than no lock: interrupting an install left the file behind and
14
+ * every later install and uninstall died on it until someone deleted it by hand (reproduced
15
+ * 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
16
+ * belongs to a process that is gone.
17
+ */
18
+ async function dropStaleLock(lockPath, staleMs) {
19
+ try {
20
+ const { mtimeMs } = await fs.stat(lockPath);
21
+ if (Date.now() - mtimeMs < staleMs) return false;
22
+ await fs.rm(lockPath, { force: true });
23
+ return true;
24
+ } catch {
25
+ // Vanished or unreadable — let the next open() decide rather than guess here.
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Windows answers a taken lock with three different codes, and only one of them is EEXIST. A file
32
+ * whose last handle closed while a delete was pending stays visible but refuses to be opened:
33
+ * `open(..., 'wx')` comes back EPERM, and a file another process still holds comes back EBUSY.
34
+ * Treating those as fatal is what made `tests/cli/rules-owners.test.mjs` fail twice on 2026-08-11
35
+ * with EPERM on the lock file — the suite went red over a lock that was simply busy for one more
36
+ * millisecond. All three mean the same thing to a caller waiting for a lock: not yours yet.
37
+ */
38
+ const LOCK_TAKEN_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
39
+
40
+ /** Exported so the retry contract is asserted directly; the race itself reproduces only by luck. */
41
+ export function isLockTaken(err) {
42
+ return LOCK_TAKEN_CODES.has(err?.code);
43
+ }
44
+
45
+ async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }) {
46
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
47
+ let lastTaken;
48
+ for (let attempt = 0; attempt < retries; attempt += 1) {
49
+ try {
50
+ const handle = await fs.open(lockPath, 'wx');
51
+ return { handle, lockPath };
52
+ } catch (err) {
53
+ if (!isLockTaken(err)) throw err;
54
+ lastTaken = err;
55
+ if (!(await dropStaleLock(lockPath, staleMs))) await waitForLock(delayMs);
56
+ }
57
+ }
58
+ // The code is part of the message: a timeout on EPERM points at a delete that never completed,
59
+ // a timeout on EEXIST at an owner that never released.
60
+ throw new Error(
61
+ `timed out waiting for ${description} lock: ${lockPath} (last attempt: ${lastTaken.code})`,
62
+ { cause: lastTaken },
63
+ );
64
+ }
65
+
66
+ export async function withFileLock(lockPath, action, {
67
+ retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
68
+ } = {}) {
69
+ const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description });
70
+ try {
71
+ return await action();
72
+ } finally {
73
+ await lock.handle.close().catch(() => {});
74
+ await fs.rm(lock.lockPath, { force: true }).catch(() => {});
75
+ }
76
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Renders the reply lines a dispatcher is allowed to return, one format per agent.
3
3
  *
4
- * AGENTS is the registry of the three dispatchers: for each one it names the result file
5
- * that run writes, how to tell that file is filled in, and which of the three reply
6
- * strategies below renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
4
+ * AGENTS adds reply strategies to the shared agent registry: for each one it describes how
5
+ * to tell the result file is filled in and which of the three reply strategies below
6
+ * renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
7
7
  * produced nothing has nothing agent-specific left to say.
8
8
  *
9
9
  * The lines built here ARE the reply. Agents forward this text verbatim instead of
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
+ import { AGENTS as AGENT_DEFINITIONS } from '../agents.mjs';
14
15
  import { changedPaths, line, readJson, readText } from './paths.mjs';
15
16
  import { splitRunChanges } from './environment.mjs';
16
17
  import { scoutCoverage } from './verdict.mjs';
@@ -47,17 +48,17 @@ function retentionReply(runDir) {
47
48
 
48
49
  export const AGENTS = {
49
50
  'codex-scout': {
50
- result: 'result.json',
51
+ ...AGENT_DEFINITIONS['codex-scout'],
51
52
  filled: (r) => Boolean(String(r?.answer || '').trim()),
52
53
  reply: scoutReply,
53
54
  },
54
55
  'codex-build': {
55
- result: 'result.json',
56
+ ...AGENT_DEFINITIONS['codex-build'],
56
57
  filled: (r) => Boolean(String(r?.summary || '').trim()),
57
58
  reply: buildReply,
58
59
  },
59
60
  'codex-review': {
60
- result: 'review.json',
61
+ ...AGENT_DEFINITIONS['codex-review'],
61
62
  filled: (r) => Boolean(String(r?.verdict || '').trim()),
62
63
  reply: reviewReply,
63
64
  },
@@ -11,7 +11,7 @@
11
11
  * --effort reasoning effort, passed to Codex as given (default: medium)
12
12
  * --verify verification command, codex-build only (e.g. "npm test")
13
13
  * --scope comma-separated globs the run may edit, codex-build only (required there)
14
- * --mode uncommitted | base:<branch> | commit:<sha>, codex-review only
14
+ * --changeset uncommitted | base:<branch> | commit:<sha>, codex-review only
15
15
  * --continue acknowledge that this repo+slug already has runs (required if it does)
16
16
  *
17
17
  * The operator's task text arrives verbatim through stdin or --task-file.