@principles/pd-cli 1.143.0 → 1.145.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 (35) hide show
  1. package/dist/commands/codex-ingest-catchup.d.ts +20 -0
  2. package/dist/commands/codex-ingest-catchup.d.ts.map +1 -0
  3. package/dist/commands/codex-ingest-catchup.js +95 -0
  4. package/dist/commands/codex-ingest-catchup.js.map +1 -0
  5. package/dist/commands/codex-reconcile.d.ts +22 -0
  6. package/dist/commands/codex-reconcile.d.ts.map +1 -0
  7. package/dist/commands/codex-reconcile.js +61 -0
  8. package/dist/commands/codex-reconcile.js.map +1 -0
  9. package/dist/commands/codex-worker.d.ts +20 -0
  10. package/dist/commands/codex-worker.d.ts.map +1 -0
  11. package/dist/commands/codex-worker.js +156 -0
  12. package/dist/commands/codex-worker.js.map +1 -0
  13. package/dist/commands/console.d.ts.map +1 -1
  14. package/dist/commands/console.js +34 -1
  15. package/dist/commands/console.js.map +1 -1
  16. package/dist/commands/pain-retry.d.ts.map +1 -1
  17. package/dist/commands/pain-retry.js +4 -2
  18. package/dist/commands/pain-retry.js.map +1 -1
  19. package/dist/index.js +70 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/services/console-launcher.d.ts +8 -2
  22. package/dist/services/console-launcher.d.ts.map +1 -1
  23. package/dist/services/console-launcher.js +34 -4
  24. package/dist/services/console-launcher.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/codex-ingest-catchup.ts +114 -0
  27. package/src/commands/codex-reconcile.ts +84 -0
  28. package/src/commands/codex-worker.ts +174 -0
  29. package/src/commands/console.ts +35 -2
  30. package/src/commands/pain-retry.ts +4 -2
  31. package/src/index.ts +75 -0
  32. package/src/services/console-launcher.ts +49 -6
  33. package/tests/commands/codex-worker-registration.test.ts +178 -0
  34. package/tests/commands/console-open.test.ts +83 -2
  35. package/tests/commands/health.test.ts +4 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Command-registration / parser + functional tests for the PRI-624 Slice C
3
+ * Codex commands (cli-7): `pd codex ingest catch-up` and `pd codex worker`.
4
+ *
5
+ * Registration mirrors src/index.ts. Functional tests exercise the real
6
+ * handlers against a temp workspace: --json emits exactly one parseable
7
+ * JSON object (cli-1), flag-off skips carry reason + nextAction (cli-6), and
8
+ * failed paths mutate nothing.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { Command } from 'commander';
15
+ import { getDefaultPdConfig } from '@principles/core/runtime-v2';
16
+
17
+ function buildTestProgram(): Command {
18
+ const program = new Command();
19
+ const codex = program.command('codex');
20
+ codex.command('reconcile');
21
+ const ingest = codex.command('ingest');
22
+ ingest
23
+ .command('catch-up')
24
+ .option('-w, --workspace <path>', 'Workspace directory')
25
+ .option('--max-rollouts <n>', 'Maximum rollouts to catch up per pass (1-32, default 8)')
26
+ .option('--json', 'Output raw JSON')
27
+ .action(() => {});
28
+ codex
29
+ .command('worker')
30
+ .option('-w, --workspace <path>', 'Workspace directory')
31
+ .option('--once', 'Run exactly one bounded cycle and exit')
32
+ .option('--interval <ms>', 'Cycle interval for continuous mode')
33
+ .option('--status', 'Report the SPEC §15 worker mode without executing anything')
34
+ .option('--json', 'Output raw JSON')
35
+ .action(() => {});
36
+ return program;
37
+ }
38
+
39
+ describe('codex Slice C command registration (cli-7)', () => {
40
+ it('parses codex ingest catch-up flags', () => {
41
+ const program = buildTestProgram();
42
+ program.parse(['node', 'pd', 'codex', 'ingest', 'catch-up', '--workspace', '/tmp/ws', '--max-rollouts', '4', '--json']);
43
+ const codex = program.commands.find((c) => c.name() === 'codex');
44
+ const ingest = codex?.commands.find((c) => c.name() === 'ingest');
45
+ const catchUp = ingest?.commands.find((c) => c.name() === 'catch-up');
46
+ expect(catchUp).toBeDefined();
47
+ expect(catchUp?.opts().workspace).toBe('/tmp/ws');
48
+ expect(catchUp?.opts().maxRollouts).toBe('4');
49
+ expect(catchUp?.opts().json).toBe(true);
50
+ });
51
+
52
+ it('parses codex worker flags including --once and --status', () => {
53
+ const program = buildTestProgram();
54
+ program.parse(['node', 'pd', 'codex', 'worker', '--workspace', '/tmp/ws', '--once', '--json']);
55
+ const codex = program.commands.find((c) => c.name() === 'codex');
56
+ const worker = codex?.commands.find((c) => c.name() === 'worker');
57
+ expect(worker).toBeDefined();
58
+ expect(worker?.opts().workspace).toBe('/tmp/ws');
59
+ expect(worker?.opts().once).toBe(true);
60
+ expect(worker?.opts().json).toBe(true);
61
+
62
+ const program2 = buildTestProgram();
63
+ program2.parse(['node', 'pd', 'codex', 'worker', '--status']);
64
+ const worker2 = program2.commands.find((c) => c.name() === 'codex')?.commands.find((c) => c.name() === 'worker');
65
+ expect(worker2?.opts().status).toBe(true);
66
+ // --interval is registered for continuous mode
67
+ const intervalOption = worker?.options.find((o) => o.long === '--interval');
68
+ expect(intervalOption).toBeDefined();
69
+ });
70
+ });
71
+
72
+ describe('codex ingest catch-up handler (functional)', () => {
73
+ let workspaceDir: string;
74
+ let logSpy: ReturnType<typeof vi.spyOn>;
75
+
76
+ beforeEach(() => {
77
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-cli-catchup-'));
78
+ fs.mkdirSync(path.join(workspaceDir, '.pd'), { recursive: true });
79
+ fs.mkdirSync(path.join(workspaceDir, '.state'), { recursive: true });
80
+ fs.writeFileSync(path.join(workspaceDir, '.state', 'trajectory.db'), '');
81
+ const config = getDefaultPdConfig();
82
+ config.features['host.codex'].enabled = true;
83
+ config.features.codex_conversation_ingestion.enabled = false;
84
+ fs.writeFileSync(path.join(workspaceDir, '.pd', 'config.yaml'), JSON.stringify(config));
85
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
86
+ });
87
+
88
+ afterEach(() => {
89
+ logSpy.mockRestore();
90
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
91
+ });
92
+
93
+ it('emits exactly one JSON object and skips with reason+nextAction when ingestion is off', { timeout: 20_000 }, async () => {
94
+ const { handleCodexIngestCatchUp } = await import('../../src/commands/codex-ingest-catchup.js');
95
+ await handleCodexIngestCatchUp({ workspace: workspaceDir, json: true });
96
+ expect(logSpy).toHaveBeenCalledTimes(1);
97
+ const report = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as { status: string; reason: string; nextAction: string };
98
+ expect(report.status).toBe('skipped');
99
+ expect(report.reason).toBe('feature_disabled');
100
+ expect(report.nextAction).toContain('codex_conversation_ingestion');
101
+ expect(process.exitCode ?? 0).toBe(0);
102
+ });
103
+ });
104
+
105
+ describe('codex worker handler (functional)', () => {
106
+ let workspaceDir: string;
107
+ let logSpy: ReturnType<typeof vi.spyOn>;
108
+
109
+ beforeEach(() => {
110
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-cli-worker-'));
111
+ fs.mkdirSync(path.join(workspaceDir, '.pd'), { recursive: true });
112
+ fs.mkdirSync(path.join(workspaceDir, '.state'), { recursive: true });
113
+ fs.writeFileSync(path.join(workspaceDir, '.state', 'trajectory.db'), '');
114
+ const config = getDefaultPdConfig();
115
+ config.features['host.codex'].enabled = true;
116
+ config.features.internalization_auto_consumer.enabled = false;
117
+ fs.writeFileSync(path.join(workspaceDir, '.pd', 'config.yaml'), JSON.stringify(config));
118
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
119
+ });
120
+
121
+ afterEach(() => {
122
+ logSpy.mockRestore();
123
+ process.exitCode = 0;
124
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
125
+ });
126
+
127
+ it('--once reports paused with reason+nextAction when the consumer flag is off', { timeout: 20_000 }, async () => {
128
+ const { handleCodexWorker } = await import('../../src/commands/codex-worker.js');
129
+ await handleCodexWorker({ workspace: workspaceDir, once: true, json: true });
130
+ expect(logSpy).toHaveBeenCalledTimes(1);
131
+ const report = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as { mode: string; reason: string; nextAction: string };
132
+ expect(report.mode).toBe('paused');
133
+ expect(report.reason).toBe('internalization_auto_consumer_disabled');
134
+ expect(report.nextAction).toContain('pd diagnose');
135
+ expect(process.exitCode ?? 0).toBe(0);
136
+ });
137
+
138
+ it('--json without --once/--status is refused (continuous mode streams, it is not one JSON document)', async () => {
139
+ const { handleCodexWorker } = await import('../../src/commands/codex-worker.js');
140
+ await handleCodexWorker({ workspace: workspaceDir, json: true });
141
+ expect(logSpy).toHaveBeenCalledTimes(1);
142
+ const error = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as { error: string; reason: string; nextAction: string };
143
+ expect(error.error).toBe('cli_contract');
144
+ expect(error.reason).toBe('json_without_once_or_status');
145
+ expect(error.nextAction).toContain('--once');
146
+ expect(process.exitCode).toBe(1);
147
+ });
148
+
149
+ it('--once without --json prints the human-readable cycle report', { timeout: 20_000 }, async () => {
150
+ const { handleCodexWorker } = await import('../../src/commands/codex-worker.js');
151
+ await handleCodexWorker({ workspace: workspaceDir, once: true, json: false });
152
+ // Human-readable form: one multi-line report, no JSON envelope.
153
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
154
+ expect(output).toContain('Codex workspace worker');
155
+ expect(output).toContain('mode: paused');
156
+ expect(output).toContain('catch-up:');
157
+ expect(output).toContain('reconcile:');
158
+ expect(output).toContain('next action:');
159
+ expect(process.exitCode ?? 0).toBe(0);
160
+ });
161
+
162
+ it('--status reports manual_action_required for a workspace absent from the install manifest', async () => {
163
+ const { handleCodexWorker } = await import('../../src/commands/codex-worker.js');
164
+ // Execution enabled — the manifest absence is then the deciding fact.
165
+ const config = getDefaultPdConfig();
166
+ config.features['host.codex'].enabled = true;
167
+ config.features.internalization_auto_consumer.enabled = true;
168
+ fs.writeFileSync(path.join(workspaceDir, '.pd', 'config.yaml'), JSON.stringify(config));
169
+ await handleCodexWorker({ workspace: workspaceDir, status: true, json: true });
170
+ expect(logSpy).toHaveBeenCalledTimes(1);
171
+ const report = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as { mode: string; reason: string; registeredInInstallManifest: boolean; nextAction: string };
172
+ expect(report.mode).toBe('manual_action_required');
173
+ expect(report.reason).toBe('workspace_not_in_install_manifest');
174
+ expect(report.registeredInInstallManifest).toBe(false);
175
+ expect(report.nextAction).toContain('pd codex ingest catch-up');
176
+ expect(process.exitCode ?? 0).toBe(0);
177
+ });
178
+ });
@@ -311,6 +311,46 @@ describe('planConsoleLaunch — reused (healthy console on preferred port)', ()
311
311
  }
312
312
  });
313
313
 
314
+ it('does not reuse a verified no-auth Console when a token is configured', async () => {
315
+ const server = http.createServer((_req, res) => {
316
+ res.statusCode = 200;
317
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'no_auth' } }));
318
+ });
319
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
320
+ const addr = server.address();
321
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
322
+ try {
323
+ const result = await planConsoleLaunch({
324
+ workspaceDir: '/tmp/anywhere', preferredPort: addr.port,
325
+ host: '127.0.0.1', token: 'configured-token',
326
+ });
327
+ expect(result.status).toBe('refused');
328
+ expect(result.reason).toBe('console_authentication_mode_mismatch');
329
+ } finally {
330
+ await new Promise<void>((resolve) => server.close(resolve));
331
+ }
332
+ });
333
+
334
+ it('fails loud when the configured token is rejected by an existing Console', async () => {
335
+ const server = http.createServer((_req, res) => {
336
+ res.statusCode = 401;
337
+ res.end('unauthorized');
338
+ });
339
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
340
+ const addr = server.address();
341
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
342
+ try {
343
+ const result = await planConsoleLaunch({
344
+ workspaceDir: '/tmp/anywhere', preferredPort: addr.port,
345
+ host: '127.0.0.1', token: 'wrong-token',
346
+ });
347
+ expect(result.status).toBe('refused');
348
+ expect(result.reason).toBe('console_authentication_failed');
349
+ } finally {
350
+ await new Promise<void>((resolve) => server.close(resolve));
351
+ }
352
+ });
353
+
314
354
  it('does NOT classify a non-console responder as reused', async () => {
315
355
  // Server returns 200 on any path but with a non-OK status code from /api/health
316
356
  const server = http.createServer((req, res) => {
@@ -387,6 +427,22 @@ describe('probeConsoleHealth', () => {
387
427
  }
388
428
  });
389
429
 
430
+ it('returns the verified authentication mode from the health contract', async () => {
431
+ const server = http.createServer((_req, res) => {
432
+ res.statusCode = 200;
433
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'authenticated' } }));
434
+ });
435
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
436
+ const addr = server.address();
437
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
438
+ try {
439
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port, token: 'token' });
440
+ expect(h).toMatchObject({ healthy: true, authenticationMode: 'authenticated' });
441
+ } finally {
442
+ await new Promise<void>((resolve) => server.close(resolve));
443
+ }
444
+ });
445
+
390
446
  it('returns healthy=false with reason for a server that returns 500', async () => {
391
447
  const server = http.createServer((req, res) => {
392
448
  res.statusCode = 500;
@@ -491,8 +547,11 @@ describe('CLI command wiring (pd console open)', () => {
491
547
  let cliPath: string;
492
548
  let workspaceRoot: string;
493
549
  let tmp: string;
550
+ let originalConsoleToken: string | undefined;
494
551
 
495
552
  beforeEach(() => {
553
+ originalConsoleToken = process.env.PD_CONSOLE_TOKEN;
554
+ delete process.env.PD_CONSOLE_TOKEN;
496
555
  workspaceRoot = path.resolve(__dirname, '../../../..');
497
556
  cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
498
557
  tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-console-open-test-'));
@@ -520,7 +579,7 @@ describe('CLI command wiring (pd console open)', () => {
520
579
  const server = http.createServer((req, res) => {
521
580
  if (req.url === '/api/health') {
522
581
  res.writeHead(200, {'Content-Type': 'application/json'});
523
- res.end(JSON.stringify({success: true}));
582
+ res.end(JSON.stringify({success: true, data: {authenticationMode: 'no_auth'}}));
524
583
  } else {
525
584
  res.writeHead(404);
526
585
  res.end();
@@ -533,6 +592,8 @@ describe('CLI command wiring (pd console open)', () => {
533
592
  });
534
593
 
535
594
  afterEach(() => {
595
+ if (originalConsoleToken === undefined) delete process.env.PD_CONSOLE_TOKEN;
596
+ else process.env.PD_CONSOLE_TOKEN = originalConsoleToken;
536
597
  delete process.env.__PD_CONSOLE_TEST_FAKE_HOME;
537
598
  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
538
599
  });
@@ -598,13 +659,32 @@ describe('CLI command wiring (pd console open)', () => {
598
659
  }
599
660
  }, 20_000);
600
661
 
662
+ it('kills and refuses a fresh Console whose reported authentication mode mismatches the configured token', async () => {
663
+ process.env.PD_CONSOLE_TOKEN = 'configured-token';
664
+ let run: CliJsonRun | undefined;
665
+ try {
666
+ run = await runPdUntilJson(
667
+ ['console', 'open', '--workspace', tmp, '--port', '49391', '--json', '--no-browser'],
668
+ workspaceRoot,
669
+ );
670
+ if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
671
+ expect(run.parsed.status).toBe('refused');
672
+ expect(run.parsed.reason).toBe('console_authentication_mode_mismatch');
673
+ expect(run.parsed).not.toHaveProperty('serverPid');
674
+ await new Promise((resolve) => setTimeout(resolve, 100));
675
+ expect(await isPortInUse('127.0.0.1', 49391)).toBe(false);
676
+ } finally {
677
+ await teardownCliTree(run);
678
+ }
679
+ }, 20_000);
680
+
601
681
  it('pd console open --json reused path does NOT include serverPid (PRI-526)', async () => {
602
682
  // Stand up a fake healthy console in-process, then point the CLI at its
603
683
  // port: planConsoleLaunch probes /api/health → 200 → reused, no spawn.
604
684
  const server = http.createServer((req, res) => {
605
685
  if (req.url === '/api/health') {
606
686
  res.statusCode = 200;
607
- res.end(JSON.stringify({ success: true }));
687
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'no_auth' } }));
608
688
  return;
609
689
  }
610
690
  res.statusCode = 404;
@@ -622,6 +702,7 @@ describe('CLI command wiring (pd console open)', () => {
622
702
  if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
623
703
  expect(run.parsed.status).toBe('reused');
624
704
  expect(run.parsed.reused).toBe(true);
705
+ expect(run.parsed.authenticationMode).toBe('no_auth');
625
706
  expect(run.parsed).not.toHaveProperty('serverPid');
626
707
  } finally {
627
708
  await teardownCliTree(run);
@@ -39,6 +39,10 @@ vi.mock('@principles/core/runtime-v2', () => ({
39
39
  }),
40
40
  auditCandidateLedgerConsistency: mockAuditCandidateLedgerConsistency,
41
41
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
42
+ // host-runtime's workspace-telemetry-emitter (transitively imported via the
43
+ // host-runtime barrel) extends this class at module-load time — the mock
44
+ // must provide a constructible base or the import chain throws.
45
+ StoreEventEmitter: class {},
42
46
  }));
43
47
 
44
48
  // PRI-443 Phase 5: getLedgerFilePathPublic now imported from