@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
@@ -82,6 +82,44 @@ describe('handleRuntimeInternalizationIntegrityRepair', () => {
82
82
  expect(jsonOutput.repairedCount).toBe(1);
83
83
  });
84
84
 
85
+ it('emits malformed_run_row quarantine actions as a single JSON object', async () => {
86
+ // Verifies the new malformed-run repair pass flows through the CLI contract
87
+ // unchanged: exactly one JSON object on stdout, with the quarantine action present.
88
+ mockRepair.mockReturnValue(makeResult({
89
+ dryRun: false,
90
+ repairedCount: 1,
91
+ actions: [
92
+ {
93
+ action: 'quarantine_malformed_run',
94
+ targetId: 'run-malf-1',
95
+ taskId: 'task-1',
96
+ type: 'malformed_run_row',
97
+ severity: 'warning',
98
+ previousState: 'queued',
99
+ nextState: 'failed',
100
+ previousStatus: 'queued',
101
+ newStatus: 'failed',
102
+ recommendedAction: 'quarantine_malformed_run',
103
+ reason: 'Run run-malf-1 (task task-1) failed schema validation — quarantined',
104
+ },
105
+ ],
106
+ }));
107
+
108
+ await handleRuntimeInternalizationIntegrityRepair({ workspace: WS, confirm: true, json: true });
109
+
110
+ // Exactly one console.log call (single JSON object, no banners/extra stdout).
111
+ expect(consoleLogSpy).toHaveBeenCalledTimes(1);
112
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
113
+ expect(jsonOutput.repairedCount).toBe(1);
114
+ expect(jsonOutput.actions).toHaveLength(1);
115
+ expect(jsonOutput.actions[0]).toMatchObject({
116
+ type: 'malformed_run_row',
117
+ recommendedAction: 'quarantine_malformed_run',
118
+ newStatus: 'failed',
119
+ targetId: 'run-malf-1',
120
+ });
121
+ });
122
+
85
123
  it('outputs text format when --json not specified', async () => {
86
124
  mockRepair.mockReturnValue(makeResult({ dryRun: true }));
87
125
 
@@ -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
  });
@@ -30,6 +30,7 @@ import { MalformedRunError } from '@principles/core';
30
30
  describe('pd task show command handler', () => {
31
31
  let consoleLogSpy: ReturnType<typeof vi.spyOn>;
32
32
  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
33
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
33
34
  let processExitSpy: ReturnType<typeof vi.spyOn>;
34
35
 
35
36
  beforeEach(() => {
@@ -55,6 +56,7 @@ describe('pd task show command handler', () => {
55
56
  process.exitCode = 0;
56
57
  consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
57
58
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
59
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
58
60
  processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
59
61
  });
60
62
 
@@ -111,8 +113,11 @@ describe('pd task show command handler', () => {
111
113
  runs: [{ runId: 'run-valid' }],
112
114
  degradedRuns: [{ runId: 'run-bad', error: 'runtimeKind missing' }],
113
115
  reason: expect.stringContaining('Malformed schema'),
114
- nextAction: expect.stringContaining('integrity-repair'),
116
+ // Honest nextAction: must NOT promise an auto-repair that doesn't exist,
117
+ // and must point at the real quarantine command (integrity-repair --confirm).
118
+ nextAction: expect.stringContaining('not auto-repaired'),
115
119
  });
120
+ expect(output.nextAction).toContain('integrity-repair --confirm');
116
121
  expect(process.exitCode).toBe(1);
117
122
  expect(processExitSpy).not.toHaveBeenCalled();
118
123
  });
@@ -139,6 +144,9 @@ describe('pd task show command handler', () => {
139
144
 
140
145
  expect(consoleErrorSpy).not.toHaveBeenCalled();
141
146
  expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Task: task-123'));
147
+ // The text-mode warning must also carry the honest nextAction.
148
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('not auto-repaired'));
149
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('integrity-repair --confirm'));
142
150
  expect(process.exitCode).toBe(1);
143
151
  expect(processExitSpy).not.toHaveBeenCalled();
144
152
  });