@principles/pd-cli 1.101.0 → 1.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/commands/diagnose.js +27 -27
  2. package/dist/commands/diagnose.js.map +1 -1
  3. package/dist/commands/pain-retry.d.ts.map +1 -1
  4. package/dist/commands/pain-retry.js +22 -27
  5. package/dist/commands/pain-retry.js.map +1 -1
  6. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -1
  7. package/dist/commands/runtime-internalization-integrity.js +40 -1
  8. package/dist/commands/runtime-internalization-integrity.js.map +1 -1
  9. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  10. package/dist/commands/runtime-internalization-run-once.js +11 -9
  11. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  12. package/dist/commands/runtime.d.ts +1 -1
  13. package/dist/commands/runtime.d.ts.map +1 -1
  14. package/dist/commands/runtime.js +92 -25
  15. package/dist/commands/runtime.js.map +1 -1
  16. package/dist/services/mainline-snapshot-assembler.d.ts +35 -0
  17. package/dist/services/mainline-snapshot-assembler.d.ts.map +1 -0
  18. package/dist/services/mainline-snapshot-assembler.js +399 -0
  19. package/dist/services/mainline-snapshot-assembler.js.map +1 -0
  20. package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
  21. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
  22. package/dist/services/resolve-runtime-from-pd-config.js +96 -0
  23. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
  24. package/package.json +1 -1
  25. package/src/commands/diagnose.ts +26 -26
  26. package/src/commands/pain-retry.ts +21 -25
  27. package/src/commands/runtime-internalization-integrity.ts +40 -1
  28. package/src/commands/runtime-internalization-run-once.ts +10 -9
  29. package/src/commands/runtime.ts +96 -24
  30. package/src/services/mainline-snapshot-assembler.ts +544 -0
  31. package/src/services/resolve-runtime-from-pd-config.ts +142 -0
  32. package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
  33. package/tests/commands/diagnose.test.ts +91 -39
  34. package/tests/commands/pain-retry.test.ts +130 -15
  35. package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
  36. package/tests/commands/runtime-internalization-integrity.test.ts +37 -0
  37. package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
  38. package/tests/commands/runtime.test.ts +124 -1
  39. package/tests/services/mainline-snapshot-assembler.test.ts +425 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * PRI-393: Runtime config unification tests
3
+ *
4
+ * Validates that all MVP mainline execution paths (probe, run-once, diagnose,
5
+ * pain-retry) read from .pd/config.yaml, NOT from .state/workflows.yaml.
6
+ *
7
+ * ERR refs:
8
+ * - EP-02: production path wiring — tests exercise real production entry points
9
+ * - EP-03: fail loud — no silent fallback
10
+ * - EP-07: runtime state source alignment — doctor/probe/run-once agree
11
+ * - EP-09: test reality gap — production schema fixtures
12
+ */
13
+
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import * as os from 'node:os';
18
+ import * as yaml from 'js-yaml';
19
+ import {
20
+ assertMainlineContract,
21
+ type MainlineSnapshot,
22
+ } from '@principles/core/runtime-v2';
23
+
24
+ // ── Helpers ────────────────────────────────────────────────────────────────
25
+
26
+ function mkTmpDir(prefix = 'pri-393-'): string {
27
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
28
+ }
29
+
30
+ function rmTmpDir(dir: string): void {
31
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
32
+ }
33
+
34
+ function writeConfigYaml(workspaceDir: string, content: object): void {
35
+ const pdDir = path.join(workspaceDir, '.pd');
36
+ fs.mkdirSync(pdDir, { recursive: true });
37
+ fs.writeFileSync(
38
+ path.join(pdDir, 'config.yaml'),
39
+ yaml.dump(content, { lineWidth: -1 }),
40
+ 'utf8',
41
+ );
42
+ }
43
+
44
+ function writeLegacyWorkflowsYaml(workspaceDir: string, content: string): void {
45
+ const stateDir = path.join(workspaceDir, '.state');
46
+ fs.mkdirSync(stateDir, { recursive: true });
47
+ fs.writeFileSync(path.join(stateDir, 'workflows.yaml'), content, 'utf8');
48
+ }
49
+
50
+ /** Minimal valid .pd/config.yaml with a pi-ai runtime profile for diagnostician. */
51
+ function makeValidConfigYaml(overrides?: { provider?: string; model?: string }): object {
52
+ return {
53
+ version: 1,
54
+ features: {
55
+ prompt: { enabled: true, category: 'core' },
56
+ correction_observer: { enabled: false, category: 'quiet' },
57
+ },
58
+ runtimeProfiles: {
59
+ lmstudio: {
60
+ type: 'pi-ai',
61
+ provider: overrides?.provider ?? 'lmstudio',
62
+ model: overrides?.model ?? 'local-model',
63
+ apiKeyEnv: 'LMSTUDIO_API_KEY',
64
+ baseUrl: 'http://localhost:1234/v1',
65
+ },
66
+ },
67
+ internalAgents: {
68
+ defaultRuntime: 'lmstudio',
69
+ agents: {
70
+ diagnostician: {
71
+ enabled: true,
72
+ runtimeProfile: 'lmstudio',
73
+ },
74
+ },
75
+ },
76
+ };
77
+ }
78
+
79
+ // ── Tests ──────────────────────────────────────────────────────────────────
80
+
81
+ describe('PRI-393: runtime config unification', () => {
82
+ let tmpDir: string;
83
+
84
+ beforeEach(() => {
85
+ tmpDir = mkTmpDir();
86
+ });
87
+
88
+ afterEach(() => {
89
+ rmTmpDir(tmpDir);
90
+ });
91
+
92
+ describe('Guard: legacy resolveRuntimeConfig not imported by production commands', () => {
93
+ it('production command source files do NOT import legacy resolveRuntimeConfig', () => {
94
+ // Read the source of each production command and verify the import
95
+ const commandFiles = [
96
+ 'packages/pd-cli/src/commands/runtime.ts',
97
+ 'packages/pd-cli/src/commands/runtime-internalization-run-once.ts',
98
+ 'packages/pd-cli/src/commands/diagnose.ts',
99
+ 'packages/pd-cli/src/commands/pain-retry.ts',
100
+ ];
101
+
102
+ for (const file of commandFiles) {
103
+ const fullPath = path.resolve(file);
104
+ if (!fs.existsSync(fullPath)) continue;
105
+ const source = fs.readFileSync(fullPath, 'utf8');
106
+
107
+ // Check that resolveRuntimeConfig is NOT imported from @principles/core/runtime-v2
108
+ // (it may appear in comments or as resolveRuntimeConfigFromPdConfig)
109
+ // Match the full import block (handles multi-line imports)
110
+ const importPattern = /import\s*[\s\S]*?@principles\/core\/runtime-v2['";]/g;
111
+ const importBlocks: string[] = [];
112
+ let match;
113
+ while ((match = importPattern.exec(source)) !== null) {
114
+ importBlocks.push(match[0]);
115
+ }
116
+
117
+ for (const block of importBlocks) {
118
+ // Allow resolveRuntimeConfigFromPdConfig but NOT bare resolveRuntimeConfig
119
+ if (block.includes('resolveRuntimeConfig') && !block.includes('resolveRuntimeConfigFromPdConfig')) {
120
+ // This is the legacy import — fail
121
+ expect.fail(
122
+ `${file} still imports legacy resolveRuntimeConfig from @principles/core/runtime-v2. ` +
123
+ `Use resolveRuntimeFromPdConfig() from services/resolve-runtime-from-pd-config.ts instead.`,
124
+ );
125
+ }
126
+ }
127
+ }
128
+ });
129
+ });
130
+
131
+ describe('Config source alignment via mainline contract', () => {
132
+ function makeBaseSnapshot(readiness: Partial<MainlineSnapshot['readiness']>): MainlineSnapshot {
133
+ return {
134
+ readiness: {
135
+ configDoctorProfile: null,
136
+ runtimeProbeProfile: null,
137
+ configSource: '.pd/config.yaml',
138
+ probeConfigSource: '.pd/config.yaml',
139
+ diagnosticianReady: true,
140
+ ...readiness,
141
+ },
142
+ chain: {
143
+ painId: null,
144
+ diagnosisTask: null,
145
+ diagnosticianArtifact: null,
146
+ candidate: null,
147
+ dreamerTask: null,
148
+ dreamerContext: null,
149
+ successor: null,
150
+ principle: null,
151
+ },
152
+ };
153
+ }
154
+
155
+ it('violation: doctor and probe use different config sources (drift)', () => {
156
+ const snapshot = makeBaseSnapshot({
157
+ configDoctorProfile: 'pi-ai.lmstudio',
158
+ runtimeProbeProfile: 'pi-ai.sensenova-cn',
159
+ configSource: '.pd/config.yaml',
160
+ probeConfigSource: '.state/workflows.yaml',
161
+ });
162
+
163
+ const verdict = assertMainlineContract(snapshot);
164
+ const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
165
+
166
+ expect(alignmentStage).toBeDefined();
167
+ expect(alignmentStage!.status).toBe('violation');
168
+ expect(alignmentStage!.reason).toContain('drift');
169
+ expect(alignmentStage!.nextAction).toContain('.pd/config.yaml');
170
+ });
171
+
172
+ it('violation: profiles match but probe reads from workflows.yaml (coincidental)', () => {
173
+ const snapshot = makeBaseSnapshot({
174
+ configDoctorProfile: 'pi-ai.lmstudio',
175
+ runtimeProbeProfile: 'pi-ai.lmstudio',
176
+ configSource: '.pd/config.yaml',
177
+ probeConfigSource: '.state/workflows.yaml',
178
+ });
179
+
180
+ const verdict = assertMainlineContract(snapshot);
181
+ const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
182
+
183
+ expect(alignmentStage).toBeDefined();
184
+ expect(alignmentStage!.status).toBe('violation');
185
+ expect(alignmentStage!.reason).toContain('coincidental');
186
+ });
187
+
188
+ it('ok: doctor and probe agree on same profile from .pd/config.yaml', () => {
189
+ const snapshot = makeBaseSnapshot({
190
+ configDoctorProfile: 'pi-ai.lmstudio',
191
+ runtimeProbeProfile: 'pi-ai.lmstudio',
192
+ configSource: '.pd/config.yaml',
193
+ probeConfigSource: '.pd/config.yaml',
194
+ });
195
+
196
+ const verdict = assertMainlineContract(snapshot);
197
+ const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
198
+
199
+ expect(alignmentStage).toBeDefined();
200
+ expect(alignmentStage!.status).toBe('ok');
201
+ expect(alignmentStage!.reason).toContain('.pd/config.yaml');
202
+ });
203
+ });
204
+
205
+ describe('resolveRuntimeFromPdConfig reads .pd/config.yaml', () => {
206
+ it('resolves pi-ai config from .pd/config.yaml', async () => {
207
+ writeConfigYaml(tmpDir, makeValidConfigYaml());
208
+
209
+ // Dynamically import to avoid module resolution issues
210
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
211
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
212
+
213
+ expect(resolved.configSource).toBe('.pd/config.yaml');
214
+ expect(resolved.result).toBeDefined();
215
+ // Should not be an error when config is valid
216
+ const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
217
+ expect(isErr(resolved.result)).toBe(false);
218
+ });
219
+
220
+ it('ignores conflicting .state/workflows.yaml when .pd/config.yaml exists', async () => {
221
+ // Write .pd/config.yaml with lmstudio
222
+ writeConfigYaml(tmpDir, makeValidConfigYaml({ provider: 'lmstudio', model: 'local-model' }));
223
+
224
+ // Write conflicting .state/workflows.yaml
225
+ writeLegacyWorkflowsYaml(tmpDir, `version: '1'
226
+ funnels:
227
+ - workflowId: pd-runtime-v2-diagnosis
228
+ stages: []
229
+ policy:
230
+ runtimeKind: pi-ai
231
+ provider: sensenova-cn
232
+ model: deepseek-v4-flash
233
+ apiKeyEnv: SENSENOVA_API_KEY
234
+ `);
235
+
236
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
237
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
238
+
239
+ // Should have legacy warning
240
+ expect(resolved.legacyWarnings.length).toBeGreaterThan(0);
241
+ expect(resolved.legacyWarnings[0]).toContain('workflows.yaml');
242
+
243
+ // Should resolve from .pd/config.yaml, NOT workflows.yaml
244
+ const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
245
+ if (!isErr(resolved.result)) {
246
+ expect(resolved.result.provider).not.toBe('sensenova-cn');
247
+ }
248
+ expect(resolved.configSource).toBe('.pd/config.yaml');
249
+ });
250
+
251
+ it('fail loud when .pd/config.yaml is malformed', async () => {
252
+ const pdDir = path.join(tmpDir, '.pd');
253
+ fs.mkdirSync(pdDir, { recursive: true });
254
+ fs.writeFileSync(path.join(pdDir, 'config.yaml'), 'version: [unterminated', 'utf8');
255
+
256
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
257
+ const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
258
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
259
+
260
+ // Malformed config must produce a RuntimeConfigError — never fall back to defaults
261
+ expect(resolved.configLoadResult.ok).toBe(false);
262
+ expect(resolved.configSource).toBe('.pd/config.yaml');
263
+ expect(isErr(resolved.result)).toBe(true);
264
+ if (isErr(resolved.result)) {
265
+ expect(resolved.result.reason).toContain('config_malformed');
266
+ expect(resolved.result.nextAction).toBeTruthy();
267
+ }
268
+ });
269
+ });
270
+
271
+ describe('JSON output purity', () => {
272
+ it('resolveRuntimeFromPdConfig result serializes to valid JSON', async () => {
273
+ writeConfigYaml(tmpDir, makeValidConfigYaml());
274
+
275
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
276
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
277
+
278
+ const jsonStr = JSON.stringify(resolved.result);
279
+ const parsed = JSON.parse(jsonStr);
280
+ expect(parsed).toBeDefined();
281
+ expect(typeof parsed).toBe('object');
282
+ });
283
+ });
284
+ });
@@ -1,11 +1,18 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as path from 'path';
2
3
 
3
4
  const mockCheck = vi.hoisted(() => vi.fn());
5
+ const mockAssemble = vi.hoisted(() => vi.fn());
4
6
 
5
7
  vi.mock('../../src/resolve-workspace.js', () => ({
6
8
  resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
7
9
  }));
8
10
 
11
+ vi.mock('../../src/services/mainline-snapshot-assembler.js', () => ({
12
+ assembleMainlineSnapshot: mockAssemble,
13
+ assertMainlineContract: vi.fn(),
14
+ }));
15
+
9
16
  vi.mock('@principles/core/runtime-v2', () => ({
10
17
  InternalizationChainIntegrityReadModel: vi.fn().mockImplementation(function () {
11
18
  return { check: mockCheck };
@@ -14,9 +21,32 @@ vi.mock('@principles/core/runtime-v2', () => ({
14
21
  }));
15
22
 
16
23
  import { handleRuntimeInternalizationIntegrity } from '../../src/commands/runtime-internalization-integrity.js';
24
+ import { InternalizationChainIntegrityReadModel } from '@principles/core/runtime-v2';
17
25
 
18
26
  const WS = '/fake/workspace';
19
27
 
28
+ function fakeSnapshot() {
29
+ return {
30
+ readiness: {
31
+ configDoctorProfile: 'openclaw.default',
32
+ runtimeProbeProfile: 'openclaw.default',
33
+ configSource: '.pd/config.yaml',
34
+ probeConfigSource: '.pd/config.yaml',
35
+ diagnosticianReady: true,
36
+ },
37
+ chain: {
38
+ painId: 'pain-test',
39
+ diagnosisTask: null,
40
+ diagnosticianArtifact: null,
41
+ candidate: null,
42
+ dreamerTask: null,
43
+ dreamerContext: null,
44
+ successor: null,
45
+ principle: null,
46
+ },
47
+ };
48
+ }
49
+
20
50
  function okResult() {
21
51
  return {
22
52
  overallStatus: 'ok' as const,
@@ -59,6 +89,7 @@ describe('handleRuntimeInternalizationIntegrity', () => {
59
89
 
60
90
  beforeEach(() => {
61
91
  vi.clearAllMocks();
92
+ mockAssemble.mockResolvedValue({ snapshot: fakeSnapshot(), warnings: [], resolvedPainId: 'pain-test' });
62
93
  consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
63
94
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
64
95
  });
@@ -68,6 +99,12 @@ describe('handleRuntimeInternalizationIntegrity', () => {
68
99
 
69
100
  await handleRuntimeInternalizationIntegrity({ workspace: WS, json: true });
70
101
 
102
+ const resolvedWorkspace = path.resolve(WS);
103
+ expect(mockAssemble).toHaveBeenCalledWith({ workspaceDir: resolvedWorkspace });
104
+ const modelCallArgs = (InternalizationChainIntegrityReadModel as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0];
105
+ expect(modelCallArgs.workspaceDir).toBe(resolvedWorkspace);
106
+ expect(modelCallArgs.mainlineSnapshot).toEqual(fakeSnapshot());
107
+
71
108
  const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
72
109
  expect(jsonOutput.overallStatus).toBe('ok');
73
110
  expect(jsonOutput.brokenLinks).toEqual([]);
@@ -20,6 +20,27 @@ vi.mock('../../src/resolve-workspace.js', () => ({
20
20
  resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
21
21
  }));
22
22
 
23
+ const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
24
+ const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
25
+ result: {
26
+ runtimeKind: 'pi-ai',
27
+ provider: 'test-provider',
28
+ model: 'test-model',
29
+ apiKeyEnv: 'TEST_API_KEY',
30
+ timeoutMs: 300_000,
31
+ agentId: 'main',
32
+ },
33
+ legacyWarnings: [],
34
+ configSource: '.pd/config.yaml',
35
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
36
+ });
37
+ return { mockResolveRuntimeFromPdConfig };
38
+ });
39
+
40
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
41
+ resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
42
+ }));
43
+
23
44
  vi.mock('@principles/core/runtime-v2', () => ({
24
45
  RuntimeStateManager: vi.fn().mockImplementation(function () {
25
46
  return {
@@ -120,8 +141,9 @@ vi.mock('@principles/core/runtime-v2', () => ({
120
141
  model: 'test-model',
121
142
  apiKeyEnv: 'TEST_API_KEY',
122
143
  }),
123
- isRuntimeConfigError: vi.fn().mockReturnValue(false),
144
+ isRuntimeConfigError: vi.fn().mockImplementation((result: unknown) => result != null && typeof result === 'object' && Object.hasOwn(result, 'reason') && !Object.hasOwn(result, 'runtimeKind')),
124
145
  validateRuntimeConfig: vi.fn(),
146
+ resolveRuntimeConfigFromPdConfig: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
125
147
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
126
148
  }));
127
149
 
@@ -381,12 +403,16 @@ describe('handleRuntimeInternalizationRunOnce', () => {
381
403
  });
382
404
 
383
405
  it('--runtime openclaw-cli resolves OpenClawCliRuntimeAdapter', async () => {
384
- const { resolveRuntimeConfig } = await import('@principles/core/runtime-v2');
385
- vi.mocked(resolveRuntimeConfig).mockReturnValue({
386
- runtimeKind: 'openclaw-cli',
387
- openclawMode: 'local',
388
- timeoutMs: 300_000,
389
- agentId: 'main',
406
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
407
+ result: {
408
+ runtimeKind: 'openclaw-cli',
409
+ openclawMode: 'local',
410
+ timeoutMs: 300_000,
411
+ agentId: 'main',
412
+ },
413
+ legacyWarnings: [],
414
+ configSource: '.pd/config.yaml',
415
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
390
416
  });
391
417
 
392
418
  mockWakeOnce.mockResolvedValue({
@@ -412,31 +438,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
412
438
  expect(OpenClawMock).toHaveBeenCalled();
413
439
  });
414
440
 
415
- it('--runtime config resolves adapter from workflows.yaml', async () => {
416
- mockWakeOnce.mockResolvedValue({
417
- decision: 'would_lease',
418
- taskId: 'task-dreamer-008',
419
- taskKind: 'dreamer',
420
- });
421
-
422
- mockRun.mockResolvedValue({
423
- status: 'succeeded',
424
- taskId: 'task-dreamer-008',
425
- runId: 'run-008',
426
- artifactId: 'pi-art-task-dreamer-008-run-008',
427
- resultRef: 'dreamer://run-008',
428
- attemptCount: 1,
429
- });
430
-
431
- await handleRuntimeInternalizationRunOnce({ workspace: WS, runtime: 'config', json: true });
432
-
433
- const ResolveConfigMock = vi.mocked(
434
- await import('@principles/core/runtime-v2').then(m => m.resolveRuntimeConfig),
435
- );
436
- expect(ResolveConfigMock).toHaveBeenCalled();
437
- });
438
-
439
- it('--runtime config reads from workspaceDir/.state (not .pd)', async () => {
441
+ it('--runtime config resolves adapter from .pd/config.yaml with workspace path (PRI-393)', async () => {
440
442
  mockWakeOnce.mockResolvedValue({
441
443
  decision: 'would_lease',
442
444
  taskId: 'task-dreamer-009',
@@ -455,12 +457,10 @@ describe('handleRuntimeInternalizationRunOnce', () => {
455
457
  const customWs = '/tmp/test-workspace';
456
458
  await handleRuntimeInternalizationRunOnce({ workspace: customWs, runtime: 'config', json: true });
457
459
 
458
- const ResolveConfigMock = vi.mocked(
459
- await import('@principles/core/runtime-v2').then(m => m.resolveRuntimeConfig),
460
+ // PRI-393: verify resolveRuntimeFromPdConfig was called with workspace dir
461
+ expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith(
462
+ expect.stringContaining('test-workspace'),
460
463
  );
461
- const resolvedWorkspace = path.resolve(customWs);
462
- const expectedStateDir = path.join(resolvedWorkspace, '.state');
463
- expect(ResolveConfigMock).toHaveBeenCalledWith(expectedStateDir, { requestedRuntimeKind: 'config' });
464
464
  });
465
465
 
466
466
  it('--runner philosopher dispatches PhilosopherRunner', async () => {
@@ -810,7 +810,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
810
810
  expect(output.timeoutSource).toBe('runner_poll');
811
811
  });
812
812
 
813
- it('--timeout-ms overrides workflows.yaml timeoutMs for PiAiRuntimeAdapter', async () => {
813
+ it('--timeout-ms overrides .pd/config.yaml timeoutMs for PiAiRuntimeAdapter (PRI-393)', async () => {
814
814
  mockWakeOnce.mockResolvedValue({
815
815
  decision: 'would_lease',
816
816
  taskId: 'task-dreamer-ov',
@@ -1258,14 +1258,17 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1258
1258
  });
1259
1259
 
1260
1260
  it('--runtime config with missing config outputs structured JSON error', async () => {
1261
- const { resolveRuntimeConfig, isRuntimeConfigError } = await import('@principles/core/runtime-v2');
1262
- vi.mocked(resolveRuntimeConfig).mockReturnValue({
1263
- ok: false,
1264
- reason: 'explicit_config_missing',
1265
- message: 'runtime=config requested but no workflows.yaml funnel policy found',
1266
- nextAction: 'Create a pd-runtime-v2-diagnosis funnel policy in workflows.yaml',
1261
+ // PRI-393: mock resolveRuntimeFromPdConfig to return config error
1262
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
1263
+ result: {
1264
+ reason: 'explicit_config_missing',
1265
+ message: 'runtime=config requested but no .pd/config.yaml runtime binding found',
1266
+ nextAction: 'Add runtime binding to .pd/config.yaml',
1267
+ },
1268
+ legacyWarnings: [],
1269
+ configSource: '.pd/config.yaml',
1270
+ configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
1267
1271
  });
1268
- vi.mocked(isRuntimeConfigError).mockReturnValue(true);
1269
1272
 
1270
1273
  mockWakeOnce.mockResolvedValue({
1271
1274
  decision: 'would_lease',
@@ -1283,14 +1286,17 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1283
1286
  });
1284
1287
 
1285
1288
  it('--runtime config with missing config outputs text error', async () => {
1286
- const { resolveRuntimeConfig, isRuntimeConfigError } = await import('@principles/core/runtime-v2');
1287
- vi.mocked(resolveRuntimeConfig).mockReturnValue({
1288
- ok: false,
1289
- reason: 'explicit_config_missing',
1290
- message: 'runtime=config requested but no workflows.yaml funnel policy found',
1291
- nextAction: 'Create a pd-runtime-v2-diagnosis funnel policy in workflows.yaml',
1292
- });
1293
- vi.mocked(isRuntimeConfigError).mockReturnValue(true);
1289
+ // PRI-393: mock resolveRuntimeFromPdConfig to return config error
1290
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
1291
+ result: {
1292
+ reason: 'explicit_config_missing',
1293
+ message: 'runtime=config requested but no .pd/config.yaml runtime binding found',
1294
+ nextAction: 'Add runtime binding to .pd/config.yaml',
1295
+ },
1296
+ legacyWarnings: [],
1297
+ configSource: '.pd/config.yaml',
1298
+ configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
1299
+ });
1294
1300
 
1295
1301
  mockWakeOnce.mockResolvedValue({
1296
1302
  decision: 'would_lease',
@@ -1,7 +1,36 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { handleRuntimeProbe, type RuntimeProbeOptions } from '../../src/commands/runtime.js';
3
3
 
4
- // Mock probeRuntime
4
+ // Mock resolveRuntimeWithOverrides
5
+ const { mockResolveRuntimeWithOverrides } = vi.hoisted(() => {
6
+ const fn = vi.fn().mockReturnValue({
7
+ result: {
8
+ runtimeKind: 'pi-ai',
9
+ provider: 'test-provider',
10
+ model: 'test-model',
11
+ apiKeyEnv: 'TEST_API_KEY',
12
+ maxRetries: 2,
13
+ timeoutMs: 180_000,
14
+ },
15
+ mergedConfig: {
16
+ runtimeKind: 'pi-ai',
17
+ provider: 'test-provider',
18
+ model: 'test-model',
19
+ apiKeyEnv: 'TEST_API_KEY',
20
+ maxRetries: 2,
21
+ timeoutMs: 180_000,
22
+ },
23
+ legacyWarnings: [],
24
+ configSource: '.pd/config.yaml',
25
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
26
+ });
27
+ return { mockResolveRuntimeWithOverrides: fn };
28
+ });
29
+
30
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
31
+ resolveRuntimeWithOverrides: mockResolveRuntimeWithOverrides,
32
+ }));
33
+
5
34
  vi.mock('@principles/core/runtime-v2', () => ({
6
35
  probeRuntime: vi.fn().mockResolvedValue({
7
36
  runtimeKind: 'openclaw-cli',
@@ -260,4 +289,98 @@ describe('pd runtime probe', () => {
260
289
  consoleErrorSpy.mockRestore();
261
290
  exitSpy.mockRestore();
262
291
  });
292
+
293
+ // ── pi-ai probe: maxRetries backfill from .pd/config.yaml ───────────────
294
+ it('PRI-393: pi-ai probe reads maxRetries from .pd/config.yaml when --maxRetries not passed', async () => {
295
+ process.env.TEST_API_KEY = 'test-value';
296
+ const { probeRuntime } = await import('@principles/core/runtime-v2');
297
+ vi.mocked(probeRuntime).mockResolvedValue({
298
+ runtimeKind: 'pi-ai',
299
+ provider: 'test-provider',
300
+ model: 'test-model',
301
+ health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-06-14T00:00:00.000Z' },
302
+ capabilities: {},
303
+ });
304
+
305
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
306
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
307
+
308
+ // mockResolveRuntimeWithOverrides returns maxRetries: 2
309
+ // No --maxRetries CLI flag
310
+ await handleRuntimeProbe({
311
+ runtime: 'pi-ai',
312
+ provider: 'test-provider',
313
+ model: 'test-model',
314
+ apiKeyEnv: 'TEST_API_KEY',
315
+ workspace: '/tmp/ws',
316
+ json: true,
317
+ } as RuntimeProbeOptions);
318
+
319
+ expect(vi.mocked(probeRuntime)).toHaveBeenCalledWith(
320
+ expect.objectContaining({ maxRetries: 2 }),
321
+ );
322
+
323
+ delete process.env.TEST_API_KEY;
324
+ consoleSpy.mockRestore();
325
+ exitSpy.mockRestore();
326
+ });
327
+
328
+ it('PRI-393: CLI --maxRetries overrides .pd/config.yaml maxRetries', async () => {
329
+ process.env.TEST_API_KEY = 'test-value';
330
+ const { probeRuntime } = await import('@principles/core/runtime-v2');
331
+ vi.mocked(probeRuntime).mockResolvedValue({
332
+ runtimeKind: 'pi-ai',
333
+ provider: 'test-provider',
334
+ model: 'test-model',
335
+ health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-06-14T00:00:00.000Z' },
336
+ capabilities: {},
337
+ });
338
+
339
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
340
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
341
+
342
+ await handleRuntimeProbe({
343
+ runtime: 'pi-ai',
344
+ provider: 'test-provider',
345
+ model: 'test-model',
346
+ apiKeyEnv: 'TEST_API_KEY',
347
+ maxRetries: 5,
348
+ workspace: '/tmp/ws',
349
+ json: true,
350
+ } as RuntimeProbeOptions);
351
+
352
+ expect(vi.mocked(probeRuntime)).toHaveBeenCalledWith(
353
+ expect.objectContaining({ maxRetries: 5 }),
354
+ );
355
+
356
+ delete process.env.TEST_API_KEY;
357
+ consoleSpy.mockRestore();
358
+ exitSpy.mockRestore();
359
+ });
360
+
361
+ it('PRI-393: env var missing → process.exit(1) and does NOT call probeRuntime', async () => {
362
+ const { probeRuntime } = await import('@principles/core/runtime-v2');
363
+ vi.mocked(probeRuntime).mockClear();
364
+
365
+ // Ensure NONEXISTENT_VAR is NOT set
366
+ delete process.env.NONEXISTENT_VAR;
367
+
368
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
369
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
370
+
371
+ await handleRuntimeProbe({
372
+ runtime: 'pi-ai',
373
+ provider: 'test-provider',
374
+ model: 'test-model',
375
+ apiKeyEnv: 'NONEXISTENT_VAR',
376
+ json: true,
377
+ } as RuntimeProbeOptions);
378
+
379
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('NONEXISTENT_VAR'));
380
+ expect(exitSpy).toHaveBeenCalledWith(1);
381
+ expect(vi.mocked(probeRuntime)).not.toHaveBeenCalled();
382
+
383
+ consoleErrorSpy.mockRestore();
384
+ exitSpy.mockRestore();
385
+ });
263
386
  });