@kbediako/codex-orchestrator 0.1.35 → 0.1.37

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 (43) hide show
  1. package/README.md +48 -25
  2. package/codex.orchestrator.json +39 -0
  3. package/dist/bin/codex-orchestrator.js +257 -29
  4. package/dist/orchestrator/src/cli/codexDefaultsSetup.js +274 -0
  5. package/dist/orchestrator/src/cli/config/userConfig.js +17 -1
  6. package/dist/orchestrator/src/cli/doctor.js +132 -1
  7. package/dist/orchestrator/src/cli/doctorIssueLog.js +42 -16
  8. package/dist/orchestrator/src/cli/frontendTestingRunner.js +24 -6
  9. package/dist/orchestrator/src/cli/orchestrator.js +119 -16
  10. package/dist/orchestrator/src/cli/rlmRunner.js +27 -3
  11. package/dist/orchestrator/src/cli/run/manifest.js +19 -0
  12. package/dist/orchestrator/src/cli/runtime/codexCommand.js +39 -0
  13. package/dist/orchestrator/src/cli/runtime/index.js +3 -0
  14. package/dist/orchestrator/src/cli/runtime/mode.js +53 -0
  15. package/dist/orchestrator/src/cli/runtime/provider.js +205 -0
  16. package/dist/orchestrator/src/cli/runtime/types.js +1 -0
  17. package/dist/orchestrator/src/cli/services/commandRunner.js +19 -5
  18. package/dist/orchestrator/src/cli/services/runPreparation.js +2 -0
  19. package/dist/orchestrator/src/cli/services/runSummaryWriter.js +12 -0
  20. package/dist/scripts/lib/pr-watch-merge.js +170 -9
  21. package/dist/scripts/run-review.js +2029 -0
  22. package/docs/README.md +12 -10
  23. package/package.json +4 -1
  24. package/schemas/manifest.json +20 -0
  25. package/skills/agent-first-adoption-steering/SKILL.md +116 -0
  26. package/skills/chrome-devtools/SKILL.md +6 -0
  27. package/skills/collab-deliberation/SKILL.md +6 -0
  28. package/skills/collab-evals/SKILL.md +15 -0
  29. package/skills/collab-subagents-first/SKILL.md +7 -1
  30. package/skills/delegate-early/SKILL.md +6 -0
  31. package/skills/delegation-usage/DELEGATION_GUIDE.md +7 -4
  32. package/skills/delegation-usage/SKILL.md +14 -4
  33. package/skills/docs-first/SKILL.md +6 -0
  34. package/skills/elegance-review/SKILL.md +4 -0
  35. package/skills/long-poll-wait/SKILL.md +82 -0
  36. package/skills/release/SKILL.md +6 -2
  37. package/skills/standalone-review/SKILL.md +9 -3
  38. package/templates/README.md +5 -0
  39. package/templates/codex/.codex/agents/awaiter-high.toml +38 -0
  40. package/templates/codex/.codex/agents/explorer-fast.toml +2 -0
  41. package/templates/codex/.codex/agents/worker-complex.toml +2 -0
  42. package/templates/codex/.codex/config.toml +19 -0
  43. package/templates/codex/AGENTS.md +10 -4
@@ -0,0 +1,274 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { join } from 'node:path';
5
+ import process from 'node:process';
6
+ import { resolveCodexHome } from './utils/codexPaths.js';
7
+ import { findPackageRoot } from './utils/packageInfo.js';
8
+ import { writeAtomicFile } from '../utils/atomicWrite.js';
9
+ const require = createRequire(import.meta.url);
10
+ const toml = require('@iarna/toml');
11
+ const canonicalize = require('canonicalize');
12
+ export const BASELINE_MODEL = 'gpt-5.3-codex';
13
+ export const BASELINE_REASONING = 'xhigh';
14
+ export const BASELINE_REASONING_MINIMUM = 'high';
15
+ export const BASELINE_AGENTS = {
16
+ max_threads: 12,
17
+ max_depth: 4,
18
+ max_spawn_depth: 4
19
+ };
20
+ const ROLE_DEFINITIONS = [
21
+ {
22
+ key: 'explorer_fast',
23
+ description: 'Fast explorer (spark text-only).',
24
+ fileName: 'explorer-fast.toml',
25
+ configFile: './agents/explorer-fast.toml',
26
+ templatePath: join('templates', 'codex', '.codex', 'agents', 'explorer-fast.toml')
27
+ },
28
+ {
29
+ key: 'worker_complex',
30
+ description: 'Complex implementation role.',
31
+ fileName: 'worker-complex.toml',
32
+ configFile: './agents/worker-complex.toml',
33
+ templatePath: join('templates', 'codex', '.codex', 'agents', 'worker-complex.toml')
34
+ },
35
+ {
36
+ key: 'awaiter',
37
+ description: 'Awaiter override (keeps awaiter behavior with latest codex/high reasoning).',
38
+ fileName: 'awaiter-high.toml',
39
+ configFile: './agents/awaiter-high.toml',
40
+ templatePath: join('templates', 'codex', '.codex', 'agents', 'awaiter-high.toml')
41
+ }
42
+ ];
43
+ export async function runCodexDefaultsSetup(options = {}) {
44
+ const env = options.env ?? process.env;
45
+ const force = Boolean(options.force);
46
+ const apply = Boolean(options.apply);
47
+ const plan = buildPlan(env, force);
48
+ const roleDefinitions = await loadRoleDefinitions();
49
+ const configState = await loadConfigState(plan.configPath);
50
+ const nextConfig = mergeBaselineDefaults(configState.parsed, roleDefinitions);
51
+ const configChanged = canonicalize(configState.parsed) !== canonicalize(nextConfig);
52
+ const roleChanges = await planRoleChanges(plan.agentsDir, force, roleDefinitions);
53
+ if (!apply) {
54
+ const changes = buildPlannedChanges({
55
+ configPath: plan.configPath,
56
+ configExists: configState.exists,
57
+ configChanged,
58
+ roleChanges
59
+ });
60
+ return {
61
+ status: 'planned',
62
+ plan,
63
+ changes
64
+ };
65
+ }
66
+ const changes = [];
67
+ if (configChanged || !configState.exists) {
68
+ const rendered = `${toml.stringify(nextConfig)}\n`;
69
+ await writeAtomicFile(plan.configPath, rendered, { ensureDir: true, encoding: 'utf8' });
70
+ changes.push({
71
+ target: 'config',
72
+ name: 'config.toml',
73
+ path: plan.configPath,
74
+ status: configState.exists ? 'updated' : 'created',
75
+ detail: configState.exists
76
+ ? 'Updated CO baseline defaults additively and preserved unrelated keys.'
77
+ : 'Created config.toml with CO baseline defaults.'
78
+ });
79
+ }
80
+ else {
81
+ changes.push({
82
+ target: 'config',
83
+ name: 'config.toml',
84
+ path: plan.configPath,
85
+ status: 'unchanged',
86
+ detail: 'CO baseline defaults already present.'
87
+ });
88
+ }
89
+ await mkdir(plan.agentsDir, { recursive: true });
90
+ for (const roleChange of roleChanges) {
91
+ const shouldWrite = roleChange.existingContent === null
92
+ || (force && roleChange.existingContent !== roleChange.definition.content);
93
+ if (shouldWrite) {
94
+ await writeAtomicFile(roleChange.path, roleChange.definition.content, {
95
+ ensureDir: true,
96
+ encoding: 'utf8'
97
+ });
98
+ changes.push({
99
+ target: 'role_file',
100
+ name: roleChange.definition.key,
101
+ path: roleChange.path,
102
+ status: roleChange.existingContent === null ? 'created' : 'updated',
103
+ detail: roleChange.existingContent === null
104
+ ? `Created ${roleChange.definition.fileName}.`
105
+ : `Overwrote ${roleChange.definition.fileName} because --force was set.`
106
+ });
107
+ continue;
108
+ }
109
+ changes.push({
110
+ target: 'role_file',
111
+ name: roleChange.definition.key,
112
+ path: roleChange.path,
113
+ status: roleChange.currentStatus,
114
+ detail: roleChange.detail
115
+ });
116
+ }
117
+ return {
118
+ status: 'applied',
119
+ plan,
120
+ changes
121
+ };
122
+ }
123
+ export function formatCodexDefaultsSetupSummary(result) {
124
+ const lines = [];
125
+ lines.push(`Codex defaults setup: ${result.status}`);
126
+ lines.push(`- Codex home: ${result.plan.codexHome}`);
127
+ lines.push(`- Config: ${result.plan.configPath}`);
128
+ lines.push(`- Agents dir: ${result.plan.agentsDir}`);
129
+ lines.push(`- Force overwrite: ${result.plan.force ? 'yes' : 'no'}`);
130
+ lines.push('- Changes:');
131
+ for (const change of result.changes) {
132
+ lines.push(` - ${change.target}:${change.name} -> ${change.status} (${change.path})`);
133
+ lines.push(` ${change.detail}`);
134
+ }
135
+ if (result.status === 'planned') {
136
+ lines.push('Run with --yes to apply this setup.');
137
+ }
138
+ return lines;
139
+ }
140
+ function buildPlan(env, force) {
141
+ const codexHome = resolveCodexHome(env);
142
+ return {
143
+ codexHome,
144
+ configPath: join(codexHome, 'config.toml'),
145
+ agentsDir: join(codexHome, 'agents'),
146
+ force
147
+ };
148
+ }
149
+ async function loadConfigState(configPath) {
150
+ if (!existsSync(configPath)) {
151
+ return { exists: false, parsed: {} };
152
+ }
153
+ const raw = await readFile(configPath, 'utf8');
154
+ try {
155
+ const parsed = toml.parse(raw);
156
+ if (!isRecord(parsed)) {
157
+ throw new Error('top-level TOML document must be a table.');
158
+ }
159
+ return { exists: true, parsed: parsed };
160
+ }
161
+ catch (error) {
162
+ const reason = error?.message ?? String(error);
163
+ throw new Error(`Failed to parse Codex config TOML at ${configPath}: ${reason}`);
164
+ }
165
+ }
166
+ function mergeBaselineDefaults(existing, roleDefinitions) {
167
+ const next = structuredClone(existing);
168
+ next.model = BASELINE_MODEL;
169
+ next.model_reasoning_effort = BASELINE_REASONING;
170
+ const agents = isRecord(next.agents) ? structuredClone(next.agents) : {};
171
+ agents.max_threads = BASELINE_AGENTS.max_threads;
172
+ agents.max_depth = BASELINE_AGENTS.max_depth;
173
+ agents.max_spawn_depth = BASELINE_AGENTS.max_spawn_depth;
174
+ for (const role of roleDefinitions) {
175
+ const existingRole = isRecord(agents[role.key])
176
+ ? structuredClone(agents[role.key])
177
+ : {};
178
+ existingRole.description = role.description;
179
+ existingRole.config_file = role.configFile;
180
+ agents[role.key] = existingRole;
181
+ }
182
+ next.agents = agents;
183
+ return next;
184
+ }
185
+ async function planRoleChanges(agentsDir, force, roleDefinitions) {
186
+ const changes = [];
187
+ for (const definition of roleDefinitions) {
188
+ const path = join(agentsDir, definition.fileName);
189
+ if (!existsSync(path)) {
190
+ changes.push({
191
+ definition,
192
+ path,
193
+ existingContent: null,
194
+ currentStatus: 'pending',
195
+ detail: `Will create ${definition.fileName}.`
196
+ });
197
+ continue;
198
+ }
199
+ const current = await readFile(path, 'utf8');
200
+ if (current === definition.content) {
201
+ changes.push({
202
+ definition,
203
+ path,
204
+ existingContent: current,
205
+ currentStatus: 'unchanged',
206
+ detail: `${definition.fileName} already matches CO baseline defaults.`
207
+ });
208
+ continue;
209
+ }
210
+ if (force) {
211
+ changes.push({
212
+ definition,
213
+ path,
214
+ existingContent: current,
215
+ currentStatus: 'pending',
216
+ detail: `Will overwrite ${definition.fileName} because --force is set.`
217
+ });
218
+ continue;
219
+ }
220
+ changes.push({
221
+ definition,
222
+ path,
223
+ existingContent: current,
224
+ currentStatus: 'preserved',
225
+ detail: `${definition.fileName} already exists; preserving without --force.`
226
+ });
227
+ }
228
+ return changes;
229
+ }
230
+ async function loadRoleDefinitions() {
231
+ const packageRoot = findPackageRoot();
232
+ const loaded = [];
233
+ for (const definition of ROLE_DEFINITIONS) {
234
+ const templateFile = join(packageRoot, definition.templatePath);
235
+ let content;
236
+ try {
237
+ content = await readFile(templateFile, 'utf8');
238
+ }
239
+ catch (error) {
240
+ const reason = error?.message ?? String(error);
241
+ throw new Error(`Unable to read role template ${templateFile}: ${reason}`);
242
+ }
243
+ loaded.push({ ...definition, content });
244
+ }
245
+ return loaded;
246
+ }
247
+ function buildPlannedChanges(params) {
248
+ const changes = [];
249
+ const configStatus = params.configChanged || !params.configExists ? 'pending' : 'unchanged';
250
+ changes.push({
251
+ target: 'config',
252
+ name: 'config.toml',
253
+ path: params.configPath,
254
+ status: configStatus,
255
+ detail: configStatus === 'pending'
256
+ ? params.configExists
257
+ ? 'Will update CO baseline defaults additively while preserving unrelated keys.'
258
+ : 'Will create config.toml with CO baseline defaults.'
259
+ : 'CO baseline defaults already present.'
260
+ });
261
+ for (const roleChange of params.roleChanges) {
262
+ changes.push({
263
+ target: 'role_file',
264
+ name: roleChange.definition.key,
265
+ path: roleChange.path,
266
+ status: roleChange.currentStatus,
267
+ detail: roleChange.detail
268
+ });
269
+ }
270
+ return changes;
271
+ }
272
+ function isRecord(value) {
273
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
274
+ }
@@ -55,11 +55,17 @@ function normalizeUserConfig(config, source) {
55
55
  if (!config) {
56
56
  return null;
57
57
  }
58
+ const runtimeMode = normalizeRuntimeMode(config.runtimeMode);
58
59
  const stageSets = normalizeStageSets(config.stageSets);
59
60
  const pipelines = Array.isArray(config.pipelines)
60
61
  ? config.pipelines.map((pipeline) => expandPipelineStages(pipeline, stageSets))
61
62
  : config.pipelines;
62
- return { pipelines, defaultPipeline: config.defaultPipeline, source };
63
+ return {
64
+ pipelines,
65
+ defaultPipeline: config.defaultPipeline,
66
+ runtimeMode,
67
+ source
68
+ };
63
69
  }
64
70
  async function readConfig(configPath) {
65
71
  try {
@@ -111,3 +117,13 @@ function expandPipelineStages(pipeline, stageSets) {
111
117
  function isStageSetRef(stage) {
112
118
  return stage.kind === 'stage-set';
113
119
  }
120
+ function normalizeRuntimeMode(value) {
121
+ if (typeof value !== 'string') {
122
+ return undefined;
123
+ }
124
+ const normalized = value.trim().toLowerCase();
125
+ if (normalized === 'cli' || normalized === 'appserver') {
126
+ return normalized;
127
+ }
128
+ throw new Error(`Invalid codex.orchestrator.json runtimeMode "${value}". Expected one of: cli, appserver.`);
129
+ }
@@ -1,15 +1,19 @@
1
1
  import process from 'node:process';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { buildDevtoolsSetupPlan, DEVTOOLS_SKILL_NAME, resolveDevtoolsReadiness } from './utils/devtools.js';
6
7
  import { isManagedCodexCliEnabled, resolveCodexCliBin, resolveCodexCliReadiness } from './utils/codexCli.js';
7
8
  import { resolveCodexHome } from './utils/codexPaths.js';
8
9
  import { resolveOptionalDependency } from './utils/optionalDeps.js';
9
10
  import { runCloudPreflight } from './utils/cloudPreflight.js';
11
+ import { BASELINE_AGENTS, BASELINE_MODEL, BASELINE_REASONING_MINIMUM } from './codexDefaultsSetup.js';
10
12
  import { CommandPlanner } from './adapters/CommandPlanner.js';
11
13
  import { PipelineResolver } from './services/pipelineResolver.js';
12
14
  import { isRepoConfigRequired } from './config/repoConfigPolicy.js';
15
+ const require = createRequire(import.meta.url);
16
+ const toml = require('@iarna/toml');
13
17
  const OPTIONAL_DEPENDENCIES = [
14
18
  {
15
19
  name: 'playwright',
@@ -75,6 +79,7 @@ export function runDoctor(cwd = process.cwd()) {
75
79
  if (readiness.config.status !== 'ok') {
76
80
  missing.push(`${DEVTOOLS_SKILL_NAME}-config`);
77
81
  }
82
+ const codexDefaults = inspectCodexDefaultsAdvisory(process.env);
78
83
  const codexBin = resolveCodexCliBin(process.env);
79
84
  const managedOptIn = isManagedCodexCliEnabled(process.env);
80
85
  const managedCodex = resolveCodexCliReadiness(process.env);
@@ -102,7 +107,7 @@ export function runDoctor(cwd = process.cwd()) {
102
107
  const delegationConfig = inspectDelegationConfig();
103
108
  const delegationStatus = delegationConfig.status === 'ok' ? 'ok' : 'missing-config';
104
109
  return {
105
- status: missing.length === 0 ? 'ok' : 'warning',
110
+ status: missing.length === 0 && codexDefaults.status === 'ok' ? 'ok' : 'warning',
106
111
  missing,
107
112
  dependencies,
108
113
  devtools,
@@ -110,6 +115,7 @@ export function runDoctor(cwd = process.cwd()) {
110
115
  active: { command: codexBin, managed_opt_in: managedOptIn },
111
116
  managed: managedCodex
112
117
  },
118
+ codex_defaults: codexDefaults,
113
119
  collab: {
114
120
  status: collabStatus,
115
121
  enabled: collabEnabled,
@@ -296,6 +302,19 @@ export function formatDoctorSummary(result) {
296
302
  lines.push(` - version: ${result.codex_cli.managed.install.version}`);
297
303
  }
298
304
  }
305
+ lines.push(`Codex defaults advisory: ${result.codex_defaults.status}`);
306
+ lines.push(` - config.toml: ${result.codex_defaults.config.status} (${result.codex_defaults.config.path})`);
307
+ if (result.codex_defaults.config.detail) {
308
+ lines.push(` detail: ${result.codex_defaults.config.detail}`);
309
+ }
310
+ lines.push(` - model: ${result.codex_defaults.checks.model.status} (actual: ${result.codex_defaults.checks.model.actual ?? '<unset>'}, expected: ${result.codex_defaults.checks.model.expected})`);
311
+ lines.push(` - model_reasoning_effort: ${result.codex_defaults.checks.model_reasoning_effort.status} (actual: ${result.codex_defaults.checks.model_reasoning_effort.actual ?? '<unset>'}, expected >= ${result.codex_defaults.checks.model_reasoning_effort.expected_minimum})`);
312
+ lines.push(` - agents.max_threads: ${result.codex_defaults.checks.max_threads.status} (actual: ${result.codex_defaults.checks.max_threads.actual ?? '<unset>'}, expected >= ${result.codex_defaults.checks.max_threads.expected_minimum})`);
313
+ lines.push(` - agents.max_depth: ${result.codex_defaults.checks.max_depth.status} (actual: ${result.codex_defaults.checks.max_depth.actual ?? '<unset>'}, expected >= ${result.codex_defaults.checks.max_depth.expected_minimum})`);
314
+ lines.push(` - agents.max_spawn_depth: ${result.codex_defaults.checks.max_spawn_depth.status} (actual: ${result.codex_defaults.checks.max_spawn_depth.actual ?? '<unset>'}, expected >= ${result.codex_defaults.checks.max_spawn_depth.expected_minimum})`);
315
+ for (const line of result.codex_defaults.guidance) {
316
+ lines.push(` - ${line}`);
317
+ }
299
318
  lines.push(`Collab: ${result.collab.status}`);
300
319
  if (result.collab.enabled !== null) {
301
320
  lines.push(` - enabled: ${result.collab.enabled}`);
@@ -326,6 +345,118 @@ export function formatDoctorSummary(result) {
326
345
  }
327
346
  return lines;
328
347
  }
348
+ function inspectCodexDefaultsAdvisory(env = process.env) {
349
+ const configPath = join(resolveCodexHome(env), 'config.toml');
350
+ const checks = {
351
+ model: { status: 'advisory', expected: BASELINE_MODEL, actual: null },
352
+ model_reasoning_effort: { status: 'advisory', expected_minimum: BASELINE_REASONING_MINIMUM, actual: null },
353
+ max_threads: { status: 'advisory', expected_minimum: BASELINE_AGENTS.max_threads, actual: null },
354
+ max_depth: { status: 'advisory', expected_minimum: BASELINE_AGENTS.max_depth, actual: null },
355
+ max_spawn_depth: { status: 'advisory', expected_minimum: BASELINE_AGENTS.max_spawn_depth, actual: null }
356
+ };
357
+ const guidance = [
358
+ 'Run `codex-orchestrator codex defaults --yes` to apply additive baseline defaults.',
359
+ 'Additive policy: unrelated config keys are preserved; existing role files stay untouched unless `--force` is set.'
360
+ ];
361
+ if (!existsSync(configPath)) {
362
+ return {
363
+ status: 'advisory',
364
+ config: { path: configPath, status: 'missing', detail: 'config.toml not found' },
365
+ checks,
366
+ guidance
367
+ };
368
+ }
369
+ let parsed;
370
+ try {
371
+ const raw = readFileSync(configPath, 'utf8');
372
+ const value = toml.parse(raw);
373
+ if (!isRecord(value)) {
374
+ throw new Error('top-level TOML document must be a table.');
375
+ }
376
+ parsed = value;
377
+ }
378
+ catch (error) {
379
+ return {
380
+ status: 'advisory',
381
+ config: {
382
+ path: configPath,
383
+ status: 'invalid',
384
+ detail: error instanceof Error ? error.message : String(error)
385
+ },
386
+ checks,
387
+ guidance: [
388
+ `Fix TOML syntax in ${configPath} first, then rerun \`codex-orchestrator codex defaults --yes\`.`,
389
+ ...guidance
390
+ ]
391
+ };
392
+ }
393
+ const model = normalizeOptionalString(readStringValue(parsed.model));
394
+ checks.model.actual = model;
395
+ checks.model.status = model === BASELINE_MODEL ? 'ok' : 'advisory';
396
+ const reasoning = normalizeOptionalString(readStringValue(parsed.model_reasoning_effort));
397
+ checks.model_reasoning_effort.actual = reasoning;
398
+ checks.model_reasoning_effort.status = isReasoningAtLeastMinimum(reasoning, BASELINE_REASONING_MINIMUM)
399
+ ? 'ok'
400
+ : 'advisory';
401
+ const agents = isRecord(parsed.agents) ? parsed.agents : {};
402
+ const maxThreads = readNumberValue(agents.max_threads);
403
+ const maxDepth = readNumberValue(agents.max_depth);
404
+ const maxSpawnDepth = readNumberValue(agents.max_spawn_depth);
405
+ checks.max_threads.actual = maxThreads;
406
+ checks.max_threads.status =
407
+ typeof maxThreads === 'number' && maxThreads >= BASELINE_AGENTS.max_threads ? 'ok' : 'advisory';
408
+ checks.max_depth.actual = maxDepth;
409
+ checks.max_depth.status = typeof maxDepth === 'number' && maxDepth >= BASELINE_AGENTS.max_depth ? 'ok' : 'advisory';
410
+ checks.max_spawn_depth.actual = maxSpawnDepth;
411
+ checks.max_spawn_depth.status =
412
+ typeof maxSpawnDepth === 'number' && maxSpawnDepth >= BASELINE_AGENTS.max_spawn_depth ? 'ok' : 'advisory';
413
+ const allChecksOk = Object.values(checks).every((check) => check.status === 'ok');
414
+ return {
415
+ status: allChecksOk ? 'ok' : 'advisory',
416
+ config: { path: configPath, status: 'ok' },
417
+ checks,
418
+ guidance
419
+ };
420
+ }
421
+ function isRecord(value) {
422
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
423
+ }
424
+ function readStringValue(value) {
425
+ return typeof value === 'string' ? value : null;
426
+ }
427
+ function readNumberValue(value) {
428
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
429
+ }
430
+ function isReasoningAtLeastMinimum(value, minimum) {
431
+ const rank = resolveReasoningRank(value);
432
+ const minimumRank = resolveReasoningRank(minimum);
433
+ if (rank === null || minimumRank === null) {
434
+ return false;
435
+ }
436
+ return rank >= minimumRank;
437
+ }
438
+ function resolveReasoningRank(value) {
439
+ if (!value) {
440
+ return null;
441
+ }
442
+ const normalized = value.trim().toLowerCase();
443
+ switch (normalized) {
444
+ case 'minimal':
445
+ return 0;
446
+ case 'low':
447
+ return 1;
448
+ case 'medium':
449
+ return 2;
450
+ case 'high':
451
+ return 3;
452
+ case 'xhigh':
453
+ return 4;
454
+ case 'maximum':
455
+ return 5;
456
+ default:
457
+ return null;
458
+ }
459
+ }
329
460
  function normalizeOptionalString(value) {
330
461
  if (typeof value !== 'string') {
331
462
  return null;
@@ -10,9 +10,10 @@ Purpose:
10
10
  export async function writeDoctorIssueLog(options) {
11
11
  const cwd = resolve(options.cwd ?? process.cwd());
12
12
  const env = options.env ?? process.env;
13
- const repoRoot = resolveIssueLogRepoRoot(cwd, env);
14
- const runsRoot = resolveIssueLogRootPath(repoRoot, env.CODEX_ORCHESTRATOR_RUNS_DIR, '.runs');
15
- const outRoot = resolveIssueLogRootPath(repoRoot, env.CODEX_ORCHESTRATOR_OUT_DIR, 'out');
13
+ const explicitCwd = options.cwd !== undefined;
14
+ const repoRoot = resolveIssueLogRepoRoot(cwd, env, explicitCwd);
15
+ const runsRoot = resolveIssueLogRootPath(repoRoot, env.CODEX_ORCHESTRATOR_RUNS_DIR, '.runs', explicitCwd);
16
+ const outRoot = resolveIssueLogRootPath(repoRoot, env.CODEX_ORCHESTRATOR_OUT_DIR, 'out', explicitCwd);
16
17
  const defaultTaskId = normalizeIssueLogTaskId(env);
17
18
  const capturedAt = new Date().toISOString();
18
19
  const issueId = formatIssueId(capturedAt);
@@ -95,14 +96,31 @@ function resolveIssueLogPath(repoRoot, rawPath) {
95
96
  }
96
97
  return resolve(repoRoot, normalized);
97
98
  }
98
- function resolveIssueLogRepoRoot(cwd, env) {
99
+ function resolveIssueLogRepoRoot(cwd, env, explicitCwd) {
100
+ const cwdResolution = resolveRepoRootFromHint(cwd);
99
101
  const configuredRoot = normalizeText(env.CODEX_ORCHESTRATOR_ROOT);
100
- const rootHint = configuredRoot === null
101
- ? cwd
102
- : isAbsolute(configuredRoot)
103
- ? configuredRoot
104
- : resolve(cwd, configuredRoot);
105
- return resolveRepoRootFromHint(rootHint);
102
+ if (configuredRoot === null) {
103
+ return cwdResolution.root;
104
+ }
105
+ const configuredHint = isAbsolute(configuredRoot) ? configuredRoot : resolve(cwd, configuredRoot);
106
+ const configuredResolution = resolveRepoRootFromHint(configuredHint);
107
+ if (!explicitCwd) {
108
+ return configuredResolution.root;
109
+ }
110
+ // Keep explicit override behavior when cwd is inside the configured workspace.
111
+ if (isPathWithin(cwd, configuredResolution.root)) {
112
+ // If cwd sits under a nested workspace boundary, prefer that more local workspace.
113
+ if (cwdResolution.source !== 'hint' && cwdResolution.root !== configuredResolution.root) {
114
+ return cwdResolution.root;
115
+ }
116
+ return configuredResolution.root;
117
+ }
118
+ // If cwd clearly belongs to another workspace (tasks index or git boundary), prefer cwd.
119
+ if (cwdResolution.source !== 'hint') {
120
+ return cwdResolution.root;
121
+ }
122
+ // Fallback to configured root when cwd has no strong workspace signals.
123
+ return configuredResolution.root;
106
124
  }
107
125
  function resolveRepoRootFromHint(rootHint) {
108
126
  const normalizedHint = resolve(rootHint);
@@ -110,7 +128,7 @@ function resolveRepoRootFromHint(rootHint) {
110
128
  let current = normalizedHint;
111
129
  while (current) {
112
130
  if (existsSync(join(current, 'tasks', 'index.json'))) {
113
- return current;
131
+ return { root: current, source: 'tasks-index' };
114
132
  }
115
133
  if (gitBoundary && current === gitBoundary) {
116
134
  break;
@@ -121,7 +139,10 @@ function resolveRepoRootFromHint(rootHint) {
121
139
  }
122
140
  current = parent;
123
141
  }
124
- return gitBoundary ?? normalizedHint;
142
+ if (gitBoundary) {
143
+ return { root: gitBoundary, source: 'git-boundary' };
144
+ }
145
+ return { root: normalizedHint, source: 'hint' };
125
146
  }
126
147
  function findNearestGitBoundary(start) {
127
148
  let current = resolve(start);
@@ -137,15 +158,20 @@ function findNearestGitBoundary(start) {
137
158
  }
138
159
  return null;
139
160
  }
140
- function resolveIssueLogRootPath(repoRoot, configuredPath, fallback) {
161
+ function resolveIssueLogRootPath(repoRoot, configuredPath, fallback, enforceRepoBoundary) {
141
162
  const normalized = normalizeText(configuredPath);
142
163
  if (!normalized) {
143
164
  return resolve(repoRoot, fallback);
144
165
  }
145
- if (isAbsolute(normalized)) {
146
- return normalized;
166
+ const resolvedPath = isAbsolute(normalized) ? normalized : resolve(repoRoot, normalized);
167
+ if (enforceRepoBoundary && !isPathWithin(resolvedPath, repoRoot)) {
168
+ return resolve(repoRoot, fallback);
147
169
  }
148
- return resolve(repoRoot, normalized);
170
+ return resolvedPath;
171
+ }
172
+ function isPathWithin(candidatePath, rootPath) {
173
+ const rel = relative(rootPath, candidatePath);
174
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
149
175
  }
150
176
  function normalizeIssueLogTaskId(env) {
151
177
  return normalizeArtifactTaskId(normalizeText(env.MCP_RUNNER_TASK_ID)
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { logger } from '../logger.js';
7
- import { resolveCodexCommand } from './utils/devtools.js';
7
+ import { createRuntimeCodexCommandContext, formatRuntimeSelectionSummary, parseRuntimeMode, resolveRuntimeCodexCommand } from './runtime/index.js';
8
8
  const DEFAULT_PROMPT = [
9
9
  'You are running frontend testing for the current project.',
10
10
  '',
@@ -37,9 +37,9 @@ export async function loadFrontendTestingPrompt(env = process.env) {
37
37
  }
38
38
  return DEFAULT_PROMPT;
39
39
  }
40
- export function resolveFrontendTestingCommand(prompt, env = process.env) {
40
+ export function resolveFrontendTestingCommand(prompt, context) {
41
41
  const args = ['exec', prompt];
42
- return resolveCodexCommand(args, env);
42
+ return resolveRuntimeCodexCommand(args, context);
43
43
  }
44
44
  function envFlagEnabled(value) {
45
45
  if (!value) {
@@ -59,16 +59,21 @@ function shouldForceNonInteractive(env) {
59
59
  }
60
60
  export async function runFrontendTesting(env = process.env) {
61
61
  const prompt = await loadFrontendTestingPrompt(env);
62
- const { command, args } = resolveFrontendTestingCommand(prompt, env);
62
+ const repoRoot = typeof env.CODEX_ORCHESTRATOR_ROOT === 'string' && env.CODEX_ORCHESTRATOR_ROOT.trim().length > 0
63
+ ? env.CODEX_ORCHESTRATOR_ROOT.trim()
64
+ : process.cwd();
65
+ const runtimeContext = await resolveFrontendTestingRuntimeContext(env, repoRoot);
66
+ logger.info(`[frontend-testing-runtime] ${formatRuntimeSelectionSummary(runtimeContext.runtime)}`);
67
+ const { command, args } = resolveFrontendTestingCommand(prompt, runtimeContext);
63
68
  const nonInteractive = shouldForceNonInteractive(env);
64
- const childEnv = { ...process.env, ...env };
69
+ const childEnv = { ...process.env, ...env, ...runtimeContext.env };
65
70
  if (nonInteractive) {
66
71
  childEnv.CODEX_NON_INTERACTIVE = childEnv.CODEX_NON_INTERACTIVE ?? '1';
67
72
  childEnv.CODEX_NO_INTERACTIVE = childEnv.CODEX_NO_INTERACTIVE ?? '1';
68
73
  childEnv.CODEX_INTERACTIVE = childEnv.CODEX_INTERACTIVE ?? '0';
69
74
  }
70
75
  const stdio = nonInteractive ? ['ignore', 'inherit', 'inherit'] : 'inherit';
71
- const child = spawn(command, args, { stdio, env: childEnv });
76
+ const child = spawn(command, args, { stdio, env: childEnv, cwd: repoRoot });
72
77
  await new Promise((resolvePromise, reject) => {
73
78
  child.once('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
74
79
  child.once('exit', (code) => {
@@ -81,6 +86,19 @@ export async function runFrontendTesting(env = process.env) {
81
86
  });
82
87
  });
83
88
  }
89
+ async function resolveFrontendTestingRuntimeContext(env, repoRoot) {
90
+ const requestedMode = parseRuntimeMode(env.CODEX_ORCHESTRATOR_RUNTIME_MODE_ACTIVE ?? env.CODEX_ORCHESTRATOR_RUNTIME_MODE ?? null);
91
+ const runId = typeof env.CODEX_ORCHESTRATOR_RUN_ID === 'string' && env.CODEX_ORCHESTRATOR_RUN_ID.trim().length > 0
92
+ ? env.CODEX_ORCHESTRATOR_RUN_ID.trim()
93
+ : `frontend-testing-${Date.now()}`;
94
+ return await createRuntimeCodexCommandContext({
95
+ requestedMode,
96
+ executionMode: 'mcp',
97
+ repoRoot,
98
+ env: { ...process.env, ...env },
99
+ runId
100
+ });
101
+ }
84
102
  async function main() {
85
103
  await runFrontendTesting();
86
104
  }