@principles/pd-cli 1.100.0 → 1.102.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 (33) 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-run-once.d.ts.map +1 -1
  7. package/dist/commands/runtime-internalization-run-once.js +11 -9
  8. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  9. package/dist/commands/runtime.d.ts +1 -1
  10. package/dist/commands/runtime.d.ts.map +1 -1
  11. package/dist/commands/runtime.js +92 -25
  12. package/dist/commands/runtime.js.map +1 -1
  13. package/dist/commands/task.js +2 -2
  14. package/dist/commands/task.js.map +1 -1
  15. package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
  16. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
  17. package/dist/services/resolve-runtime-from-pd-config.js +96 -0
  18. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/commands/diagnose.ts +26 -26
  21. package/src/commands/pain-retry.ts +21 -25
  22. package/src/commands/runtime-internalization-run-once.ts +10 -9
  23. package/src/commands/runtime.ts +96 -24
  24. package/src/commands/task.ts +2 -2
  25. package/src/services/resolve-runtime-from-pd-config.ts +142 -0
  26. package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
  27. package/tests/commands/diagnose.test.ts +91 -39
  28. package/tests/commands/pain-retry.test.ts +130 -15
  29. package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
  30. package/tests/commands/runtime-internalization-integrity-repair.test.ts +38 -0
  31. package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
  32. package/tests/commands/runtime.test.ts +124 -1
  33. package/tests/commands/task.test.ts +9 -1
@@ -41,6 +41,23 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
41
41
  return { MockPrincipleTreeLedgerAdapter };
42
42
  });
43
43
 
44
+ const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
45
+ const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
46
+ result: {
47
+ runtimeKind: 'pi-ai',
48
+ provider: 'test-provider',
49
+ model: 'test-model',
50
+ apiKeyEnv: 'TEST_KEY',
51
+ timeoutMs: 300000,
52
+ agentId: 'main',
53
+ },
54
+ legacyWarnings: [],
55
+ configSource: '.pd/config.yaml',
56
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
57
+ });
58
+ return { mockResolveRuntimeFromPdConfig };
59
+ });
60
+
44
61
  vi.mock('../../src/resolve-workspace.js', () => ({
45
62
  resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
46
63
  }));
@@ -122,6 +139,10 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
122
139
  computeFlagsFromLoadResult: vi.fn().mockReturnValue({}),
123
140
  }));
124
141
 
142
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
143
+ resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
144
+ }));
145
+
125
146
  import { handleDiagnoseRun, handleDiagnoseStatus, type DiagnoseRunOptions } from '../../src/commands/diagnose.js';
126
147
 
127
148
  const SUCCEEDED_RESULT = {
@@ -177,14 +198,18 @@ describe('pd diagnose run --runtime routing', () => {
177
198
  exitSpy.mockRestore();
178
199
  });
179
200
 
180
- it('HG-03: --runtime openclaw-cli without mode (no file config) fails via resolveRuntimeConfig', async () => {
181
- mockResolveRuntimeConfig.mockReturnValueOnce({
182
- ok: false,
183
- reason: 'missing_openclaw_mode',
184
- message: 'runtimeKind is openclaw-cli but no mode specified',
185
- nextAction: 'Provide exactly one mode',
201
+ it('HG-03: --runtime openclaw-cli without mode (no file config) fails via resolveRuntimeFromPdConfig', async () => {
202
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
203
+ result: {
204
+ ok: false,
205
+ reason: 'missing_openclaw_mode',
206
+ message: 'runtimeKind is openclaw-cli but no mode specified',
207
+ nextAction: 'Provide exactly one mode',
208
+ },
209
+ legacyWarnings: [],
210
+ configSource: '.pd/config.yaml',
211
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
186
212
  });
187
- mockIsRuntimeConfigError.mockReturnValueOnce(true);
188
213
 
189
214
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
190
215
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -196,7 +221,7 @@ describe('pd diagnose run --runtime routing', () => {
196
221
  json: false,
197
222
  } as DiagnoseRunOptions);
198
223
 
199
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no mode specified'));
224
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no mode resolved'));
200
225
  expect(exitSpy).toHaveBeenCalledWith(1);
201
226
 
202
227
  consoleErrorSpy.mockRestore();
@@ -226,13 +251,17 @@ describe('pd diagnose run --runtime routing', () => {
226
251
  });
227
252
 
228
253
  it('DPB-09: openclaw-cli with file config openclawMode succeeds without CLI flag', async () => {
229
- mockResolveRuntimeConfig.mockReturnValueOnce({
230
- runtimeKind: 'openclaw-cli',
231
- openclawMode: 'local',
232
- timeoutMs: 300000,
233
- agentId: 'main',
254
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
255
+ result: {
256
+ runtimeKind: 'openclaw-cli',
257
+ openclawMode: 'local',
258
+ timeoutMs: 300000,
259
+ agentId: 'main',
260
+ },
261
+ legacyWarnings: [],
262
+ configSource: '.pd/config.yaml',
263
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
234
264
  });
235
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
236
265
 
237
266
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
238
267
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -250,14 +279,18 @@ describe('pd diagnose run --runtime routing', () => {
250
279
  exitSpy.mockRestore();
251
280
  });
252
281
 
253
- it('DPB-09: openclaw-cli flag overrides file config mode', async () => {
254
- mockResolveRuntimeConfig.mockReturnValueOnce({
255
- runtimeKind: 'openclaw-cli',
256
- openclawMode: 'gateway',
257
- timeoutMs: 300000,
258
- agentId: 'main',
282
+ it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
283
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
284
+ result: {
285
+ runtimeKind: 'openclaw-cli',
286
+ openclawMode: 'gateway',
287
+ timeoutMs: 300000,
288
+ agentId: 'main',
289
+ },
290
+ legacyWarnings: [],
291
+ configSource: '.pd/config.yaml',
292
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
259
293
  });
260
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
261
294
 
262
295
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
263
296
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -270,6 +303,13 @@ describe('pd diagnose run --runtime routing', () => {
270
303
  json: false,
271
304
  } as DiagnoseRunOptions);
272
305
 
306
+ // Flag override: config says gateway, flag says local → adapter gets local
307
+ const OpenClawCliMock = vi.mocked(
308
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
309
+ );
310
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
311
+ expect.objectContaining({ runtimeMode: 'local' }),
312
+ );
273
313
  expect(exitSpy).not.toHaveBeenCalledWith(1);
274
314
 
275
315
  consoleSpy.mockRestore();
@@ -277,13 +317,17 @@ describe('pd diagnose run --runtime routing', () => {
277
317
  });
278
318
 
279
319
  it('DPB-09: openclaw-cli missing mode (--json) outputs JSON error', async () => {
280
- mockResolveRuntimeConfig.mockReturnValueOnce({
281
- ok: false,
282
- reason: 'missing_openclaw_mode',
283
- message: 'runtimeKind is openclaw-cli but no mode specified',
284
- nextAction: 'Provide exactly one mode',
320
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
321
+ result: {
322
+ runtimeKind: 'openclaw-cli',
323
+ openclawMode: undefined,
324
+ timeoutMs: 300000,
325
+ agentId: 'main',
326
+ },
327
+ legacyWarnings: [],
328
+ configSource: '.pd/config.yaml',
329
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
285
330
  });
286
- mockIsRuntimeConfigError.mockReturnValueOnce(true);
287
331
 
288
332
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
289
333
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -323,13 +367,17 @@ describe('pd diagnose run --runtime routing', () => {
323
367
  });
324
368
 
325
369
  it('DPB-09: openclaw-cli --openclaw-gateway constructs adapter with runtimeMode=gateway', async () => {
326
- mockResolveRuntimeConfig.mockReturnValueOnce({
327
- runtimeKind: 'openclaw-cli',
328
- openclawMode: 'gateway',
329
- timeoutMs: 300000,
330
- agentId: 'main',
370
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
371
+ result: {
372
+ runtimeKind: 'openclaw-cli',
373
+ openclawMode: 'gateway',
374
+ timeoutMs: 300000,
375
+ agentId: 'main',
376
+ },
377
+ legacyWarnings: [],
378
+ configSource: '.pd/config.yaml',
379
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
331
380
  });
332
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
333
381
 
334
382
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
335
383
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -355,13 +403,17 @@ describe('pd diagnose run --runtime routing', () => {
355
403
  });
356
404
 
357
405
  it('DPB-09: openclaw-cli --openclaw-local constructs adapter with runtimeMode=local', async () => {
358
- mockResolveRuntimeConfig.mockReturnValueOnce({
359
- runtimeKind: 'openclaw-cli',
360
- openclawMode: 'local',
361
- timeoutMs: 300000,
362
- agentId: 'main',
406
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
407
+ result: {
408
+ runtimeKind: 'openclaw-cli',
409
+ openclawMode: 'local',
410
+ timeoutMs: 300000,
411
+ agentId: 'main',
412
+ },
413
+ legacyWarnings: [],
414
+ configSource: '.pd/config.yaml',
415
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
363
416
  });
364
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
365
417
 
366
418
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
367
419
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -55,7 +55,7 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
55
55
  return { MockPrincipleTreeLedgerAdapter };
56
56
  });
57
57
 
58
- const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
58
+ const { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
59
59
  const mockRun = vi.fn().mockResolvedValue({
60
60
  status: 'succeeded',
61
61
  taskId: 'diagnosis_test-pain-1',
@@ -70,7 +70,20 @@ const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
70
70
  timeoutMs: 300000,
71
71
  agentId: 'main',
72
72
  });
73
- return { mockRun, mockResolveRuntimeConfig };
73
+ const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
74
+ result: {
75
+ runtimeKind: 'pi-ai',
76
+ provider: 'test-provider',
77
+ model: 'test-model',
78
+ apiKeyEnv: 'TEST_KEY',
79
+ timeoutMs: 300000,
80
+ agentId: 'main',
81
+ },
82
+ legacyWarnings: [],
83
+ configSource: '.pd/config.yaml',
84
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
85
+ });
86
+ return { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig };
74
87
  });
75
88
 
76
89
  vi.mock('../../src/resolve-workspace.js', () => ({
@@ -128,6 +141,10 @@ vi.mock('../../src/config-reader.js', () => ({
128
141
  readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
129
142
  }));
130
143
 
144
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
145
+ resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
146
+ }));
147
+
131
148
  import { handlePainRetry } from '../../src/commands/pain-retry.js';
132
149
 
133
150
  // ── Test Data ──────────────────────────────────────────────────────────────────
@@ -201,6 +218,19 @@ describe('pd pain retry — validation and error paths', () => {
201
218
  timeoutMs: 300000,
202
219
  agentId: 'main',
203
220
  });
221
+ mockResolveRuntimeFromPdConfig.mockReturnValue({
222
+ result: {
223
+ runtimeKind: 'pi-ai',
224
+ provider: 'test-provider',
225
+ model: 'test-model',
226
+ apiKeyEnv: 'TEST_KEY',
227
+ timeoutMs: 300000,
228
+ agentId: 'main',
229
+ },
230
+ legacyWarnings: [],
231
+ configSource: '.pd/config.yaml',
232
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
233
+ });
204
234
  mockGetTask.mockResolvedValue(null);
205
235
  mockGetCandidatesByTaskId.mockResolvedValue([]);
206
236
  mockUpdateCandidateStatus.mockResolvedValue(undefined);
@@ -342,11 +372,16 @@ describe('pd pain retry — validation and error paths', () => {
342
372
 
343
373
  it('RETRY-05a: missing --runtime and no config — refused with reason + nextAction (JSON)', async () => {
344
374
  mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
345
- // Make resolveRuntimeConfig return an error so no runtime is resolved from config
346
- mockResolveRuntimeConfig.mockReturnValueOnce({
347
- reason: 'config_not_found',
348
- message: 'No workflows.yaml found',
349
- nextAction: 'Create workflows.yaml or pass --runtime',
375
+ // PRI-393: resolveRuntimeFromPdConfig returns error no runtime resolved
376
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
377
+ result: {
378
+ reason: 'config_not_found',
379
+ message: 'No .pd/config.yaml found',
380
+ nextAction: 'Create .pd/config.yaml or pass --runtime',
381
+ },
382
+ legacyWarnings: [],
383
+ configSource: '.pd/config.yaml',
384
+ configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
350
385
  });
351
386
 
352
387
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -404,14 +439,19 @@ describe('pd pain retry — validation and error paths', () => {
404
439
 
405
440
  it('RETRY-05c: blank provider/model/apiKeyEnv — refused with missing_required_config', async () => {
406
441
  mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
407
- // Config returns blank strings for provider/model/apiKeyEnv
408
- mockResolveRuntimeConfig.mockReturnValueOnce({
409
- runtimeKind: 'pi-ai',
410
- provider: '',
411
- model: ' ',
412
- apiKeyEnv: '',
413
- timeoutMs: 300000,
414
- agentId: 'main',
442
+ // PRI-393: resolveRuntimeFromPdConfig returns config with blank strings
443
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
444
+ result: {
445
+ runtimeKind: 'pi-ai',
446
+ provider: '',
447
+ model: ' ',
448
+ apiKeyEnv: '',
449
+ timeoutMs: 300000,
450
+ agentId: 'main',
451
+ },
452
+ legacyWarnings: [],
453
+ configSource: '.pd/config.yaml',
454
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
415
455
  });
416
456
 
417
457
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -440,6 +480,81 @@ describe('pd pain retry — validation and error paths', () => {
440
480
  logSpy.mockRestore();
441
481
  exitSpy.mockRestore();
442
482
  });
483
+
484
+ it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
485
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
486
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
487
+ result: {
488
+ runtimeKind: 'openclaw-cli',
489
+ openclawMode: 'gateway',
490
+ timeoutMs: 300000,
491
+ agentId: 'main',
492
+ },
493
+ legacyWarnings: [],
494
+ configSource: '.pd/config.yaml',
495
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
496
+ });
497
+
498
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
499
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
500
+
501
+ await handlePainRetry({
502
+ painId: 'test-pain-1',
503
+ workspace: '/tmp/fake-workspace',
504
+ runtime: 'openclaw-cli',
505
+ openclawLocal: true,
506
+ json: true,
507
+ });
508
+
509
+ // Flag override: config says gateway, flag says local → adapter gets local
510
+ const OpenClawCliMock = vi.mocked(
511
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
512
+ );
513
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
514
+ expect.objectContaining({ runtimeMode: 'local' }),
515
+ );
516
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
517
+
518
+ logSpy.mockRestore();
519
+ exitSpy.mockRestore();
520
+ });
521
+
522
+ it('DPB-09: openclaw-cli flag overrides file config mode (config=local, flag=gateway → runtimeMode=gateway)', async () => {
523
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
524
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
525
+ result: {
526
+ runtimeKind: 'openclaw-cli',
527
+ openclawMode: 'local',
528
+ timeoutMs: 300000,
529
+ agentId: 'main',
530
+ },
531
+ legacyWarnings: [],
532
+ configSource: '.pd/config.yaml',
533
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
534
+ });
535
+
536
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
537
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
538
+
539
+ await handlePainRetry({
540
+ painId: 'test-pain-1',
541
+ workspace: '/tmp/fake-workspace',
542
+ runtime: 'openclaw-cli',
543
+ openclawGateway: true,
544
+ json: true,
545
+ });
546
+
547
+ const OpenClawCliMock = vi.mocked(
548
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
549
+ );
550
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
551
+ expect.objectContaining({ runtimeMode: 'gateway' }),
552
+ );
553
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
554
+
555
+ logSpy.mockRestore();
556
+ exitSpy.mockRestore();
557
+ });
443
558
  });
444
559
 
445
560
  describe('pd pain retry — success paths', () => {
@@ -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
+ });