agentic-orchestrator 0.1.7 → 0.1.9

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 (58) hide show
  1. package/README.md +25 -3
  2. package/agentic/orchestrator/schemas/agents.schema.json +1 -1
  3. package/apps/control-plane/src/cli/dashboard-command-handler.ts +42 -5
  4. package/apps/control-plane/src/cli/env-file.ts +115 -0
  5. package/apps/control-plane/src/cli/help-command-handler.ts +1 -1
  6. package/apps/control-plane/src/cli/init-command-handler.ts +72 -2
  7. package/apps/control-plane/src/cli/retry-command-handler.ts +0 -1
  8. package/apps/control-plane/src/core/kernel.ts +1 -3
  9. package/apps/control-plane/src/core/tool-caller.ts +18 -3
  10. package/apps/control-plane/src/interfaces/cli/bootstrap.ts +10 -3
  11. package/apps/control-plane/src/providers/providers.ts +67 -8
  12. package/apps/control-plane/src/supervisor/build-wave-executor.ts +21 -4
  13. package/apps/control-plane/src/supervisor/qa-wave-executor.ts +21 -4
  14. package/apps/control-plane/src/supervisor/runtime.ts +9 -4
  15. package/apps/control-plane/src/supervisor/types.ts +1 -0
  16. package/apps/control-plane/test/cli-helpers.spec.ts +4 -0
  17. package/apps/control-plane/test/dashboard-command.spec.ts +36 -0
  18. package/apps/control-plane/test/init-wizard.spec.ts +166 -1
  19. package/apps/control-plane/test/providers.spec.ts +75 -2
  20. package/apps/control-plane/test/supervisor-collaborators.spec.ts +86 -0
  21. package/apps/control-plane/test/supervisor.unit.spec.ts +1 -1
  22. package/config/agentic/orchestrator/adapters.yaml +3 -0
  23. package/config/agentic/orchestrator/agents.yaml +13 -0
  24. package/config/agentic/orchestrator/gates.yaml +28 -0
  25. package/config/agentic/orchestrator/policy.yaml +22 -0
  26. package/config/agentic/orchestrator/prompts/builder.system.md +1 -0
  27. package/config/agentic/orchestrator/prompts/planner.system.md +16 -0
  28. package/config/agentic/orchestrator/prompts/qa.system.md +1 -0
  29. package/dist/apps/control-plane/cli/dashboard-command-handler.js +32 -5
  30. package/dist/apps/control-plane/cli/dashboard-command-handler.js.map +1 -1
  31. package/dist/apps/control-plane/cli/env-file.d.ts +4 -0
  32. package/dist/apps/control-plane/cli/env-file.js +89 -0
  33. package/dist/apps/control-plane/cli/env-file.js.map +1 -0
  34. package/dist/apps/control-plane/cli/help-command-handler.js +1 -1
  35. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  36. package/dist/apps/control-plane/cli/init-command-handler.js +53 -4
  37. package/dist/apps/control-plane/cli/init-command-handler.js.map +1 -1
  38. package/dist/apps/control-plane/cli/retry-command-handler.js +0 -1
  39. package/dist/apps/control-plane/cli/retry-command-handler.js.map +1 -1
  40. package/dist/apps/control-plane/core/kernel.js.map +1 -1
  41. package/dist/apps/control-plane/core/tool-caller.d.ts +11 -1
  42. package/dist/apps/control-plane/interfaces/cli/bootstrap.js +9 -3
  43. package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
  44. package/dist/apps/control-plane/providers/providers.d.ts +2 -1
  45. package/dist/apps/control-plane/providers/providers.js +52 -7
  46. package/dist/apps/control-plane/providers/providers.js.map +1 -1
  47. package/dist/apps/control-plane/supervisor/build-wave-executor.js +20 -4
  48. package/dist/apps/control-plane/supervisor/build-wave-executor.js.map +1 -1
  49. package/dist/apps/control-plane/supervisor/qa-wave-executor.js +20 -4
  50. package/dist/apps/control-plane/supervisor/qa-wave-executor.js.map +1 -1
  51. package/dist/apps/control-plane/supervisor/runtime.d.ts +2 -2
  52. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  53. package/dist/apps/control-plane/supervisor/types.d.ts +1 -1
  54. package/dist/apps/control-plane/supervisor/types.js.map +1 -1
  55. package/package.json +1 -1
  56. package/spec-files/completed/agentic_orchestrator_feature_gaps_closure_spec.md +2 -0
  57. package/spec-files/outstanding/agentic_orchestrator_provider_auth_bootstrap_spec.md +384 -0
  58. package/spec-files/progress.md +19 -0
@@ -17,6 +17,17 @@ interface BuildWaveExecutorDependencies {
17
17
  reactionsService?: ReactionsService;
18
18
  }
19
19
 
20
+ function isRetryableToolError(error: unknown): boolean {
21
+ if (!error || typeof error !== 'object') {
22
+ return false;
23
+ }
24
+ const details = (error as { details?: unknown }).details;
25
+ if (!details || typeof details !== 'object') {
26
+ return false;
27
+ }
28
+ return (details as { retryable?: unknown }).retryable === true;
29
+ }
30
+
20
31
  export class BuildWaveExecutor {
21
32
  private readonly toolCaller: SupervisorToolCaller;
22
33
  private readonly workerDecisionRunner: WorkerDecisionRunner;
@@ -72,6 +83,7 @@ export class BuildWaveExecutor {
72
83
  let gateExitCode: number;
73
84
  let gateEvidencePath = '';
74
85
  let gateLogs = '';
86
+ let gateRetryEligible: boolean;
75
87
  const failureHistory: GateRepairContext['failureHistory'] = [];
76
88
 
77
89
  try {
@@ -80,18 +92,19 @@ export class BuildWaveExecutor {
80
92
  TOOLS.GATES_RUN,
81
93
  {
82
94
  feature_id: featureId,
83
- profile: null,
84
95
  mode: 'fast',
85
96
  },
86
97
  );
87
98
  gateOverall = gateResult.data.overall ?? GATE_RESULT.PASS;
88
99
  gateExitCode = gateOverall === GATE_RESULT.FAIL ? 1 : 0;
89
100
  gateEvidencePath = gateResult.data.evidence_path ?? '';
101
+ gateRetryEligible = gateOverall === GATE_RESULT.FAIL;
90
102
  } catch (error) {
91
103
  gateOverall = GATE_RESULT.FAIL;
92
104
  gateExitCode = 1;
93
105
  const typed = error as { message?: string };
94
106
  gateLogs = typed.message ?? '';
107
+ gateRetryEligible = isRetryableToolError(error);
95
108
  }
96
109
 
97
110
  if (gateOverall === GATE_RESULT.FAIL) {
@@ -106,13 +119,13 @@ export class BuildWaveExecutor {
106
119
  });
107
120
  }
108
121
 
109
- if (this.reactionsService && gateOverall === GATE_RESULT.FAIL) {
122
+ if (this.reactionsService && gateOverall === GATE_RESULT.FAIL && gateRetryEligible) {
110
123
  const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
111
124
  feature_id: featureId,
112
125
  });
113
126
  let retryCount = initialRetryCount;
114
127
  const retryDelayMs = this.reactionsService.retryDelayMs();
115
- while (this.reactionsService.shouldRetry(featureId, retryCount)) {
128
+ while (gateRetryEligible && this.reactionsService.shouldRetry(featureId, retryCount)) {
116
129
  if (typeof this.reactionsService.waitBeforeRetry === 'function') {
117
130
  await this.reactionsService.waitBeforeRetry();
118
131
  }
@@ -140,7 +153,6 @@ export class BuildWaveExecutor {
140
153
  TOOLS.GATES_RUN,
141
154
  {
142
155
  feature_id: featureId,
143
- profile: null,
144
156
  mode: 'fast',
145
157
  },
146
158
  );
@@ -148,11 +160,13 @@ export class BuildWaveExecutor {
148
160
  gateExitCode = gateOverall === GATE_RESULT.FAIL ? 1 : 0;
149
161
  gateEvidencePath = retryResult.data.evidence_path ?? gateEvidencePath;
150
162
  gateLogs = '';
163
+ gateRetryEligible = gateOverall === GATE_RESULT.FAIL;
151
164
  } catch (error) {
152
165
  gateOverall = GATE_RESULT.FAIL;
153
166
  gateExitCode = 1;
154
167
  const typed = error as { message?: string };
155
168
  gateLogs = typed.message ?? '';
169
+ gateRetryEligible = isRetryableToolError(error);
156
170
  }
157
171
 
158
172
  retryCount += 1;
@@ -173,6 +187,9 @@ export class BuildWaveExecutor {
173
187
  if (gateOverall === GATE_RESULT.PASS) {
174
188
  break;
175
189
  }
190
+ if (!gateRetryEligible) {
191
+ break;
192
+ }
176
193
  }
177
194
 
178
195
  if (gateOverall === GATE_RESULT.FAIL && this.reactionsService.shouldEscalate(retryCount)) {
@@ -31,6 +31,17 @@ interface QaWaveExecutorDependencies {
31
31
  reactionsService?: ReactionsService;
32
32
  }
33
33
 
34
+ function isRetryableToolError(error: unknown): boolean {
35
+ if (!error || typeof error !== 'object') {
36
+ return false;
37
+ }
38
+ const details = (error as { details?: unknown }).details;
39
+ if (!details || typeof details !== 'object') {
40
+ return false;
41
+ }
42
+ return (details as { retryable?: unknown }).retryable === true;
43
+ }
44
+
34
45
  export class QaWaveExecutor {
35
46
  private readonly kernel: FeatureOrchestrationPort;
36
47
  private readonly provider: WorkerProvider;
@@ -93,22 +104,24 @@ export class QaWaveExecutor {
93
104
  let gateExitCode: number;
94
105
  let gateEvidencePath = '';
95
106
  let gateLogs = '';
107
+ let gateRetryEligible: boolean;
96
108
  const failureHistory: GateRepairContext['failureHistory'] = [];
97
109
 
98
110
  try {
99
111
  const gateResult = await this.toolCaller.callTool<GatesRunData>('qa', TOOLS.GATES_RUN, {
100
112
  feature_id: featureId,
101
- profile: null,
102
113
  mode: 'full',
103
114
  });
104
115
  gateOverall = gateResult.data.overall ?? GATE_RESULT.PASS;
105
116
  gateExitCode = gateOverall === GATE_RESULT.FAIL ? 1 : 0;
106
117
  gateEvidencePath = gateResult.data.evidence_path ?? '';
118
+ gateRetryEligible = gateOverall === GATE_RESULT.FAIL;
107
119
  } catch (error) {
108
120
  gateOverall = GATE_RESULT.FAIL;
109
121
  gateExitCode = 1;
110
122
  const typed = error as { message?: string };
111
123
  gateLogs = typed.message ?? '';
124
+ gateRetryEligible = isRetryableToolError(error);
112
125
  }
113
126
 
114
127
  if (gateOverall === GATE_RESULT.FAIL) {
@@ -123,10 +136,10 @@ export class QaWaveExecutor {
123
136
  });
124
137
  }
125
138
 
126
- if (this.reactionsService && gateOverall === GATE_RESULT.FAIL) {
139
+ if (this.reactionsService && gateOverall === GATE_RESULT.FAIL && gateRetryEligible) {
127
140
  let retryCount = initialRetryCount;
128
141
  const retryDelayMs = this.reactionsService.retryDelayMs();
129
- while (this.reactionsService.shouldRetry(featureId, retryCount)) {
142
+ while (gateRetryEligible && this.reactionsService.shouldRetry(featureId, retryCount)) {
130
143
  if (typeof this.reactionsService.waitBeforeRetry === 'function') {
131
144
  await this.reactionsService.waitBeforeRetry();
132
145
  }
@@ -154,7 +167,6 @@ export class QaWaveExecutor {
154
167
  TOOLS.GATES_RUN,
155
168
  {
156
169
  feature_id: featureId,
157
- profile: null,
158
170
  mode: 'full',
159
171
  },
160
172
  );
@@ -162,11 +174,13 @@ export class QaWaveExecutor {
162
174
  gateExitCode = gateOverall === GATE_RESULT.FAIL ? 1 : 0;
163
175
  gateEvidencePath = retryResult.data.evidence_path ?? gateEvidencePath;
164
176
  gateLogs = '';
177
+ gateRetryEligible = gateOverall === GATE_RESULT.FAIL;
165
178
  } catch (error) {
166
179
  gateOverall = GATE_RESULT.FAIL;
167
180
  gateExitCode = 1;
168
181
  const typed = error as { message?: string };
169
182
  gateLogs = typed.message ?? '';
183
+ gateRetryEligible = isRetryableToolError(error);
170
184
  }
171
185
 
172
186
  retryCount += 1;
@@ -187,6 +201,9 @@ export class QaWaveExecutor {
187
201
  if (gateOverall === GATE_RESULT.PASS) {
188
202
  break;
189
203
  }
204
+ if (!gateRetryEligible) {
205
+ break;
206
+ }
190
207
  }
191
208
 
192
209
  if (gateOverall === GATE_RESULT.FAIL && this.reactionsService.shouldEscalate(retryCount)) {
@@ -32,6 +32,7 @@ import type {
32
32
  InitialPlanGenerator,
33
33
  PromptBundle,
34
34
  RuntimeRole,
35
+ ToolArgs,
35
36
  SupervisorKernelPort,
36
37
  SupervisorOptions,
37
38
  SupervisorRuntimeState,
@@ -472,12 +473,16 @@ export class SupervisorRuntime
472
473
  }
473
474
  }
474
475
 
475
- async callTool<TData = Record<string, unknown>>(
476
+ async callTool<TData = Record<string, unknown>, TToolName extends string = string>(
476
477
  role: RuntimeRole,
477
- toolName: string,
478
- args: Record<string, unknown>,
478
+ toolName: TToolName,
479
+ args: ToolArgs<TToolName>,
479
480
  ): Promise<{ ok: true; data: TData }> {
480
- const payload = withOperationIdIfRequired(toolName, args, createOperationId);
481
+ const payload = withOperationIdIfRequired(
482
+ toolName,
483
+ args as Record<string, unknown>,
484
+ createOperationId,
485
+ );
481
486
  const roleSessionId = this.resolveRoleSessionId(role, payload);
482
487
 
483
488
  const response = await this.toolClient.call(toolName, payload, {
@@ -5,6 +5,7 @@ export type {
5
5
  FeatureStateFrontMatter,
6
6
  FeatureStatePayload,
7
7
  RuntimeRole,
8
+ ToolArgs,
8
9
  ToolCaller as SupervisorToolCaller,
9
10
  } from '../core/tool-caller.js';
10
11
 
@@ -221,6 +221,8 @@ describe('RetryCommandHandler', () => {
221
221
  expect.objectContaining({ feature_id: 'feature_abc', mode: 'full' }),
222
222
  expect.any(Object),
223
223
  );
224
+ const gatesRunCall = callMock.mock.calls.find((call) => call[0] === TOOLS.GATES_RUN);
225
+ expect(gatesRunCall?.[1]).not.toHaveProperty('profile');
224
226
  });
225
227
 
226
228
  it('GIVEN_valid_feature_id_with_force_WHEN_execute_called_THEN_gets_current_state_and_patches_retry_count', async () => {
@@ -259,6 +261,8 @@ describe('RetryCommandHandler', () => {
259
261
  expect.objectContaining({ feature_id: 'feature_abc' }),
260
262
  expect.any(Object),
261
263
  );
264
+ const gatesRunCall = callMock.mock.calls.find((call) => call[0] === TOOLS.GATES_RUN);
265
+ expect(gatesRunCall?.[1]).not.toHaveProperty('profile');
262
266
  });
263
267
  });
264
268
 
@@ -2,6 +2,12 @@ import { describe, expect, it, vi, afterEach } from 'vitest';
2
2
  import { DashboardCommandHandler } from '../src/cli/dashboard-command-handler.js';
3
3
  import type { ChildProcess } from 'node:child_process';
4
4
 
5
+ vi.mock('node:fs/promises', () => ({
6
+ default: {
7
+ access: vi.fn(async () => undefined),
8
+ },
9
+ }));
10
+
5
11
  vi.mock('node:child_process', () => ({
6
12
  execFile: vi.fn(),
7
13
  spawn: vi.fn(),
@@ -104,4 +110,34 @@ describe('DashboardCommandHandler', () => {
104
110
  );
105
111
  expect(result).toMatchObject({ ok: true, data: { mode: 'dev' } });
106
112
  });
113
+
114
+ it('GIVEN_dashboard_cli_missing_WHEN_dashboard_command_THEN_installs_workspace_dependencies_before_build', async () => {
115
+ const { default: fs } = await import('node:fs/promises');
116
+ const { execFile, spawn } = await import('node:child_process');
117
+ const mockChild = { unref: vi.fn(), on: vi.fn() } as unknown as ChildProcess;
118
+
119
+ vi.mocked(fs.access).mockRejectedValue(new Error('missing'));
120
+ vi.mocked(execFile).mockImplementation((_file, _args, _opts, cb) => {
121
+ cb?.(null, '', '');
122
+ return {} as never;
123
+ });
124
+ vi.mocked(spawn).mockReturnValue(mockChild);
125
+
126
+ const handler = new DashboardCommandHandler();
127
+ const result = await handler.execute({ foreground: false });
128
+
129
+ expect(execFile).toHaveBeenCalledWith(
130
+ 'npm',
131
+ ['install', '--workspace', '@aop/web-dashboard', '--no-audit', '--no-fund'],
132
+ expect.any(Object),
133
+ expect.any(Function),
134
+ );
135
+ expect(execFile).toHaveBeenCalledWith(
136
+ 'npm',
137
+ ['run', '--workspace', '@aop/web-dashboard', 'build'],
138
+ expect.any(Object),
139
+ expect.any(Function),
140
+ );
141
+ expect(result).toMatchObject({ ok: true, data: { built: true } });
142
+ });
107
143
  });
@@ -27,7 +27,7 @@ import { InitCommandHandler } from '../src/cli/init-command-handler.js';
27
27
  import { HelpCommandHandler } from '../src/cli/help-command-handler.js';
28
28
 
29
29
  function makePromptFactory(responses: string[]) {
30
- const question = vi.fn(async () => responses.shift() ?? '');
30
+ const question = vi.fn(async (_query: string) => responses.shift() ?? '');
31
31
  const close = vi.fn();
32
32
  const factory = vi.fn(() => ({ question, close }));
33
33
  return { factory, question, close };
@@ -97,6 +97,7 @@ describe('InitCommandHandler', () => {
97
97
  expect(agentsContent).toContain('roles:');
98
98
  expect(agentsContent).toContain('default_provider: custom');
99
99
  expect(agentsContent).toContain('default_model: local-default');
100
+ expect(agentsContent).not.toContain('provider_config_env:');
100
101
 
101
102
  const adaptersContent = await fs.readFile(
102
103
  path.join(cwd, 'config', 'agentic', 'orchestrator', 'adapters.yaml'),
@@ -350,6 +351,7 @@ describe('InitCommandHandler', () => {
350
351
  );
351
352
  expect(agentsContent).toContain('default_provider: claude');
352
353
  expect(agentsContent).toContain('default_model: sonnet-4.5');
354
+ expect(agentsContent).not.toContain('provider_config_env:');
353
355
 
354
356
  const adaptersContent = await fs.readFile(
355
357
  path.join(cwd, 'config', 'agentic', 'orchestrator', 'adapters.yaml'),
@@ -358,6 +360,169 @@ describe('InitCommandHandler', () => {
358
360
  expect(adaptersContent).toContain('activity-detector: claude-jsonl');
359
361
  expect(adaptersContent).toContain('scm-provider: github');
360
362
  });
363
+
364
+ it('GIVEN_interactive_local_cli_mode_WHEN_init_runs_THEN_provider_env_prompt_is_skipped_and_agents_yaml_omits_provider_config_env', async () => {
365
+ await fs.mkdir(path.join(cwd, '.git'), { recursive: true });
366
+ await fs.writeFile(path.join(cwd, '.git', 'config'), '[core]\n', 'utf8');
367
+ execFileMock.mockResolvedValue({ stdout: 'origin/main\n', stderr: '' });
368
+
369
+ const prompts = makePromptFactory([
370
+ 'main',
371
+ 'codex',
372
+ 'codex-default',
373
+ 'github',
374
+ '3',
375
+ '3000',
376
+ 'none',
377
+ 'vitest',
378
+ 'yes',
379
+ ]);
380
+ const handler = new InitCommandHandler(cwd, prompts.factory);
381
+ const result = await handler.execute({ auto: false });
382
+
383
+ expect(result.ok).toBe(true);
384
+ expect(
385
+ prompts.question.mock.calls.some((call) =>
386
+ String(call[0]).includes('Provider config env var name'),
387
+ ),
388
+ ).toBe(false);
389
+
390
+ const agentsContent = await fs.readFile(
391
+ path.join(cwd, 'config', 'agentic', 'orchestrator', 'agents.yaml'),
392
+ 'utf8',
393
+ );
394
+ expect(agentsContent).not.toContain('provider_config_env:');
395
+ });
396
+
397
+ it('GIVEN_api_backed_mode_with_existing_env_var_WHEN_init_runs_THEN_agents_yaml_uses_that_env_var', async () => {
398
+ await fs.mkdir(path.join(cwd, '.git'), { recursive: true });
399
+ await fs.writeFile(path.join(cwd, '.git', 'config'), '[core]\n', 'utf8');
400
+ execFileMock.mockResolvedValue({ stdout: 'origin/main\n', stderr: '' });
401
+ process.env.GEMINI_API_KEY = 'existing-key';
402
+
403
+ const prompts = makePromptFactory([
404
+ 'main',
405
+ 'gemini',
406
+ 'gemini-default',
407
+ 'github',
408
+ '3',
409
+ '3000',
410
+ 'none',
411
+ 'vitest',
412
+ 'no',
413
+ 'GEMINI_API_KEY',
414
+ ]);
415
+ const handler = new InitCommandHandler(cwd, prompts.factory);
416
+ const result = await handler.execute({ auto: false }).finally(() => {
417
+ delete process.env.GEMINI_API_KEY;
418
+ });
419
+
420
+ expect(result.ok).toBe(true);
421
+ const agentsContent = await fs.readFile(
422
+ path.join(cwd, 'config', 'agentic', 'orchestrator', 'agents.yaml'),
423
+ 'utf8',
424
+ );
425
+ expect(agentsContent).toContain('provider_config_env: GEMINI_API_KEY');
426
+ });
427
+
428
+ it('GIVEN_api_backed_mode_with_env_var_in_dotenv_WHEN_init_runs_THEN_agents_yaml_uses_that_env_var_without_bootstrap_prompt', async () => {
429
+ await fs.mkdir(path.join(cwd, '.git'), { recursive: true });
430
+ await fs.writeFile(path.join(cwd, '.git', 'config'), '[core]\n', 'utf8');
431
+ await fs.writeFile(path.join(cwd, '.env'), 'GEMINI_API_KEY=dotenv-key\n', 'utf8');
432
+ execFileMock.mockResolvedValue({ stdout: 'origin/main\n', stderr: '' });
433
+
434
+ const prompts = makePromptFactory([
435
+ 'main',
436
+ 'gemini',
437
+ 'gemini-default',
438
+ 'github',
439
+ '3',
440
+ '3000',
441
+ 'none',
442
+ 'vitest',
443
+ 'no',
444
+ 'GEMINI_API_KEY',
445
+ ]);
446
+ const handler = new InitCommandHandler(cwd, prompts.factory);
447
+ const result = await handler.execute({ auto: false });
448
+
449
+ expect(result.ok).toBe(true);
450
+ expect(
451
+ prompts.question.mock.calls.some((call) =>
452
+ String(call[0]).includes('Paste provider key to store in AOP_PROVIDER_CONFIG_ENV'),
453
+ ),
454
+ ).toBe(false);
455
+ const agentsContent = await fs.readFile(
456
+ path.join(cwd, 'config', 'agentic', 'orchestrator', 'agents.yaml'),
457
+ 'utf8',
458
+ );
459
+ expect(agentsContent).toContain('provider_config_env: GEMINI_API_KEY');
460
+ });
461
+
462
+ it('GIVEN_api_backed_mode_with_missing_env_var_WHEN_init_runs_THEN_bootstraps_AOP_provider_env_in_dotenv', async () => {
463
+ await fs.mkdir(path.join(cwd, '.git'), { recursive: true });
464
+ await fs.writeFile(path.join(cwd, '.git', 'config'), '[core]\n', 'utf8');
465
+ execFileMock.mockResolvedValue({ stdout: 'origin/main\n', stderr: '' });
466
+
467
+ const prompts = makePromptFactory([
468
+ 'main',
469
+ 'gemini',
470
+ 'gemini-default',
471
+ 'github',
472
+ '3',
473
+ '3000',
474
+ 'none',
475
+ 'vitest',
476
+ 'no',
477
+ 'GEMINI_API_KEY',
478
+ 'bootstrap-secret',
479
+ ]);
480
+ const handler = new InitCommandHandler(cwd, prompts.factory);
481
+ const result = await handler.execute({ auto: false });
482
+
483
+ expect(result.ok).toBe(true);
484
+ expect(result.data.next_steps).toContain(
485
+ 'Stored provider credential in .env as AOP_PROVIDER_CONFIG_ENV.',
486
+ );
487
+
488
+ const agentsContent = await fs.readFile(
489
+ path.join(cwd, 'config', 'agentic', 'orchestrator', 'agents.yaml'),
490
+ 'utf8',
491
+ );
492
+ expect(agentsContent).toContain('provider_config_env: AOP_PROVIDER_CONFIG_ENV');
493
+
494
+ const envContent = await fs.readFile(path.join(cwd, '.env'), 'utf8');
495
+ expect(envContent).toContain('AOP_PROVIDER_CONFIG_ENV=bootstrap-secret');
496
+ });
497
+
498
+ it('GIVEN_existing_AOP_provider_env_in_dotenv_WHEN_init_bootstraps_again_THEN_replaces_existing_value_without_duplication', async () => {
499
+ await fs.mkdir(path.join(cwd, '.git'), { recursive: true });
500
+ await fs.writeFile(path.join(cwd, '.git', 'config'), '[core]\n', 'utf8');
501
+ await fs.writeFile(path.join(cwd, '.env'), 'FOO=bar\nAOP_PROVIDER_CONFIG_ENV=old\n', 'utf8');
502
+ execFileMock.mockResolvedValue({ stdout: 'origin/main\n', stderr: '' });
503
+
504
+ const prompts = makePromptFactory([
505
+ 'main',
506
+ 'gemini',
507
+ 'gemini-default',
508
+ 'github',
509
+ '3',
510
+ '3000',
511
+ 'none',
512
+ 'vitest',
513
+ 'no',
514
+ 'MISSING_ENV_VAR',
515
+ 'new-secret',
516
+ ]);
517
+ const handler = new InitCommandHandler(cwd, prompts.factory);
518
+ const result = await handler.execute({ auto: false });
519
+
520
+ expect(result.ok).toBe(true);
521
+ const envContent = await fs.readFile(path.join(cwd, '.env'), 'utf8');
522
+ expect(envContent).toContain('FOO=bar');
523
+ expect(envContent).toContain('AOP_PROVIDER_CONFIG_ENV=new-secret');
524
+ expect((envContent.match(/AOP_PROVIDER_CONFIG_ENV=/g) ?? []).length).toBe(1);
525
+ });
361
526
  });
362
527
 
363
528
  describe('InitCommandHandler validation branches', () => {
@@ -13,6 +13,7 @@ describe('provider selection', () => {
13
13
  env: {
14
14
  AOP_AGENT_PROVIDER: 'codex',
15
15
  AOP_AGENT_MODEL: 'env-model',
16
+ CLI_CONFIG: 'cli-token',
16
17
  AOP_PROVIDER_CONFIG_ENV: 'ENV_CONFIG',
17
18
  },
18
19
  agentsConfig: {
@@ -27,6 +28,7 @@ describe('provider selection', () => {
27
28
  expect(selection.provider).toBe('custom');
28
29
  expect(selection.model).toBe('cli-model');
29
30
  expect(selection.provider_config_env).toBe('CLI_CONFIG');
31
+ expect(selection.provider_config_ref).toBe('cli-token');
30
32
  });
31
33
 
32
34
  it('GIVEN_no_provider_WHEN_resolving_THEN_throws_agent_provider_not_configured', () => {
@@ -49,16 +51,36 @@ describe('provider selection', () => {
49
51
  ).toThrow(ERROR_CODES.UNSUPPORTED_AGENT_PROVIDER);
50
52
  });
51
53
 
52
- it('GIVEN_non_custom_provider_without_credentials_WHEN_resolving_THEN_throws_provider_auth_missing', () => {
54
+ it('GIVEN_credential_required_provider_without_credentials_WHEN_resolving_THEN_throws_provider_auth_missing', () => {
53
55
  expect(() =>
54
56
  resolveProviderSelection({
55
- cli: { agent_provider: 'codex', provider_config_env: 'AOP_KEY' },
57
+ cli: { agent_provider: 'gemini', provider_config_env: 'GEMINI_API_KEY' },
56
58
  env: {},
57
59
  agentsConfig: {},
58
60
  }),
59
61
  ).toThrow(ERROR_CODES.PROVIDER_AUTH_MISSING);
60
62
  });
61
63
 
64
+ it('GIVEN_codex_provider_without_credentials_WHEN_resolving_THEN_does_not_throw', () => {
65
+ const selection = resolveProviderSelection({
66
+ cli: { agent_provider: 'codex' },
67
+ env: {},
68
+ agentsConfig: {},
69
+ });
70
+ expect(selection.provider).toBe('codex');
71
+ expect(selection.provider_config_ref).toBeNull();
72
+ });
73
+
74
+ it('GIVEN_claude_provider_without_credentials_WHEN_resolving_THEN_does_not_throw', () => {
75
+ const selection = resolveProviderSelection({
76
+ cli: { agent_provider: 'claude' },
77
+ env: {},
78
+ agentsConfig: {},
79
+ });
80
+ expect(selection.provider).toBe('claude');
81
+ expect(selection.provider_config_ref).toBeNull();
82
+ });
83
+
62
84
  it('GIVEN_non_custom_provider_without_model_WHEN_resolving_THEN_uses_default_model_name', () => {
63
85
  const selection = resolveProviderSelection({
64
86
  cli: {
@@ -88,6 +110,57 @@ describe('provider selection', () => {
88
110
  expect(selection.provider_config_ref).toBeNull();
89
111
  });
90
112
 
113
+ it('GIVEN_AOP_provider_config_env_holds_direct_credential_WHEN_resolving_THEN_uses_direct_value', () => {
114
+ const selection = resolveProviderSelection({
115
+ cli: {
116
+ agent_provider: 'gemini',
117
+ },
118
+ env: {
119
+ AOP_PROVIDER_CONFIG_ENV: 'direct-bootstrap-key',
120
+ },
121
+ agentsConfig: {},
122
+ });
123
+
124
+ expect(selection.provider_config_env).toBe('AOP_PROVIDER_CONFIG_ENV');
125
+ expect(selection.provider_config_ref).toBe('direct-bootstrap-key');
126
+ });
127
+
128
+ it('GIVEN_AOP_provider_config_env_points_to_existing_env_var_WHEN_resolving_THEN_uses_legacy_indirection', () => {
129
+ const selection = resolveProviderSelection({
130
+ cli: {
131
+ agent_provider: 'gemini',
132
+ },
133
+ env: {
134
+ AOP_PROVIDER_CONFIG_ENV: 'GEMINI_API_KEY',
135
+ GEMINI_API_KEY: 'legacy-indirect-key',
136
+ },
137
+ agentsConfig: {},
138
+ });
139
+
140
+ expect(selection.provider_config_env).toBe('GEMINI_API_KEY');
141
+ expect(selection.provider_config_ref).toBe('legacy-indirect-key');
142
+ });
143
+
144
+ it('GIVEN_cli_provider_config_env_missing_but_runtime_provider_config_env_present_WHEN_resolving_THEN_runtime_env_name_is_used', () => {
145
+ const selection = resolveProviderSelection({
146
+ cli: {
147
+ agent_provider: 'gemini',
148
+ provider_config_env: 'MISSING_CLI_KEY',
149
+ },
150
+ env: {
151
+ CFG_KEY: 'cfg-token',
152
+ },
153
+ agentsConfig: {
154
+ runtime: {
155
+ provider_config_env: 'CFG_KEY',
156
+ },
157
+ },
158
+ });
159
+
160
+ expect(selection.provider_config_env).toBe('CFG_KEY');
161
+ expect(selection.provider_config_ref).toBe('cfg-token');
162
+ });
163
+
91
164
  it('GIVEN_kiro_cli_provider_without_credentials_WHEN_resolving_THEN_allows_null_config_ref', () => {
92
165
  const selection = resolveProviderSelection({
93
166
  cli: {