@kbediako/codex-orchestrator 0.1.2 → 0.1.3

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 (55) hide show
  1. package/README.md +9 -7
  2. package/dist/bin/codex-orchestrator.js +214 -121
  3. package/dist/orchestrator/src/cli/config/userConfig.js +86 -12
  4. package/dist/orchestrator/src/cli/exec/context.js +5 -2
  5. package/dist/orchestrator/src/cli/exec/learning.js +5 -3
  6. package/dist/orchestrator/src/cli/exec/stageRunner.js +1 -1
  7. package/dist/orchestrator/src/cli/exec/summary.js +1 -1
  8. package/dist/orchestrator/src/cli/orchestrator.js +16 -7
  9. package/dist/orchestrator/src/cli/pipelines/index.js +13 -24
  10. package/dist/orchestrator/src/cli/rlm/prompt.js +31 -0
  11. package/dist/orchestrator/src/cli/rlm/runner.js +177 -0
  12. package/dist/orchestrator/src/cli/rlm/types.js +1 -0
  13. package/dist/orchestrator/src/cli/rlm/validator.js +159 -0
  14. package/dist/orchestrator/src/cli/rlmRunner.js +417 -0
  15. package/dist/orchestrator/src/cli/run/environment.js +4 -11
  16. package/dist/orchestrator/src/cli/run/manifest.js +7 -1
  17. package/dist/orchestrator/src/cli/services/commandRunner.js +1 -1
  18. package/dist/orchestrator/src/cli/services/controlPlaneService.js +3 -1
  19. package/dist/orchestrator/src/cli/services/execRuntime.js +1 -2
  20. package/dist/orchestrator/src/cli/services/pipelineResolver.js +33 -2
  21. package/dist/orchestrator/src/cli/services/runPreparation.js +7 -1
  22. package/dist/orchestrator/src/cli/services/schedulerService.js +1 -1
  23. package/dist/orchestrator/src/cli/utils/specGuardRunner.js +3 -1
  24. package/dist/orchestrator/src/cli/utils/strings.js +8 -6
  25. package/dist/orchestrator/src/persistence/ExperienceStore.js +6 -16
  26. package/dist/orchestrator/src/persistence/TaskStateStore.js +1 -1
  27. package/dist/orchestrator/src/persistence/sanitizeIdentifier.js +1 -1
  28. package/dist/packages/orchestrator/src/exec/stdio.js +112 -0
  29. package/dist/packages/orchestrator/src/exec/unified-exec.js +1 -1
  30. package/dist/packages/orchestrator/src/index.js +1 -0
  31. package/dist/packages/shared/design-artifacts/writer.js +4 -14
  32. package/dist/packages/shared/streams/stdio.js +2 -112
  33. package/dist/packages/shared/utils/strings.js +17 -0
  34. package/dist/scripts/design/pipeline/advanced-assets.js +1 -1
  35. package/dist/scripts/design/pipeline/context.js +5 -5
  36. package/dist/scripts/design/pipeline/extract.js +9 -6
  37. package/dist/scripts/design/pipeline/{optionalDeps.js → optional-deps.js} +49 -38
  38. package/dist/scripts/design/pipeline/permit.js +59 -0
  39. package/dist/scripts/design/pipeline/toolkit/common.js +18 -32
  40. package/dist/scripts/design/pipeline/toolkit/reference.js +1 -1
  41. package/dist/scripts/design/pipeline/toolkit/snapshot.js +1 -1
  42. package/dist/scripts/design/pipeline/visual-regression.js +2 -11
  43. package/dist/scripts/lib/cli-args.js +53 -0
  44. package/dist/scripts/lib/docs-helpers.js +111 -0
  45. package/dist/scripts/lib/npm-pack.js +20 -0
  46. package/dist/scripts/lib/run-manifests.js +160 -0
  47. package/package.json +5 -2
  48. package/dist/orchestrator/src/cli/pipelines/defaultDiagnostics.js +0 -32
  49. package/dist/orchestrator/src/cli/pipelines/designReference.js +0 -72
  50. package/dist/orchestrator/src/cli/pipelines/hiFiDesignToolkit.js +0 -71
  51. package/dist/orchestrator/src/cli/utils/jsonlWriter.js +0 -10
  52. package/dist/orchestrator/src/control-plane/index.js +0 -3
  53. package/dist/orchestrator/src/persistence/identifierGuards.js +0 -1
  54. package/dist/orchestrator/src/persistence/writeAtomicFile.js +0 -4
  55. package/dist/orchestrator/src/scheduler/index.js +0 -1
@@ -2,8 +2,11 @@ import process from 'node:process';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { TaskManager } from '../manager.js';
5
+ import { RunManifestWriter } from '../persistence/RunManifestWriter.js';
6
+ import { TaskStateStore } from '../persistence/TaskStateStore.js';
5
7
  import { CommandPlanner, CommandBuilder, CommandTester, CommandReviewer } from './adapters/index.js';
6
- import { resolveEnvironment } from './run/environment.js';
8
+ import { resolveEnvironmentPaths } from '../../../scripts/lib/run-manifests.js';
9
+ import { normalizeEnvironmentPaths } from './run/environment.js';
7
10
  import { bootstrapManifest, loadManifest, updateHeartbeat, finalizeStatus, appendSummary, ensureGuardrailStatus, resetForResume, recordResumeEvent } from './run/manifest.js';
8
11
  import { ManifestPersister, persistManifest } from './run/manifestPersister.js';
9
12
  import { generateRunId } from './utils/runId.js';
@@ -18,14 +21,15 @@ import { ControlPlaneService } from './services/controlPlaneService.js';
18
21
  import { SchedulerService } from './services/schedulerService.js';
19
22
  import { applyHandlesToRunSummary, applyPrivacyToRunSummary, persistRunSummary } from './services/runSummaryWriter.js';
20
23
  import { prepareRun, resolvePipelineForResume, overrideTaskEnvironment } from './services/runPreparation.js';
21
- import { loadUserConfig } from './config/userConfig.js';
24
+ import { loadPackageConfig, loadUserConfig } from './config/userConfig.js';
22
25
  import { RunEventPublisher, snapshotStages } from './events/runEvents.js';
23
26
  import { CLI_EXECUTION_MODE_PARSER, resolveRequiresCloudPolicy } from '../utils/executionMode.js';
27
+ const resolveBaseEnvironment = () => normalizeEnvironmentPaths(resolveEnvironmentPaths());
24
28
  export class CodexOrchestrator {
25
29
  baseEnv;
26
30
  controlPlane = new ControlPlaneService();
27
31
  scheduler = new SchedulerService();
28
- constructor(baseEnv = resolveEnvironment()) {
32
+ constructor(baseEnv = resolveBaseEnvironment()) {
29
33
  this.baseEnv = baseEnv;
30
34
  }
31
35
  async start(options = {}) {
@@ -77,7 +81,10 @@ export class CodexOrchestrator {
77
81
  const resolver = new PipelineResolver();
78
82
  const designConfig = await resolver.loadDesignConfig(actualEnv.repoRoot);
79
83
  const userConfig = await loadUserConfig(actualEnv);
80
- const pipeline = resolvePipelineForResume(actualEnv, manifest, userConfig);
84
+ const fallbackConfig = manifest.pipeline_id === 'rlm' && userConfig?.source === 'repo'
85
+ ? await loadPackageConfig(actualEnv)
86
+ : null;
87
+ const pipeline = resolvePipelineForResume(actualEnv, manifest, userConfig, fallbackConfig);
81
88
  const envOverrides = resolver.resolveDesignEnvOverrides(designConfig, pipeline.id);
82
89
  await this.validateResumeToken(paths, manifest, options.resumeToken ?? null);
83
90
  recordResumeEvent(manifest, {
@@ -196,11 +203,13 @@ export class CodexOrchestrator {
196
203
  logPath: params.paths.logPath
197
204
  });
198
205
  }
199
- createTaskManager(runId, pipeline, executePipeline, getResult, plannerInstance) {
206
+ createTaskManager(runId, pipeline, executePipeline, getResult, plannerInstance, env) {
200
207
  const planner = plannerInstance ?? new CommandPlanner(pipeline);
201
208
  const builder = new CommandBuilder(executePipeline);
202
209
  const tester = new CommandTester(getResult);
203
210
  const reviewer = new CommandReviewer(getResult);
211
+ const stateStore = new TaskStateStore({ outDir: env.outRoot, runsDir: env.runsRoot });
212
+ const manifestWriter = new RunManifestWriter({ runsDir: env.runsRoot });
204
213
  const options = {
205
214
  planner,
206
215
  builder,
@@ -208,7 +217,7 @@ export class CodexOrchestrator {
208
217
  reviewer,
209
218
  runIdFactory: () => runId,
210
219
  modePolicy: (task, subtask) => this.determineMode(task, subtask),
211
- persistence: { autoStart: true }
220
+ persistence: { autoStart: true, stateStore, manifestWriter }
212
221
  };
213
222
  return new TaskManager(options);
214
223
  }
@@ -445,7 +454,7 @@ export class CodexOrchestrator {
445
454
  return executing;
446
455
  };
447
456
  const getResult = () => pipelineResult;
448
- const manager = this.createTaskManager(runId, pipeline, executePipeline, getResult, planner);
457
+ const manager = this.createTaskManager(runId, pipeline, executePipeline, getResult, planner, env);
449
458
  this.attachPlanTargetTracker(manager, manifest, paths, persister);
450
459
  getPrivacyGuard().reset();
451
460
  const controlPlaneResult = await this.controlPlane.guard({
@@ -1,34 +1,23 @@
1
- import { defaultDiagnosticsPipeline } from './defaultDiagnostics.js';
2
- import { designReferencePipeline } from './designReference.js';
3
- import { hiFiDesignToolkitPipeline } from './hiFiDesignToolkit.js';
4
1
  import { findPipeline } from '../config/userConfig.js';
5
- const builtinPipelines = new Map([
6
- [defaultDiagnosticsPipeline.id, defaultDiagnosticsPipeline],
7
- [designReferencePipeline.id, designReferencePipeline],
8
- [hiFiDesignToolkitPipeline.id, hiFiDesignToolkitPipeline]
9
- ]);
10
- function getBuiltinPipeline(id) {
11
- if (!id) {
12
- return null;
13
- }
14
- return builtinPipelines.get(id) ?? null;
2
+ function resolveConfigSource(config) {
3
+ return config?.source === 'package' ? 'default' : 'user';
15
4
  }
16
- export function resolvePipeline(env, options) {
5
+ export function resolvePipeline(_env, options) {
17
6
  const { pipelineId, config } = options;
7
+ const configSource = resolveConfigSource(config);
18
8
  if (pipelineId) {
19
9
  const fromUser = findPipeline(config, pipelineId);
20
10
  if (fromUser) {
21
- return { pipeline: fromUser, source: 'user' };
22
- }
23
- const builtin = getBuiltinPipeline(pipelineId);
24
- if (builtin) {
25
- return { pipeline: builtin, source: 'default' };
11
+ return { pipeline: fromUser, source: configSource };
26
12
  }
27
- throw new Error(`Pipeline '${pipelineId}' not found.`);
13
+ const suffix = config ? '' : ' (missing codex.orchestrator.json)';
14
+ throw new Error(`Pipeline '${pipelineId}' not found${suffix}.`);
28
15
  }
29
- const defaultId = config?.defaultPipeline ?? defaultDiagnosticsPipeline.id;
16
+ const defaultId = config?.defaultPipeline ?? 'diagnostics';
30
17
  const userPipeline = findPipeline(config, defaultId);
31
- const chosen = userPipeline ?? getBuiltinPipeline(defaultId) ?? defaultDiagnosticsPipeline;
32
- const source = userPipeline ? 'user' : 'default';
33
- return { pipeline: chosen, source };
18
+ if (userPipeline) {
19
+ return { pipeline: userPipeline, source: configSource };
20
+ }
21
+ const suffix = config ? '' : ' (missing codex.orchestrator.json)';
22
+ throw new Error(`Pipeline '${defaultId}' not found${suffix}.`);
34
23
  }
@@ -0,0 +1,31 @@
1
+ import { basename } from 'node:path';
2
+ export function buildRlmPrompt(input) {
3
+ const repoName = basename(input.repoRoot) || 'repo';
4
+ const maxIterations = input.maxIterations === 0 ? 'unlimited' : String(input.maxIterations);
5
+ const lines = [
6
+ `You are Codex running an RLM loop in repo "${repoName}".`,
7
+ `Goal: ${input.goal}`,
8
+ `Iteration: ${input.iteration} of ${maxIterations}.`
9
+ ];
10
+ if (input.diffSummary) {
11
+ lines.push('', 'Workspace summary:', input.diffSummary.trim());
12
+ }
13
+ if (input.lastValidatorOutput) {
14
+ lines.push('', 'Last validator output:', input.lastValidatorOutput.trim());
15
+ }
16
+ if (input.validatorCommand) {
17
+ lines.push('', `Validator command (do NOT run it): ${input.validatorCommand}`);
18
+ }
19
+ else {
20
+ lines.push('', 'Validator: none (budgeted run)');
21
+ }
22
+ lines.push('', 'Instructions:', '- Plan and apply minimal changes toward the goal.', '- Use tools as needed (edit files, run commands, inspect diffs).', '- Do not run the validator command; it will be run after you finish.', '- Self-refine before finalizing (ensure changes align with goal).');
23
+ if (input.roles === 'triad') {
24
+ if (input.subagentsEnabled) {
25
+ lines.push('', 'Use subagents if available: Planner, Critic, Reviser.');
26
+ }
27
+ lines.push('', 'Role split (single response with sections):', 'Planner: outline the plan.', 'Critic: identify risks or missing steps.', 'Reviser: execute the plan and summarize changes.');
28
+ }
29
+ lines.push('', 'End your response with:', 'Summary: <one-line summary of changes>', 'Next: <what to try next if validator still fails>');
30
+ return lines.join('\n');
31
+ }
@@ -0,0 +1,177 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join, relative } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { isoTimestamp } from '../utils/time.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const MAX_DIFF_CHARS = 2000;
8
+ const MAX_VALIDATOR_OUTPUT_CHARS = 4000;
9
+ function truncate(value, maxChars) {
10
+ if (value.length <= maxChars) {
11
+ return value;
12
+ }
13
+ return `${value.slice(0, maxChars)}...`;
14
+ }
15
+ function extractSummary(output) {
16
+ const lines = output
17
+ .split('\n')
18
+ .map((line) => line.trim())
19
+ .filter(Boolean);
20
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
21
+ const line = lines[i] ?? '';
22
+ if (line.toLowerCase().startsWith('summary:')) {
23
+ return line.slice('summary:'.length).trim() || null;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ function summarizeValidatorOutput(result) {
29
+ const combined = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n');
30
+ if (!combined) {
31
+ return null;
32
+ }
33
+ return truncate(combined, MAX_VALIDATOR_OUTPUT_CHARS);
34
+ }
35
+ async function collectGitSummary(repoRoot) {
36
+ try {
37
+ const [statusResult, diffResult] = await Promise.all([
38
+ execFileAsync('git', ['status', '--short'], { cwd: repoRoot }),
39
+ execFileAsync('git', ['diff', '--stat'], { cwd: repoRoot })
40
+ ]);
41
+ const statusText = statusResult.stdout.trim() || 'clean';
42
+ const diffText = diffResult.stdout.trim() || 'no diff';
43
+ return [
44
+ 'status:',
45
+ truncate(statusText, MAX_DIFF_CHARS),
46
+ 'diff:',
47
+ truncate(diffText, MAX_DIFF_CHARS)
48
+ ].join('\n');
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return `git summary unavailable: ${message}`;
53
+ }
54
+ }
55
+ async function writeStateFile(path, state) {
56
+ await writeFile(path, JSON.stringify(state, null, 2), 'utf8');
57
+ }
58
+ async function writeValidatorLog(path, command, result) {
59
+ const lines = [];
60
+ lines.push(`[${isoTimestamp()}] $ ${command}`);
61
+ if (result.spawnError) {
62
+ lines.push('[validator] spawn error');
63
+ }
64
+ if (result.stdout.trim()) {
65
+ lines.push(result.stdout.trimEnd());
66
+ }
67
+ if (result.stderr.trim()) {
68
+ lines.push(result.stderr.trimEnd());
69
+ }
70
+ lines.push(`[exit] ${result.exitCode}`);
71
+ await writeFile(path, `${lines.join('\n')}\n`, 'utf8');
72
+ }
73
+ export async function runRlmLoop(options) {
74
+ const now = options.now ?? isoTimestamp;
75
+ const log = options.logger ?? (() => undefined);
76
+ const state = {
77
+ goal: options.goal,
78
+ validator: options.validatorCommand ?? 'none',
79
+ roles: options.roles,
80
+ maxIterations: options.maxIterations,
81
+ maxMinutes: options.maxMinutes ?? null,
82
+ iterations: []
83
+ };
84
+ const runDir = options.runDir;
85
+ const statePath = join(runDir, 'state.json');
86
+ await mkdir(runDir, { recursive: true });
87
+ const collectSummary = options.collectDiffSummary ?? collectGitSummary;
88
+ const maxIterations = options.maxIterations;
89
+ const maxMinutes = options.maxMinutes ?? null;
90
+ const startTime = Date.now();
91
+ const deadline = maxMinutes && maxMinutes > 0 ? startTime + maxMinutes * 60 * 1000 : null;
92
+ if (maxIterations === 0 && !options.validatorCommand && !deadline) {
93
+ state.final = { status: 'invalid_config', exitCode: 5 };
94
+ await writeStateFile(statePath, state);
95
+ return { state, exitCode: 5, error: 'validator none with unbounded budget' };
96
+ }
97
+ const timeExceeded = () => deadline !== null && Date.now() >= deadline;
98
+ let lastValidatorOutput = null;
99
+ const finalize = async (status) => {
100
+ state.final = status ?? { status: 'error', exitCode: 10 };
101
+ await writeStateFile(statePath, state);
102
+ return { state, exitCode: state.final.exitCode };
103
+ };
104
+ try {
105
+ for (let iteration = 1; maxIterations === 0 || iteration <= maxIterations; iteration += 1) {
106
+ if (timeExceeded()) {
107
+ if (options.validatorCommand) {
108
+ return await finalize({ status: 'max_minutes', exitCode: 3 });
109
+ }
110
+ return await finalize({ status: 'budget_complete', exitCode: 0 });
111
+ }
112
+ const iterationStartedAt = now();
113
+ const preDiffSummary = await collectSummary(options.repoRoot);
114
+ const agentResult = await options.runAgent({
115
+ goal: options.goal,
116
+ iteration,
117
+ maxIterations,
118
+ roles: options.roles,
119
+ subagentsEnabled: options.subagentsEnabled,
120
+ validatorCommand: options.validatorCommand,
121
+ lastValidatorOutput,
122
+ diffSummary: preDiffSummary,
123
+ repoRoot: options.repoRoot
124
+ });
125
+ const postDiffSummary = await collectSummary(options.repoRoot);
126
+ const summary = agentResult.summary ?? extractSummary(agentResult.output) ?? null;
127
+ let validatorExitCode = null;
128
+ let validatorLogPath = null;
129
+ let validatorResult = null;
130
+ if (options.validatorCommand) {
131
+ if (!options.runValidator) {
132
+ throw new Error('Validator runner missing');
133
+ }
134
+ const validatorLogFile = join(runDir, `validator-${iteration}.log`);
135
+ validatorResult = await options.runValidator(options.validatorCommand);
136
+ await writeValidatorLog(validatorLogFile, options.validatorCommand, validatorResult);
137
+ validatorLogPath = relative(options.repoRoot, validatorLogFile);
138
+ validatorExitCode = validatorResult.exitCode;
139
+ lastValidatorOutput = summarizeValidatorOutput(validatorResult);
140
+ }
141
+ state.iterations.push({
142
+ n: iteration,
143
+ startedAt: iterationStartedAt,
144
+ summary,
145
+ validatorExitCode,
146
+ validatorLogPath,
147
+ diffSummary: postDiffSummary
148
+ });
149
+ await writeStateFile(statePath, state);
150
+ if (validatorResult?.spawnError) {
151
+ return await finalize({ status: 'error', exitCode: 4 });
152
+ }
153
+ if (validatorExitCode === 0) {
154
+ return await finalize({ status: 'passed', exitCode: 0 });
155
+ }
156
+ if (maxIterations > 0 && iteration >= maxIterations) {
157
+ if (options.validatorCommand) {
158
+ return await finalize({ status: 'max_iterations', exitCode: 3 });
159
+ }
160
+ return await finalize({ status: 'budget_complete', exitCode: 0 });
161
+ }
162
+ if (timeExceeded()) {
163
+ if (options.validatorCommand) {
164
+ return await finalize({ status: 'max_minutes', exitCode: 3 });
165
+ }
166
+ return await finalize({ status: 'budget_complete', exitCode: 0 });
167
+ }
168
+ log(`RLM iteration ${iteration} complete; continuing.`);
169
+ }
170
+ }
171
+ catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ await finalize({ status: 'error', exitCode: 10 });
174
+ return { state, exitCode: 10, error: message };
175
+ }
176
+ return await finalize({ status: 'error', exitCode: 10 });
177
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const NODE_PACKAGE_MANAGER_MAP = {
4
+ pnpm: 'pnpm test',
5
+ yarn: 'yarn test',
6
+ npm: 'npm test',
7
+ bun: 'bun test'
8
+ };
9
+ const NODE_LOCKFILES = [
10
+ { file: 'pnpm-lock.yaml', command: 'pnpm test' },
11
+ { file: 'yarn.lock', command: 'yarn test' },
12
+ { file: 'package-lock.json', command: 'npm test' },
13
+ { file: 'bun.lockb', command: 'bun test' }
14
+ ];
15
+ async function fileExists(path) {
16
+ try {
17
+ await access(path);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function ecosystemReason(ecosystem, reason) {
25
+ return `${ecosystem}: ${reason}`;
26
+ }
27
+ async function detectNodeCandidates(repoRoot) {
28
+ const candidates = [];
29
+ const packageJsonPath = join(repoRoot, 'package.json');
30
+ if (await fileExists(packageJsonPath)) {
31
+ try {
32
+ const raw = await readFile(packageJsonPath, 'utf8');
33
+ const parsed = JSON.parse(raw);
34
+ const packageManager = parsed.packageManager?.trim();
35
+ if (packageManager) {
36
+ const tool = packageManager.split('@')[0]?.trim();
37
+ const command = tool ? NODE_PACKAGE_MANAGER_MAP[tool] : undefined;
38
+ if (command) {
39
+ candidates.push({
40
+ command,
41
+ reason: ecosystemReason('node', `packageManager=${packageManager}`),
42
+ ecosystem: 'node'
43
+ });
44
+ }
45
+ }
46
+ }
47
+ catch {
48
+ // Ignore invalid package.json for auto-detection.
49
+ }
50
+ }
51
+ for (const lockfile of NODE_LOCKFILES) {
52
+ const lockPath = join(repoRoot, lockfile.file);
53
+ if (await fileExists(lockPath)) {
54
+ candidates.push({
55
+ command: lockfile.command,
56
+ reason: ecosystemReason('node', lockfile.file),
57
+ ecosystem: 'node'
58
+ });
59
+ }
60
+ }
61
+ return candidates;
62
+ }
63
+ async function detectPythonCandidates(repoRoot) {
64
+ const candidates = [];
65
+ const pyprojectPath = join(repoRoot, 'pyproject.toml');
66
+ if (await fileExists(pyprojectPath)) {
67
+ candidates.push({
68
+ command: 'python -m pytest',
69
+ reason: ecosystemReason('python', 'pyproject.toml'),
70
+ ecosystem: 'python'
71
+ });
72
+ }
73
+ const pytestIni = join(repoRoot, 'pytest.ini');
74
+ if (await fileExists(pytestIni)) {
75
+ candidates.push({
76
+ command: 'pytest',
77
+ reason: ecosystemReason('python', 'pytest.ini'),
78
+ ecosystem: 'python'
79
+ });
80
+ }
81
+ const requirements = join(repoRoot, 'requirements.txt');
82
+ if (await fileExists(requirements)) {
83
+ candidates.push({
84
+ command: 'pytest',
85
+ reason: ecosystemReason('python', 'requirements.txt'),
86
+ ecosystem: 'python'
87
+ });
88
+ }
89
+ return candidates;
90
+ }
91
+ async function detectGoCandidates(repoRoot) {
92
+ const goModPath = join(repoRoot, 'go.mod');
93
+ if (await fileExists(goModPath)) {
94
+ return [
95
+ {
96
+ command: 'go test ./...',
97
+ reason: ecosystemReason('go', 'go.mod'),
98
+ ecosystem: 'go'
99
+ }
100
+ ];
101
+ }
102
+ return [];
103
+ }
104
+ async function detectRustCandidates(repoRoot) {
105
+ const cargoPath = join(repoRoot, 'Cargo.toml');
106
+ if (await fileExists(cargoPath)) {
107
+ return [
108
+ {
109
+ command: 'cargo test',
110
+ reason: ecosystemReason('rust', 'Cargo.toml'),
111
+ ecosystem: 'rust'
112
+ }
113
+ ];
114
+ }
115
+ return [];
116
+ }
117
+ export async function detectValidatorCandidates(repoRoot) {
118
+ const [node, python, go, rust] = await Promise.all([
119
+ detectNodeCandidates(repoRoot),
120
+ detectPythonCandidates(repoRoot),
121
+ detectGoCandidates(repoRoot),
122
+ detectRustCandidates(repoRoot)
123
+ ]);
124
+ return [...node, ...python, ...go, ...rust];
125
+ }
126
+ export async function detectValidator(repoRoot) {
127
+ const rawCandidates = await detectValidatorCandidates(repoRoot);
128
+ if (rawCandidates.length === 0) {
129
+ return { status: 'missing', command: null, candidates: [] };
130
+ }
131
+ const grouped = new Map();
132
+ for (const candidate of rawCandidates) {
133
+ const entry = grouped.get(candidate.command);
134
+ if (entry) {
135
+ entry.reasons.push(candidate.reason);
136
+ }
137
+ else {
138
+ grouped.set(candidate.command, {
139
+ command: candidate.command,
140
+ reasons: [candidate.reason],
141
+ ecosystem: candidate.ecosystem
142
+ });
143
+ }
144
+ }
145
+ const candidates = Array.from(grouped.values()).map((entry) => ({
146
+ command: entry.command,
147
+ reason: entry.reasons.join('; '),
148
+ ecosystem: entry.ecosystem
149
+ }));
150
+ if (candidates.length === 1) {
151
+ return {
152
+ status: 'selected',
153
+ command: candidates[0]?.command ?? null,
154
+ reason: candidates[0]?.reason,
155
+ candidates
156
+ };
157
+ }
158
+ return { status: 'ambiguous', command: null, candidates };
159
+ }