@principles/pd-cli 1.81.0 → 1.83.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 (45) hide show
  1. package/dist/commands/diagnose.d.ts.map +1 -1
  2. package/dist/commands/diagnose.js +5 -0
  3. package/dist/commands/diagnose.js.map +1 -1
  4. package/dist/commands/pain-retry.d.ts.map +1 -1
  5. package/dist/commands/pain-retry.js +5 -0
  6. package/dist/commands/pain-retry.js.map +1 -1
  7. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  8. package/dist/commands/runtime-internalization-run-once.js +59 -9
  9. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  10. package/dist/config-reader.d.ts +25 -0
  11. package/dist/config-reader.d.ts.map +1 -0
  12. package/dist/config-reader.js +109 -0
  13. package/dist/config-reader.js.map +1 -0
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/commands/diagnose.ts +8 -1
  18. package/src/commands/pain-retry.ts +8 -1
  19. package/src/commands/runtime-internalization-run-once.ts +66 -11
  20. package/src/config-reader.ts +122 -0
  21. package/src/index.ts +1 -1
  22. package/tests/commands/candidate-audit-repair.test.ts +1 -0
  23. package/tests/commands/candidate-intake.test.ts +1 -0
  24. package/tests/commands/candidate-internalization-backfill.test.ts +1 -0
  25. package/tests/commands/candidate-internalize.test.ts +1 -0
  26. package/tests/commands/candidate-route.test.ts +1 -0
  27. package/tests/commands/diagnose.test.ts +5 -0
  28. package/tests/commands/health.test.ts +1 -0
  29. package/tests/commands/pain-record.test.ts +1 -0
  30. package/tests/commands/pain-retry.test.ts +5 -0
  31. package/tests/commands/runtime-activation.test.ts +1 -0
  32. package/tests/commands/runtime-canary.test.ts +1 -0
  33. package/tests/commands/runtime-diagnostics-export.test.ts +1 -0
  34. package/tests/commands/runtime-health-snapshot.test.ts +1 -0
  35. package/tests/commands/runtime-internalization-enqueue-successors.test.ts +1 -0
  36. package/tests/commands/runtime-internalization-integrity-repair.test.ts +1 -0
  37. package/tests/commands/runtime-internalization-integrity.test.ts +1 -0
  38. package/tests/commands/runtime-internalization-queue.test.ts +1 -0
  39. package/tests/commands/runtime-internalization-run-once.test.ts +292 -15
  40. package/tests/commands/runtime-internalization-wake-once.test.ts +1 -0
  41. package/tests/commands/runtime-pruning.test.ts +1 -0
  42. package/tests/commands/runtime-recovery.test.ts +1 -0
  43. package/tests/commands/runtime.test.ts +1 -0
  44. package/tests/commands/trace.test.ts +1 -0
  45. package/tests/config-reader.test.ts +142 -0
@@ -122,6 +122,11 @@ vi.mock('@principles/core/runtime-v2', () => ({
122
122
  }),
123
123
  isRuntimeConfigError: vi.fn().mockReturnValue(false),
124
124
  validateRuntimeConfig: vi.fn(),
125
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
126
+ }));
127
+
128
+ vi.mock('../../src/config-reader.js', () => ({
129
+ readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
125
130
  }));
126
131
 
127
132
  import { handleRuntimeInternalizationRunOnce } from '../../src/commands/runtime-internalization-run-once.js';
@@ -535,7 +540,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
535
540
  expect(text).toContain('resultRef: philosopher://run-phil-002');
536
541
  });
537
542
 
538
- it('successful dreamer + --enqueue-next returns successorTaskId', async () => {
543
+ it('auto-enqueue: successful dreamer returns successor info by default', async () => {
539
544
  mockWakeOnce.mockResolvedValue({
540
545
  decision: 'would_lease',
541
546
  taskId: 'task-dreamer-enq-001',
@@ -564,11 +569,11 @@ describe('handleRuntimeInternalizationRunOnce', () => {
564
569
 
565
570
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
566
571
  expect(output.enqueueDecision).toBe('successor_created');
567
- expect(output.successorTaskId).toBe('task-phil-enq-001');
572
+ expect(output.successorTaskIds![0]).toBe('task-phil-enq-001');
568
573
  expect(output.successorKind).toBe('philosopher');
569
574
  });
570
575
 
571
- it('repeated --enqueue-next returns existing successorTaskId', async () => {
576
+ it('auto-enqueue: repeated run returns existing successorTaskId', async () => {
572
577
  mockWakeOnce.mockResolvedValue({
573
578
  decision: 'would_lease',
574
579
  taskId: 'task-dreamer-enq-002',
@@ -597,11 +602,11 @@ describe('handleRuntimeInternalizationRunOnce', () => {
597
602
 
598
603
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
599
604
  expect(output.enqueueDecision).toBe('successor_exists');
600
- expect(output.successorTaskId).toBe('task-phil-enq-002');
605
+ expect(output.successorTaskIds![0]).toBe('task-phil-enq-002');
601
606
  expect(output.successorKind).toBe('philosopher');
602
607
  });
603
608
 
604
- it('--enqueue-next with no_successor does not set successorTaskId', async () => {
609
+ it('auto-enqueue: no_successor does not set successor info', async () => {
605
610
  mockWakeOnce.mockResolvedValue({
606
611
  decision: 'would_lease',
607
612
  taskId: 'task-dreamer-enq-003',
@@ -629,10 +634,10 @@ describe('handleRuntimeInternalizationRunOnce', () => {
629
634
 
630
635
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
631
636
  expect(output.enqueueDecision).toBe('no_successor');
632
- expect(output.successorTaskId).toBeUndefined();
637
+ expect(output.successorTaskIds).toEqual([]);
633
638
  });
634
639
 
635
- it('--enqueue-next with failed run does not call commitNextTaskProposal', async () => {
640
+ it('auto-enqueue: failed run does not call commitNextTaskProposal', async () => {
636
641
  mockWakeOnce.mockResolvedValue({
637
642
  decision: 'would_lease',
638
643
  taskId: 'task-dreamer-enq-004',
@@ -652,7 +657,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
652
657
  expect(mockCommitNextTaskProposal).not.toHaveBeenCalled();
653
658
  });
654
659
 
655
- it('--enqueue-next without --allow-test-double still blocked', async () => {
660
+ it('auto-enqueue without --allow-test-double still blocked', async () => {
656
661
  await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', enqueueNext: true });
657
662
 
658
663
  expect(process.exitCode).toBe(1);
@@ -880,7 +885,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
880
885
  expect(output.runnerResult.status).toBe('succeeded');
881
886
  });
882
887
 
883
- it('--runner scribe --enqueue-next creates artificer successor', async () => {
888
+ it('auto-enqueue: --runner scribe creates artificer successor', async () => {
884
889
  mockWakeOnce.mockResolvedValue({
885
890
  decision: 'would_lease',
886
891
  taskId: 'task-scribe-enq-001',
@@ -916,7 +921,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
916
921
 
917
922
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
918
923
  expect(output.enqueueDecision).toBe('successor_created');
919
- expect(output.successorTaskId).toBe('task-artificer-enq-001');
924
+ expect(output.successorTaskIds![0]).toBe('task-artificer-enq-001');
920
925
  expect(output.successorKind).toBe('artificer');
921
926
  });
922
927
 
@@ -979,7 +984,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
979
984
  expect(output.runnerResult.status).toBe('succeeded');
980
985
  });
981
986
 
982
- it('--runner artificer --enqueue-next returns successor decision', async () => {
987
+ it('auto-enqueue: --runner artificer returns successor decision', async () => {
983
988
  mockWakeOnce.mockResolvedValue({
984
989
  decision: 'would_lease',
985
990
  taskId: 'task-artificer-enq-001',
@@ -1022,7 +1027,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1022
1027
 
1023
1028
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1024
1029
  expect(output.enqueueDecision).toBe('successor_created');
1025
- expect(output.successorTaskId).toBe('task-evaluator-enq-001');
1030
+ expect(output.successorTaskIds![0]).toBe('task-evaluator-enq-001');
1026
1031
  expect(output.successorKind).toBe('evaluator');
1027
1032
  });
1028
1033
 
@@ -1112,7 +1117,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1112
1117
  expect(output.runnerResult.status).toBe('succeeded');
1113
1118
  });
1114
1119
 
1115
- it('--runner evaluator --enqueue-next returns successor decision', async () => {
1120
+ it('auto-enqueue: --runner evaluator returns successor decision', async () => {
1116
1121
  mockWakeOnce.mockResolvedValue({
1117
1122
  decision: 'would_lease',
1118
1123
  taskId: 'task-evaluator-enq-001',
@@ -1155,7 +1160,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1155
1160
 
1156
1161
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1157
1162
  expect(output.enqueueDecision).toBe('successor_created');
1158
- expect(output.successorTaskId).toBe('task-rollout-reviewer-enq-001');
1163
+ expect(output.successorTaskIds![0]).toBe('task-rollout-reviewer-enq-001');
1159
1164
  expect(output.successorKind).toBe('rollout_reviewer');
1160
1165
  });
1161
1166
 
@@ -1208,7 +1213,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1208
1213
  expect(output.runnerResult.status).toBe('succeeded');
1209
1214
  });
1210
1215
 
1211
- it('--runner rollout_reviewer --enqueue-next returns no_successor for prompt channel', async () => {
1216
+ it('auto-enqueue: --runner rollout_reviewer returns no_successor for prompt channel', async () => {
1212
1217
  mockWakeOnce.mockResolvedValue({
1213
1218
  decision: 'would_lease',
1214
1219
  taskId: 'task-rollout-reviewer-enq-001',
@@ -1315,4 +1320,276 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1315
1320
  expect(output.nextAction).toBeTruthy();
1316
1321
  expect(process.exitCode).toBe(1);
1317
1322
  });
1323
+
1324
+ // === Default enqueue successor tests ===
1325
+
1326
+ it('default behavior (no --no-enqueue-next) auto-enqueues successor on runner success', async () => {
1327
+ mockWakeOnce.mockResolvedValue({
1328
+ decision: 'would_lease',
1329
+ taskId: 'task-dreamer-auto-001',
1330
+ taskKind: 'dreamer',
1331
+ });
1332
+
1333
+ mockRun.mockResolvedValue({
1334
+ status: 'succeeded',
1335
+ taskId: 'task-dreamer-auto-001',
1336
+ runId: 'run-auto-001',
1337
+ artifactId: 'pi-art-auto-001',
1338
+ resultRef: 'dreamer://run-auto-001',
1339
+ contextHash: 'ctx-auto',
1340
+ output: { valid: true, taskId: 'task-dreamer-auto-001', candidates: VALID_DREAMER_CANDIDATES, contextRefs: [], generatedAt: new Date().toISOString() },
1341
+ attemptCount: 1,
1342
+ });
1343
+
1344
+ mockCommitNextTaskProposal.mockResolvedValue({
1345
+ decision: 'successor_created',
1346
+ sourceTaskId: 'task-dreamer-auto-001',
1347
+ successorTaskId: 'task-phil-auto-001',
1348
+ successorKind: 'philosopher',
1349
+ });
1350
+
1351
+ // No enqueueNext specified — default should auto-enqueue
1352
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', allowTestDouble: true, json: true });
1353
+
1354
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1355
+ expect(output.successorEnqueueAttempted).toBe(true);
1356
+ expect(output.successorTasksCreated).toBe(1);
1357
+ expect(output.successorTaskIds).toContain('task-phil-auto-001');
1358
+ expect(output.enqueueDecision).toBe('successor_created');
1359
+ expect(output.successorKind).toBe('philosopher');
1360
+ expect(mockCommitNextTaskProposal).toHaveBeenCalledWith('task-dreamer-auto-001');
1361
+ });
1362
+
1363
+ it('--no-enqueue-next (enqueueNext: false) skips successor enqueue', async () => {
1364
+ mockWakeOnce.mockResolvedValue({
1365
+ decision: 'would_lease',
1366
+ taskId: 'task-dreamer-skip-001',
1367
+ taskKind: 'dreamer',
1368
+ });
1369
+
1370
+ mockRun.mockResolvedValue({
1371
+ status: 'succeeded',
1372
+ taskId: 'task-dreamer-skip-001',
1373
+ runId: 'run-skip-001',
1374
+ artifactId: 'pi-art-skip-001',
1375
+ resultRef: 'dreamer://run-skip-001',
1376
+ contextHash: 'ctx-skip',
1377
+ output: { valid: true, taskId: 'task-dreamer-skip-001', candidates: VALID_DREAMER_CANDIDATES, contextRefs: [], generatedAt: new Date().toISOString() },
1378
+ attemptCount: 1,
1379
+ });
1380
+
1381
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', allowTestDouble: true, enqueueNext: false, json: true });
1382
+
1383
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1384
+ expect(output.successorEnqueueAttempted).toBe(false);
1385
+ expect(output.nextAction).toContain('--no-enqueue-next');
1386
+ expect(mockCommitNextTaskProposal).not.toHaveBeenCalled();
1387
+ });
1388
+
1389
+ it('successor enqueue failure outputs partial_success with reason and nextAction', async () => {
1390
+ mockWakeOnce.mockResolvedValue({
1391
+ decision: 'would_lease',
1392
+ taskId: 'task-dreamer-fail-001',
1393
+ taskKind: 'dreamer',
1394
+ });
1395
+
1396
+ mockRun.mockResolvedValue({
1397
+ status: 'succeeded',
1398
+ taskId: 'task-dreamer-fail-001',
1399
+ runId: 'run-fail-001',
1400
+ artifactId: 'pi-art-fail-001',
1401
+ resultRef: 'dreamer://run-fail-001',
1402
+ contextHash: 'ctx-fail',
1403
+ output: { valid: true, taskId: 'task-dreamer-fail-001', candidates: VALID_DREAMER_CANDIDATES, contextRefs: [], generatedAt: new Date().toISOString() },
1404
+ attemptCount: 1,
1405
+ });
1406
+
1407
+ mockCommitNextTaskProposal.mockRejectedValue(new Error('database locked'));
1408
+
1409
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', allowTestDouble: true, json: true });
1410
+
1411
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1412
+ expect(output.decision).toBe('partial_success');
1413
+ expect(output.successorEnqueueAttempted).toBe(true);
1414
+ expect(output.enqueueDecision).toBe('enqueue_failed');
1415
+ expect(output.enqueueReason).toContain('database locked');
1416
+ expect(output.nextAction).toContain('enqueue-successors');
1417
+ expect(output.successorTasksCreated).toBe(0);
1418
+ expect(output.successorTaskIds).toEqual([]);
1419
+ });
1420
+
1421
+ it('runner failure never enqueues successor (default behavior)', async () => {
1422
+ mockWakeOnce.mockResolvedValue({
1423
+ decision: 'would_lease',
1424
+ taskId: 'task-dreamer-nofail-001',
1425
+ taskKind: 'dreamer',
1426
+ });
1427
+
1428
+ mockRun.mockResolvedValue({
1429
+ status: 'failed',
1430
+ taskId: 'task-dreamer-nofail-001',
1431
+ errorCategory: 'execution_failed',
1432
+ failureReason: 'Runtime unavailable',
1433
+ attemptCount: 1,
1434
+ });
1435
+
1436
+ // Default behavior (no --no-enqueue-next)
1437
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', allowTestDouble: true, json: true });
1438
+
1439
+ expect(mockCommitNextTaskProposal).not.toHaveBeenCalled();
1440
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1441
+ expect(output.successorEnqueueAttempted).toBeUndefined();
1442
+ });
1443
+
1444
+ it('JSON output is single parseable JSON with successor fields', async () => {
1445
+ mockWakeOnce.mockResolvedValue({
1446
+ decision: 'would_lease',
1447
+ taskId: 'task-dreamer-json-001',
1448
+ taskKind: 'dreamer',
1449
+ });
1450
+
1451
+ mockRun.mockResolvedValue({
1452
+ status: 'succeeded',
1453
+ taskId: 'task-dreamer-json-001',
1454
+ runId: 'run-json-001',
1455
+ artifactId: 'pi-art-json-001',
1456
+ resultRef: 'dreamer://run-json-001',
1457
+ contextHash: 'ctx-json',
1458
+ output: { valid: true, taskId: 'task-dreamer-json-001', candidates: VALID_DREAMER_CANDIDATES, contextRefs: [], generatedAt: new Date().toISOString() },
1459
+ attemptCount: 1,
1460
+ });
1461
+
1462
+ mockCommitNextTaskProposal.mockResolvedValue({
1463
+ decision: 'successor_created',
1464
+ sourceTaskId: 'task-dreamer-json-001',
1465
+ successorTaskId: 'task-phil-json-001',
1466
+ successorKind: 'philosopher',
1467
+ });
1468
+
1469
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', allowTestDouble: true, json: true });
1470
+
1471
+ const rawOutput = consoleLogSpy.mock.calls[0][0];
1472
+ // Must be single parseable JSON
1473
+ const output = JSON.parse(rawOutput);
1474
+ expect(output).toHaveProperty('successorEnqueueAttempted');
1475
+ expect(output).toHaveProperty('successorTasksCreated');
1476
+ expect(output).toHaveProperty('successorTaskIds');
1477
+ // nextAction may be undefined when successor is successfully created
1478
+ // but must be present on partial_success / no_successor / skipped
1479
+ });
1480
+
1481
+ it('text output for auto-enqueue shows successor info', async () => {
1482
+ mockWakeOnce.mockResolvedValue({
1483
+ decision: 'would_lease',
1484
+ taskId: 'task-dreamer-text-001',
1485
+ taskKind: 'dreamer',
1486
+ });
1487
+
1488
+ mockRun.mockResolvedValue({
1489
+ status: 'succeeded',
1490
+ taskId: 'task-dreamer-text-001',
1491
+ runId: 'run-text-001',
1492
+ artifactId: 'pi-art-text-001',
1493
+ resultRef: 'dreamer://run-text-001',
1494
+ contextHash: 'ctx-text',
1495
+ output: { valid: true, taskId: 'task-dreamer-text-001', candidates: VALID_DREAMER_CANDIDATES, contextRefs: [], generatedAt: new Date().toISOString() },
1496
+ attemptCount: 1,
1497
+ });
1498
+
1499
+ mockCommitNextTaskProposal.mockResolvedValue({
1500
+ decision: 'successor_created',
1501
+ sourceTaskId: 'task-dreamer-text-001',
1502
+ successorTaskId: 'task-phil-text-001',
1503
+ successorKind: 'philosopher',
1504
+ });
1505
+
1506
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'dreamer', runtime: 'test-double', allowTestDouble: true, json: false });
1507
+
1508
+ const text = consoleLogSpy.mock.calls.map((c: string[]) => c[0]).join('\n');
1509
+ expect(text).toContain('successor: task-phil-text-001');
1510
+ expect(text).toContain('enqueue_attempted: true');
1511
+ expect(text).toContain('successors_created: 1');
1512
+ });
1513
+ });
1514
+
1515
+ // === Commander parser-level tests for --no-enqueue-next ===
1516
+ // These tests verify that Commander correctly maps --no-enqueue-next to opts.enqueueNext.
1517
+ // They exercise the real Commander parsing path, NOT the handler directly.
1518
+ // See ERR-063: previous code used opts.noEnqueueNext (always undefined) instead of opts.enqueueNext.
1519
+
1520
+ describe('Commander --no-enqueue-next parser wiring', () => {
1521
+ function buildRunOnceCommand(capturedOpts: Record<string, unknown>) {
1522
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1523
+ const { Command } = require('commander') as typeof import('commander');
1524
+ const program = new Command();
1525
+ program.exitOverride(); // prevent process.exit during tests
1526
+
1527
+ const internalizationCmd = program.command('internalization');
1528
+ internalizationCmd
1529
+ .command('run-once')
1530
+ .description('Wake-and-run: lease the next PI task and execute it')
1531
+ .option('-w, --workspace <path>', 'Workspace directory')
1532
+ .option('--runner <kind>', 'Runner kind', 'dreamer')
1533
+ .option('--runtime <kind>', 'Runtime adapter kind', 'config')
1534
+ .option('--allow-test-double', 'Acknowledge test-double runtime')
1535
+ .option('--no-enqueue-next', 'Skip successor enqueue after successful runner')
1536
+ .option('--timeout-ms <ms>', 'Runner timeout', parseInt)
1537
+ .option('--json', 'Output raw JSON')
1538
+ .action(async (opts) => {
1539
+ Object.assign(capturedOpts, opts);
1540
+ });
1541
+
1542
+ return program;
1543
+ }
1544
+
1545
+ it('with --no-enqueue-next, Commander sets enqueueNext=false', async () => {
1546
+ const captured: Record<string, unknown> = {};
1547
+ const program = buildRunOnceCommand(captured);
1548
+
1549
+ await program.parseAsync([
1550
+ 'node', 'pd', 'internalization', 'run-once',
1551
+ '--no-enqueue-next',
1552
+ '--workspace', '/tmp/test',
1553
+ '--runtime', 'test-double',
1554
+ '--allow-test-double',
1555
+ '--json',
1556
+ ]);
1557
+
1558
+ expect(captured).toHaveProperty('enqueueNext', false);
1559
+ expect(captured).not.toHaveProperty('noEnqueueNext');
1560
+ });
1561
+
1562
+ it('without --no-enqueue-next, Commander sets enqueueNext=true (default)', async () => {
1563
+ const captured: Record<string, unknown> = {};
1564
+ const program = buildRunOnceCommand(captured);
1565
+
1566
+ await program.parseAsync([
1567
+ 'node', 'pd', 'internalization', 'run-once',
1568
+ '--workspace', '/tmp/test',
1569
+ '--runtime', 'test-double',
1570
+ '--allow-test-double',
1571
+ '--json',
1572
+ ]);
1573
+
1574
+ expect(captured).toHaveProperty('enqueueNext', true);
1575
+ });
1576
+
1577
+ it('opts has no noEnqueueNext property regardless of flag presence', async () => {
1578
+ const capturedWith: Record<string, unknown> = {};
1579
+ const programWith = buildRunOnceCommand(capturedWith);
1580
+ await programWith.parseAsync([
1581
+ 'node', 'pd', 'internalization', 'run-once',
1582
+ '--no-enqueue-next', '--workspace', '/tmp/test',
1583
+ ]);
1584
+
1585
+ const capturedWithout: Record<string, unknown> = {};
1586
+ const programWithout = buildRunOnceCommand(capturedWithout);
1587
+ await programWithout.parseAsync([
1588
+ 'node', 'pd', 'internalization', 'run-once',
1589
+ '--workspace', '/tmp/test',
1590
+ ]);
1591
+
1592
+ expect(capturedWith).not.toHaveProperty('noEnqueueNext');
1593
+ expect(capturedWithout).not.toHaveProperty('noEnqueueNext');
1594
+ });
1318
1595
  });
@@ -21,6 +21,7 @@ vi.mock('@principles/core/runtime-v2', () => ({
21
21
  InternalizationOrchestrator: vi.fn().mockImplementation(function () {
22
22
  return { wakeOnce: mockWakeOnce };
23
23
  }),
24
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
24
25
  }));
25
26
 
26
27
  import { handleRuntimeInternalizationWakeOnce } from '../../src/commands/runtime-internalization-wake-once.js';
@@ -87,6 +87,7 @@ vi.mock('@principles/core/runtime-v2', () => ({
87
87
  listPruningReviews: mockListPruningReviews,
88
88
  buildMaskedPrincipleSet: mockBuildMaskedPrincipleSet,
89
89
  removeOrphanReferencesFromLedger: mockRemoveOrphanReferencesFromLedger,
90
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
90
91
  }));
91
92
 
92
93
  import { handlePruningReport, handlePruningExplain, handlePruningReview, handlePruningRollback, handlePruningOrphans } from '../../src/commands/runtime-pruning.js';
@@ -28,6 +28,7 @@ vi.mock('@principles/core/runtime-v2', () => ({
28
28
  warnings: input.warnings ?? [],
29
29
  })),
30
30
  remediationAction: vi.fn((input) => input),
31
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
31
32
  }));
32
33
 
33
34
  import { handleRuntimeRecoverySweep } from '../../src/commands/runtime-recovery.js';
@@ -32,6 +32,7 @@ vi.mock('@principles/core/runtime-v2', () => ({
32
32
  agentId: 'main',
33
33
  }),
34
34
  isRuntimeConfigError: vi.fn().mockReturnValue(false),
35
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
35
36
  PDRuntimeError: class PDRuntimeError extends Error {
36
37
  constructor(public category: string, message: string) {
37
38
  super(message);
@@ -14,6 +14,7 @@ vi.mock('@principles/core/runtime-v2', () => ({
14
14
  PainChainReadModel: vi.fn().mockImplementation(function () {
15
15
  return { traceByPainId: mockTraceByPainId, close: mockPainChainClose };
16
16
  }),
17
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
17
18
  }));
18
19
 
19
20
  vi.mock('../../src/resolve-workspace.js', () => ({
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Tests for config-reader — readOutputLanguageFromWorkspace (PRI-336).
3
+ *
4
+ * Validates:
5
+ * - No config file → legitimate default, no warning
6
+ * - No principles section → legitimate default, no warning
7
+ * - No outputLanguage key → legitimate default, no warning
8
+ * - Read error → degraded with read_error reason + nextAction (ERR-002/009)
9
+ * - YAML parse error → degraded with yaml_parse_error reason + nextAction
10
+ * - Invalid config root → degraded with invalid_config_root reason + nextAction
11
+ * - Invalid principles structure → degraded with invalid_principles_structure reason + nextAction
12
+ * - Valid outputLanguage → resolved value
13
+ * - Invalid outputLanguage value → degraded with invalid value warning
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17
+ import { readOutputLanguageFromWorkspace } from '../../src/config-reader.js';
18
+
19
+ // Mock fs and yaml
20
+ vi.mock('fs', () => ({
21
+ existsSync: vi.fn(),
22
+ readFileSync: vi.fn(),
23
+ }));
24
+
25
+ vi.mock('js-yaml', () => ({
26
+ default: {
27
+ load: vi.fn(),
28
+ JSON_SCHEMA: 'JSON_SCHEMA',
29
+ },
30
+ }));
31
+
32
+ import * as fs from 'fs';
33
+ import yaml from 'js-yaml';
34
+
35
+ const mockExistsSync = vi.mocked(fs.existsSync);
36
+ const mockReadFileSync = vi.mocked(fs.readFileSync);
37
+ const mockYamlLoad = vi.mocked(yaml.load);
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ describe('readOutputLanguageFromWorkspace', () => {
48
+ it('returns default with no warning when config file does not exist', () => {
49
+ mockExistsSync.mockReturnValue(false);
50
+ const result = readOutputLanguageFromWorkspace('/workspace');
51
+ expect(result.outputLanguage).toBe('zh-CN');
52
+ expect(result.degradationWarning).toBeUndefined();
53
+ });
54
+
55
+ it('returns default with no warning when principles section is missing', () => {
56
+ mockExistsSync.mockReturnValue(true);
57
+ mockReadFileSync.mockReturnValue('version: 1\n');
58
+ mockYamlLoad.mockReturnValue({ version: 1 });
59
+ const result = readOutputLanguageFromWorkspace('/workspace');
60
+ expect(result.outputLanguage).toBe('zh-CN');
61
+ expect(result.degradationWarning).toBeUndefined();
62
+ });
63
+
64
+ it('returns default with no warning when outputLanguage key is missing', () => {
65
+ mockExistsSync.mockReturnValue(true);
66
+ mockReadFileSync.mockReturnValue('version: 1\nprinciples: {}\n');
67
+ mockYamlLoad.mockReturnValue({ version: 1, principles: {} });
68
+ const result = readOutputLanguageFromWorkspace('/workspace');
69
+ expect(result.outputLanguage).toBe('zh-CN');
70
+ expect(result.degradationWarning).toBeUndefined();
71
+ });
72
+
73
+ it('returns valid outputLanguage when configured', () => {
74
+ mockExistsSync.mockReturnValue(true);
75
+ mockReadFileSync.mockReturnValue('version: 1\nprinciples:\n outputLanguage: en\n');
76
+ mockYamlLoad.mockReturnValue({ version: 1, principles: { outputLanguage: 'en' } });
77
+ const result = readOutputLanguageFromWorkspace('/workspace');
78
+ expect(result.outputLanguage).toBe('en');
79
+ expect(result.degradationWarning).toBeUndefined();
80
+ });
81
+
82
+ it('returns degraded with read_error when readFileSync throws', () => {
83
+ mockExistsSync.mockReturnValue(true);
84
+ mockReadFileSync.mockImplementation(() => { throw new Error('EACCES: permission denied'); });
85
+ const result = readOutputLanguageFromWorkspace('/workspace');
86
+ expect(result.outputLanguage).toBe('zh-CN');
87
+ expect(result.degradationWarning).toContain('Failed to read .pd/config.yaml');
88
+ expect(result.degradationWarning).toContain('nextAction');
89
+ expect(result.degradationWarning).toContain('Check file permissions');
90
+ });
91
+
92
+ it('returns degraded with yaml_parse_error when yaml.load throws', () => {
93
+ mockExistsSync.mockReturnValue(true);
94
+ mockReadFileSync.mockReturnValue('invalid: yaml: [broken');
95
+ mockYamlLoad.mockImplementation(() => { throw new Error('YAML syntax error'); });
96
+ const result = readOutputLanguageFromWorkspace('/workspace');
97
+ expect(result.outputLanguage).toBe('zh-CN');
98
+ expect(result.degradationWarning).toContain('Failed to parse .pd/config.yaml');
99
+ expect(result.degradationWarning).toContain('nextAction');
100
+ expect(result.degradationWarning).toContain('Fix YAML syntax');
101
+ });
102
+
103
+ it('returns degraded with invalid_config_root when parsed YAML is not an object', () => {
104
+ mockExistsSync.mockReturnValue(true);
105
+ mockReadFileSync.mockReturnValue('"just a string"');
106
+ mockYamlLoad.mockReturnValue('just a string');
107
+ const result = readOutputLanguageFromWorkspace('/workspace');
108
+ expect(result.outputLanguage).toBe('zh-CN');
109
+ expect(result.degradationWarning).toContain('root is not an object');
110
+ expect(result.degradationWarning).toContain('nextAction');
111
+ });
112
+
113
+ it('returns degraded with invalid_config_root when parsed YAML is an array', () => {
114
+ mockExistsSync.mockReturnValue(true);
115
+ mockReadFileSync.mockReturnValue('- item1\n- item2\n');
116
+ mockYamlLoad.mockReturnValue(['item1', 'item2']);
117
+ const result = readOutputLanguageFromWorkspace('/workspace');
118
+ expect(result.outputLanguage).toBe('zh-CN');
119
+ expect(result.degradationWarning).toContain('root is not an object');
120
+ });
121
+
122
+ it('returns degraded with invalid_principles_structure when principles is not an object', () => {
123
+ mockExistsSync.mockReturnValue(true);
124
+ mockReadFileSync.mockReturnValue('version: 1\nprinciples: "not an object"\n');
125
+ mockYamlLoad.mockReturnValue({ version: 1, principles: 'not an object' });
126
+ const result = readOutputLanguageFromWorkspace('/workspace');
127
+ expect(result.outputLanguage).toBe('zh-CN');
128
+ expect(result.degradationWarning).toContain('principles field is not an object');
129
+ expect(result.degradationWarning).toContain('nextAction');
130
+ });
131
+
132
+ it('returns degraded with invalid value warning when outputLanguage is invalid', () => {
133
+ mockExistsSync.mockReturnValue(true);
134
+ mockReadFileSync.mockReturnValue('version: 1\nprinciples:\n outputLanguage: fr\n');
135
+ mockYamlLoad.mockReturnValue({ version: 1, principles: { outputLanguage: 'fr' } });
136
+ const result = readOutputLanguageFromWorkspace('/workspace');
137
+ expect(result.outputLanguage).toBe('zh-CN');
138
+ expect(result.degradationWarning).toContain('invalid');
139
+ expect(result.degradationWarning).toContain('fr');
140
+ expect(result.degradationWarning).toContain('nextAction');
141
+ });
142
+ });