@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,114 @@
1
+ /**
2
+ * pd codex ingest catch-up command implementation (Codex Governance Closure
3
+ * Slice C, PRI-624; SPEC §13/§15 recovery command).
4
+ *
5
+ * Performs ONE bounded non-destructive catch-up pass over the workspace's
6
+ * durable Codex checkpoints: resolves each previously-authenticated rollout
7
+ * by exact uuid, re-authorizes the transcript path, and resumes the bounded
8
+ * incremental ingestion from the checkpoint — then feeds any admission
9
+ * candidates through the same Slice B admission/continuation pass the hook
10
+ * uses. Flag-off (`codex_conversation_ingestion=false`) performs ZERO
11
+ * transcript filesystem I/O and reports a structured skip.
12
+ *
13
+ * This is the manual-mode counterpart of the Companion worker's catch-up
14
+ * step (worker cycle step 2); it creates no LLM execution.
15
+ *
16
+ * CLI gate compliance:
17
+ * - cli-1: --json outputs exactly one parseable JSON object on stdout.
18
+ * - cli-2: exit paths stop execution.
19
+ * - cli-5/6: skipped/degraded results carry reason + nextAction; nothing is
20
+ * mutated on failure (the ingestion seam is transactional per rollout).
21
+ */
22
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
23
+ import { catchUpCodexIngestion, type CodexCatchUpResult } from '@principles/codex-adapter';
24
+
25
+ interface CodexIngestCatchUpOptions {
26
+ workspace?: string;
27
+ json?: boolean;
28
+ maxRollouts?: number;
29
+ }
30
+
31
+ export interface CodexIngestCatchUpReport {
32
+ generatedAt: string;
33
+ host: 'codex';
34
+ workspace: string;
35
+ status: 'ok' | 'degraded' | 'skipped';
36
+ reason?: string;
37
+ nextAction?: string;
38
+ rolloutsProcessed: number;
39
+ remainingLagRollouts: string[];
40
+ unexaminedRollouts: string[];
41
+ degradations: string[];
42
+ }
43
+
44
+ function buildReport(generatedAt: string, workspace: string, result: CodexCatchUpResult): CodexIngestCatchUpReport {
45
+ if (result.status === 'skipped') {
46
+ return {
47
+ generatedAt,
48
+ host: 'codex',
49
+ workspace,
50
+ status: 'skipped',
51
+ reason: result.reason,
52
+ nextAction: result.nextAction,
53
+ rolloutsProcessed: 0,
54
+ remainingLagRollouts: [],
55
+ unexaminedRollouts: [],
56
+ degradations: [],
57
+ };
58
+ }
59
+ const degradations: string[] = [];
60
+ for (const rollout of result.rollouts) {
61
+ for (const degradation of rollout.admissionDegradations) {
62
+ degradations.push(`${rollout.rolloutIdentity}: ${degradation.reason}`);
63
+ }
64
+ if (rollout.outcome.status === 'degraded') {
65
+ degradations.push(`${rollout.rolloutIdentity}: ${rollout.outcome.reason}`);
66
+ }
67
+ }
68
+ return {
69
+ generatedAt,
70
+ host: 'codex',
71
+ workspace,
72
+ status: result.status,
73
+ rolloutsProcessed: result.rollouts.length,
74
+ remainingLagRollouts: [...result.remainingLagRollouts],
75
+ unexaminedRollouts: [...result.unexaminedRollouts],
76
+ degradations: degradations.slice(0, 10),
77
+ ...(degradations.length > 0 && result.status === 'degraded'
78
+ ? { reason: 'one or more rollouts degraded; committed observations remain durable', nextAction: 'Inspect the per-rollout degradations; re-run catch-up after fixing the underlying condition (Slice D adds the audited quarantine command).' }
79
+ : {}),
80
+ };
81
+ }
82
+
83
+ export async function handleCodexIngestCatchUp(options: CodexIngestCatchUpOptions): Promise<void> {
84
+ const generatedAt = new Date().toISOString();
85
+ const workspace = resolveWorkspaceDir(options.workspace);
86
+
87
+ const result = await catchUpCodexIngestion({
88
+ workspaceDir: workspace,
89
+ env: { CODEX_HOME: process.env.CODEX_HOME },
90
+ ...(typeof options.maxRollouts === 'number' ? { maxRollouts: options.maxRollouts } : {}),
91
+ });
92
+
93
+ const report = buildReport(generatedAt, workspace, result);
94
+ if (options.json) {
95
+ console.log(JSON.stringify(report));
96
+ if (report.status === 'degraded') process.exitCode = 1;
97
+ return;
98
+ }
99
+
100
+ const lines = [
101
+ `Codex ingestion catch-up (${workspace})`,
102
+ ` status: ${report.status}`,
103
+ ` rollouts processed: ${report.rolloutsProcessed}`,
104
+ ` remaining lag: ${report.remainingLagRollouts.length}`,
105
+ ` unexamined (bound): ${report.unexaminedRollouts.length}`,
106
+ ];
107
+ for (const degradation of report.degradations) {
108
+ lines.push(` degradation: ${degradation}`);
109
+ }
110
+ if (report.reason !== undefined) lines.push(` reason: ${report.reason}`);
111
+ if (report.nextAction !== undefined) lines.push(` next action: ${report.nextAction}`);
112
+ console.log(lines.join('\n'));
113
+ if (report.status === 'degraded') process.exitCode = 1;
114
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * pd codex reconcile command implementation (Codex Governance Closure
3
+ * Slice B, PRI-623; SPEC §13/§20).
4
+ *
5
+ * Runs the narrow idempotent reconciliation pass between the governance
6
+ * admission markers (trajectory.db) and the Runtime V2 task store
7
+ * (.pd/state.db): creates missing Diagnostician tasks for admitted pains,
8
+ * repairs task links, retries pending promotion tails, and reports stale
9
+ * tails — without a background worker (the Slice C Companion worker will
10
+ * call the same seam).
11
+ *
12
+ * CLI gate compliance:
13
+ * - cli-1: --json outputs exactly one parseable JSON object on stdout.
14
+ * - cli-2: exit paths stop execution.
15
+ * - cli-6: degraded results include reason + nextAction.
16
+ */
17
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
18
+ import { reconcileGovernanceContinuation } from '@principles/host-runtime';
19
+
20
+ interface CodexReconcileOptions {
21
+ workspace?: string;
22
+ json?: boolean;
23
+ limit?: number;
24
+ }
25
+
26
+ export interface CodexReconcileReport {
27
+ generatedAt: string;
28
+ host: 'codex';
29
+ workspace: string;
30
+ ok: boolean;
31
+ reason?: string;
32
+ nextAction?: string;
33
+ tasksEnsured: number;
34
+ linksRepaired: number;
35
+ pendingTails: number;
36
+ completedTails: number;
37
+ staleTails: number;
38
+ degradations: string[];
39
+ }
40
+
41
+ export async function handleCodexReconcile(options: CodexReconcileOptions): Promise<void> {
42
+ const generatedAt = new Date().toISOString();
43
+ const workspace = resolveWorkspaceDir(options.workspace);
44
+
45
+ const result = await reconcileGovernanceContinuation({
46
+ workspaceDir: workspace,
47
+ ...(typeof options.limit === 'number' ? { limit: options.limit } : {}),
48
+ });
49
+
50
+ const report: CodexReconcileReport = {
51
+ generatedAt,
52
+ host: 'codex',
53
+ workspace,
54
+ ok: result.ok,
55
+ tasksEnsured: result.tasksEnsured,
56
+ linksRepaired: result.linksRepaired,
57
+ pendingTails: result.pendingTails,
58
+ completedTails: result.completedTails,
59
+ staleTails: result.staleTails,
60
+ degradations: [...result.degradations],
61
+ ...(result.ok ? {} : { reason: result.reason ?? 'reconciliation_failed', nextAction: result.nextAction ?? 'Inspect the listed degradations; admitted pains and evidence remain durable.' }),
62
+ };
63
+
64
+ if (options.json) {
65
+ console.log(JSON.stringify(report));
66
+ if (!report.ok) process.exitCode = 1;
67
+ return;
68
+ }
69
+
70
+ const lines = [
71
+ `Codex governance reconciliation (${workspace})`,
72
+ ` tasks ensured: ${report.tasksEnsured} (links repaired: ${report.linksRepaired})`,
73
+ ` promotion tails: pending=${report.pendingTails} completed-now=${report.completedTails} stale=${report.staleTails}`,
74
+ ];
75
+ for (const degradation of report.degradations) {
76
+ lines.push(` degradation: ${degradation}`);
77
+ }
78
+ if (!report.ok) {
79
+ lines.push(` reason: ${report.reason}`);
80
+ lines.push(` next action: ${report.nextAction}`);
81
+ }
82
+ console.log(lines.join('\n'));
83
+ if (!report.ok) process.exitCode = 1;
84
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * pd codex worker command implementation (Codex Governance Closure Slice C,
3
+ * PRI-624; SPEC §13/§15; ADR-0020 §11.1).
4
+ *
5
+ * Runs the Workspace-scoped worker cycle — catch-up → reconciliation → one
6
+ * Diagnostician execution → one bounded downstream consumer cycle — either
7
+ * once (manual / Companion-supervised restart mode) or continuously.
8
+ *
9
+ * `status` answers the SPEC §15 worker-mode question without executing
10
+ * anything: `manual_action_required` means no Companion-registered worker
11
+ * serves this workspace (it is not in the install manifest) and the manual
12
+ * commands below are the recovery path.
13
+ *
14
+ * CLI gate compliance:
15
+ * - cli-1: --json is restricted to the single-output forms (--once / --status)
16
+ * so stdout is exactly one parseable JSON object; continuous mode streams
17
+ * human-readable reports instead (review P2).
18
+ * - cli-2: exit paths stop execution.
19
+ * - cli-6: paused/degraded modes carry reason + nextAction.
20
+ */
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
25
+ import { runCodexWorkspaceWorkerCycle, type CodexWorkerCycleResult } from '@principles/codex-adapter';
26
+ import { getInstallLayoutPaths, parseInstallManifest } from '@principles/install-layout';
27
+
28
+ const DEFAULT_INTERVAL_MS = 120_000;
29
+
30
+ interface CodexWorkerOptions {
31
+ workspace?: string;
32
+ json?: boolean;
33
+ once?: boolean;
34
+ intervalMs?: number;
35
+ status?: boolean;
36
+ }
37
+
38
+ export interface CodexWorkerStatusReport {
39
+ generatedAt: string;
40
+ host: 'codex';
41
+ workspace: string;
42
+ mode: 'ready' | 'manual_action_required' | 'paused' | 'degraded';
43
+ registeredInInstallManifest: boolean;
44
+ reason?: string;
45
+ nextAction?: string;
46
+ }
47
+
48
+ async function printStatusReport(workspace: string, json: boolean): Promise<void> {
49
+ const generatedAt = new Date().toISOString();
50
+ const paths = getInstallLayoutPaths(os.homedir());
51
+ let registered = false;
52
+ let manifestError: string | undefined;
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(paths.manifest, 'utf8')) as unknown;
55
+ const parsed = parseInstallManifest(raw);
56
+ if (parsed.manifest !== undefined) {
57
+ registered = parsed.manifest.workspaces?.some((entry) => path.resolve(entry) === path.resolve(workspace)) ?? false;
58
+ } else {
59
+ manifestError = parsed.error;
60
+ }
61
+ } catch {
62
+ manifestError = 'install_manifest_unreadable';
63
+ }
64
+
65
+ // The status scan itself delegates mode computation to the cycle module's
66
+ // flag/config semantics by running a paused-compatible evaluation: a real
67
+ // cycle is NOT run (no execution, no lease, no transcript I/O).
68
+ const { computeCodexWorkerStatusMode } = await import('@principles/codex-adapter');
69
+ const evaluation = computeCodexWorkerStatusMode({ workspaceDir: workspace, registeredInInstallManifest: registered });
70
+
71
+ const report: CodexWorkerStatusReport = {
72
+ generatedAt,
73
+ host: 'codex',
74
+ workspace,
75
+ mode: evaluation.mode,
76
+ registeredInInstallManifest: registered,
77
+ ...(evaluation.reason !== undefined ? { reason: evaluation.reason } : {}),
78
+ ...(evaluation.nextAction !== undefined ? { nextAction: evaluation.nextAction } : {}),
79
+ ...(manifestError !== undefined ? { reason: `${evaluation.reason ?? manifestError}` } : {}),
80
+ };
81
+ if (json) {
82
+ console.log(JSON.stringify(report));
83
+ return;
84
+ }
85
+ const lines = [
86
+ `Codex workspace worker status (${workspace})`,
87
+ ` mode: ${report.mode}`,
88
+ ` registered in install manifest: ${registered}`,
89
+ ];
90
+ if (report.reason !== undefined) lines.push(` reason: ${report.reason}`);
91
+ if (report.nextAction !== undefined) lines.push(` next action: ${report.nextAction}`);
92
+ console.log(lines.join('\n'));
93
+ if (report.mode === 'degraded') process.exitCode = 1;
94
+ }
95
+
96
+ function printCycleReport(result: CodexWorkerCycleResult, json: boolean): void {
97
+ if (json) {
98
+ console.log(JSON.stringify(result));
99
+ return;
100
+ }
101
+ const lines = [
102
+ `Codex workspace worker (${result.workspaceDir})`,
103
+ ` mode: ${result.mode}${result.reason !== undefined ? ` (${result.reason})` : ''}`,
104
+ ];
105
+ if (result.report !== undefined) {
106
+ const {catchUp} = result.report;
107
+ const catchUpSummary = catchUp.status === 'skipped' ? `skipped (${catchUp.reason})` : `${catchUp.status} — processed=${catchUp.rollouts.length} lag=${catchUp.remainingLagRollouts.length}`;
108
+ lines.push(` catch-up: ${catchUpSummary}`);
109
+ lines.push(` reconcile: ok=${result.report.reconcile.ok} ensured=${result.report.reconcile.tasksEnsured} linksRepaired=${result.report.reconcile.linksRepaired}`);
110
+ if (result.report.diagnostician !== null) {
111
+ lines.push(` diagnostician: ${result.report.diagnostician.status} (${result.report.diagnostician.taskId})`);
112
+ } else {
113
+ lines.push(' diagnostician: none pending');
114
+ }
115
+ if (result.report.downstream !== null) {
116
+ lines.push(` downstream: ran=${result.report.downstream.ran}${result.report.downstream.taskKind !== undefined ? ` kind=${result.report.downstream.taskKind}` : ''}${result.report.downstream.skipReason !== undefined ? ` skip=${result.report.downstream.skipReason}` : ''}`);
117
+ }
118
+ }
119
+ if (result.nextAction !== undefined) lines.push(` next action: ${result.nextAction}`);
120
+ console.log(lines.join('\n'));
121
+ }
122
+
123
+ export async function handleCodexWorker(options: CodexWorkerOptions): Promise<void> {
124
+ // cli-1: continuous mode streams one report per cycle — that is NOT one
125
+ // parseable JSON document. --json is only valid for the single-output
126
+ // forms (--once / --status), enforced here (review P2).
127
+ if (options.json === true && options.once !== true && options.status !== true) {
128
+ const error = {
129
+ generatedAt: new Date().toISOString(),
130
+ host: 'codex',
131
+ error: 'cli_contract',
132
+ reason: 'json_without_once_or_status',
133
+ nextAction: 'Use --json with --once (single cycle) or --status (no-op scan); continuous mode streams human-readable reports instead.',
134
+ };
135
+ console.log(JSON.stringify(error));
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+
140
+ const workspace = resolveWorkspaceDir(options.workspace);
141
+
142
+ if (options.status === true) {
143
+ await printStatusReport(workspace, options.json === true);
144
+ return;
145
+ }
146
+
147
+ if (options.once === true) {
148
+ const result = await runCodexWorkspaceWorkerCycle({ workspaceDir: workspace, env: { CODEX_HOME: process.env.CODEX_HOME } });
149
+ printCycleReport(result, options.json === true);
150
+ if (result.mode === 'degraded') process.exitCode = 1;
151
+ return;
152
+ }
153
+
154
+ const interval = typeof options.intervalMs === 'number' && options.intervalMs >= 1000 ? options.intervalMs : DEFAULT_INTERVAL_MS;
155
+ let stopped = false;
156
+ const onSignal = (): void => {
157
+ stopped = true;
158
+ };
159
+ process.on('SIGINT', onSignal);
160
+ process.on('SIGTERM', onSignal);
161
+ try {
162
+ while (!stopped) {
163
+ const result = await runCodexWorkspaceWorkerCycle({ workspaceDir: workspace, env: { CODEX_HOME: process.env.CODEX_HOME } });
164
+ printCycleReport(result, false);
165
+ if (stopped) break;
166
+ await new Promise((resolve) => setTimeout(resolve, interval));
167
+ }
168
+ } finally {
169
+ process.off('SIGINT', onSignal);
170
+ process.off('SIGTERM', onSignal);
171
+ }
172
+ }
173
+
174
+ /** SPEC §15 worker mode, evaluated without executing anything. */
@@ -369,7 +369,8 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
369
369
  }
370
370
 
371
371
  // 4) Read auth token for health probes (PD_CONSOLE_TOKEN)
372
- const token = opts.token ?? process.env.PD_CONSOLE_TOKEN;
372
+ const rawToken = opts.token ?? process.env.PD_CONSOLE_TOKEN;
373
+ const token = !opts.noAuth && rawToken?.trim() ? rawToken.trim() : undefined;
373
374
 
374
375
  // 5) Plan the launch (reuse or fresh bind)
375
376
  let plan;
@@ -461,6 +462,7 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
461
462
  workspaceDir,
462
463
  reused: true,
463
464
  browserOpened,
465
+ ...(health.authenticationMode ? { authenticationMode: health.authenticationMode } : {}),
464
466
  nextAction: browserOpened
465
467
  ? 'Browser opened to the running Console.'
466
468
  : `Open ${plan.url} in your browser to access the Console.`,
@@ -535,10 +537,15 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
535
537
  // 7) Wait for console ready (bounded poll)
536
538
  const readyDeadline = Date.now() + 15_000;
537
539
  let ready = false;
540
+ let readyAuthenticationMode: 'authenticated' | 'no_auth' | undefined;
538
541
  while (Date.now() < readyDeadline) {
539
542
  if (child.exitCode !== null) break;
540
543
  const h = await probeConsoleHealth({ host: plan.host, port: plan.port, timeoutMs: 1000, token });
541
- if (h.healthy) { ready = true; break; }
544
+ if (h.healthy) {
545
+ ready = true;
546
+ readyAuthenticationMode = h.authenticationMode;
547
+ break;
548
+ }
542
549
  await sleep(250);
543
550
  }
544
551
 
@@ -587,6 +594,31 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
587
594
  return;
588
595
  }
589
596
 
597
+ const expectedAuthenticationMode = token?.trim() ? 'authenticated' : 'no_auth';
598
+ if (readyAuthenticationMode !== expectedAuthenticationMode) {
599
+ try { child.kill('SIGTERM'); } catch { /* child may already have exited */ }
600
+ const result: ConsoleLaunchResult = {
601
+ status: 'refused',
602
+ url: '',
603
+ port: plan.port,
604
+ host: plan.host,
605
+ workspaceDir,
606
+ reused: false,
607
+ browserOpened: false,
608
+ authenticationMode: readyAuthenticationMode,
609
+ reason: 'console_authentication_mode_mismatch',
610
+ nextAction: 'Stop the Console, verify PD_CONSOLE_TOKEN propagation, and retry.',
611
+ };
612
+ if (opts.json) {
613
+ console.log(JSON.stringify(result, null, 2));
614
+ } else {
615
+ console.error(`error: ${result.reason}`);
616
+ console.error(`next: ${result.nextAction}`);
617
+ }
618
+ process.exit(1);
619
+ return;
620
+ }
621
+
590
622
  // 7) Console is ready → optionally open browser, emit result, then keep child running
591
623
  let browserOpened = false;
592
624
  let browserWarning: string | undefined;
@@ -604,6 +636,7 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
604
636
  workspaceDir,
605
637
  reused: false,
606
638
  browserOpened,
639
+ ...(readyAuthenticationMode ? { authenticationMode: readyAuthenticationMode } : {}),
607
640
  nextAction: browserOpened
608
641
  ? 'Browser opened to the Console. Press Ctrl+C to stop.'
609
642
  : `Open ${plan.url} in your browser. Press Ctrl+C to stop.`,
@@ -183,10 +183,12 @@ function parsePainDetectedData(value: unknown):
183
183
  }
184
184
  if (Object.hasOwn(obj, 'provenance')) {
185
185
  const prov = obj.provenance;
186
- if (prov !== 'openclaw_context_bound' && prov !== 'owner_reported_no_host_trace' && prov !== 'automatic_hook') {
186
+ // host_context_bound is the current value (SPEC §12); the legacy
187
+ // openclaw_context_bound spelling remains valid for replay of old dead letters.
188
+ if (prov !== 'host_context_bound' && prov !== 'openclaw_context_bound' && prov !== 'owner_reported_no_host_trace' && prov !== 'automatic_hook') {
187
189
  return { valid: false, error: `painData.provenance is not a known literal (got ${JSON.stringify(prov)})` };
188
190
  }
189
- data.provenance = prov;
191
+ data.provenance = prov === 'openclaw_context_bound' ? 'host_context_bound' : prov;
190
192
  }
191
193
  if (Object.hasOwn(obj, 'evidence')) {
192
194
  if (!Array.isArray(obj.evidence)) {
package/src/index.ts CHANGED
@@ -563,6 +563,81 @@ runtimeHealthCmd
563
563
  await handleRuntimeHealthSnapshot({ workspace: opts.workspace, json: opts.json });
564
564
  });
565
565
 
566
+ // ── pd codex reconcile — Codex governance continuation recovery ───────────────
567
+ // Codex Governance Closure Slice B (PRI-623, SPEC §13/§20): runs the idempotent
568
+ // cross-store reconciliation pass (admitted pain → missing Diagnostician task,
569
+ // task-before-link, pending promotion tails) exposed for the CLI now and the
570
+ // Slice C Companion worker later.
571
+ const codexCmd = program
572
+ .command('codex')
573
+ .description('Codex host governance operations');
574
+
575
+ codexCmd
576
+ .command('reconcile')
577
+ .description('Reconcile admitted Codex pains with Diagnostician tasks and promotion tails (idempotent)')
578
+ .option('-w, --workspace <path>', 'Workspace directory')
579
+ .option('--limit <n>', 'Maximum admitted pains to reconcile per pass (1-200, default 50)')
580
+ .option('--json', 'Output raw JSON')
581
+ .action(async (opts) => {
582
+ const { handleCodexReconcile } = await import('./commands/codex-reconcile.js');
583
+ const limit = typeof opts.limit === 'string' ? Number.parseInt(opts.limit, 10) : undefined;
584
+ await handleCodexReconcile({
585
+ workspace: opts.workspace,
586
+ json: opts.json === true,
587
+ ...(limit !== undefined && Number.isInteger(limit) ? { limit } : {}),
588
+ });
589
+ });
590
+
591
+ // ── pd codex ingest catch-up — bounded manual transcript lag recovery ────────
592
+ // Codex Governance Closure Slice C (PRI-624, SPEC §15): the manual-mode
593
+ // counterpart of the Companion worker's catch-up step. Zero transcript I/O
594
+ // when codex_conversation_ingestion is disabled.
595
+ const codexIngestCmd = codexCmd
596
+ .command('ingest')
597
+ .description('Codex conversation-ingestion operations');
598
+
599
+ codexIngestCmd
600
+ .command('catch-up')
601
+ .description('Catch up transcript lag from durable checkpoints (bounded, non-destructive, no LLM)')
602
+ .option('-w, --workspace <path>', 'Workspace directory')
603
+ .option('--max-rollouts <n>', 'Maximum rollouts to catch up per pass (1-32, default 8)')
604
+ .option('--json', 'Output raw JSON')
605
+ .action(async (opts) => {
606
+ const { handleCodexIngestCatchUp } = await import('./commands/codex-ingest-catchup.js');
607
+ const maxRollouts = typeof opts.maxRollouts === 'string' ? Number.parseInt(opts.maxRollouts, 10) : undefined;
608
+ await handleCodexIngestCatchUp({
609
+ workspace: opts.workspace,
610
+ json: opts.json === true,
611
+ ...(maxRollouts !== undefined && Number.isInteger(maxRollouts) ? { maxRollouts } : {}),
612
+ });
613
+ });
614
+
615
+ // ── pd codex worker — Workspace-scoped governance worker ─────────────────────
616
+ // Codex Governance Closure Slice C (PRI-624, SPEC §13; ADR-0020 §11.1): the
617
+ // one Owner-approved background worker (catch-up → reconciliation → one
618
+ // Diagnostician execution → one bounded downstream consumer cycle). The
619
+ // Companion spawns this per registered workspace; --once is the manual and
620
+ // supervised-restart form.
621
+ codexCmd
622
+ .command('worker')
623
+ .description('Run the Codex workspace governance worker (catch-up, reconciliation, diagnostician, downstream)')
624
+ .option('-w, --workspace <path>', 'Workspace directory')
625
+ .option('--once', 'Run exactly one bounded cycle and exit')
626
+ .option('--interval <ms>', 'Cycle interval for continuous mode (default 120000, minimum 1000)')
627
+ .option('--status', 'Report the SPEC §15 worker mode without executing anything')
628
+ .option('--json', 'Output raw JSON')
629
+ .action(async (opts) => {
630
+ const { handleCodexWorker } = await import('./commands/codex-worker.js');
631
+ const intervalMs = typeof opts.interval === 'string' ? Number.parseInt(opts.interval, 10) : undefined;
632
+ await handleCodexWorker({
633
+ workspace: opts.workspace,
634
+ json: opts.json === true,
635
+ once: opts.once === true,
636
+ status: opts.status === true,
637
+ ...(intervalMs !== undefined && Number.isInteger(intervalMs) ? { intervalMs } : {}),
638
+ });
639
+ });
640
+
566
641
  runtimeHealthCmd
567
642
  .command('gfi')
568
643
  .description('GFI workspace snapshot — active vs stale session breakdown')
@@ -21,6 +21,7 @@ import * as http from 'http';
21
21
  // ─── Public types ────────────────────────────────────────────────────────────
22
22
 
23
23
  export type ConsoleStatus = 'reused' | 'started' | 'failed' | 'refused';
24
+ export type ConsoleAuthenticationMode = 'authenticated' | 'no_auth';
24
25
 
25
26
  export interface ConsoleLaunchResult {
26
27
  status: ConsoleStatus;
@@ -34,6 +35,8 @@ export interface ConsoleLaunchResult {
34
35
  reused: boolean;
35
36
  /** True when a browser should/has been opened (skipped in --json mode). */
36
37
  browserOpened: boolean;
38
+ /** Verified for successful launch/reuse; omitted when no server was reached. */
39
+ authenticationMode?: ConsoleAuthenticationMode;
37
40
  /**
38
41
  * PID of the freshly spawned console server process. Present only when
39
42
  * status === 'started'; absent on 'reused' (the server was started by
@@ -58,6 +61,10 @@ const DEFAULT_PORT = 3100;
58
61
  const DEFAULT_HOST = '127.0.0.1';
59
62
  const PORT_FALLBACK_LIMIT = 20; // try 3100..3119 before giving up
60
63
 
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
61
68
  // ─── Loopback safety (ERR-049) ──────────────────────────────────────────────
62
69
 
63
70
  /** Returns true if the host resolves to a loopback address. */
@@ -138,13 +145,20 @@ export interface HealthProbeOptions {
138
145
  }
139
146
 
140
147
  /** Probe a port to see if it serves a healthy PD Console. */
141
- export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ healthy: boolean; reason?: string }> {
148
+ export interface ConsoleHealthProbeResult {
149
+ healthy: boolean;
150
+ authenticationMode?: ConsoleAuthenticationMode;
151
+ reason?: string;
152
+ failureKind?: 'unauthorized' | 'invalid_response' | 'unreachable';
153
+ }
154
+
155
+ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<ConsoleHealthProbeResult> {
142
156
  const { host, port, timeoutMs = 1500, token } = opts;
143
157
 
144
158
  if (Object.hasOwn(globalThis, '__mockProbeConsoleHealth')) {
145
159
  const mock = Reflect.get(globalThis, '__mockProbeConsoleHealth') as (
146
160
  o: HealthProbeOptions
147
- ) => Promise<{ healthy: boolean; reason?: string }>;
161
+ ) => Promise<ConsoleHealthProbeResult>;
148
162
  return mock(opts);
149
163
  }
150
164
  return new Promise((resolve) => {
@@ -157,7 +171,7 @@ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ he
157
171
  (res) => {
158
172
  // 401 means auth required — treat as unhealthy, not a generic error
159
173
  if (res.statusCode === 401) {
160
- resolve({ healthy: false, reason: 'console health endpoint returned 401 (unauthorized) — check PD_CONSOLE_TOKEN' });
174
+ resolve({ healthy: false, failureKind: 'unauthorized', reason: 'console health endpoint returned 401 (unauthorized) — check PD_CONSOLE_TOKEN' });
161
175
  return;
162
176
  }
163
177
  if (res.statusCode !== 200) {
@@ -170,13 +184,19 @@ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ he
170
184
  });
171
185
  res.on('end', () => {
172
186
  try {
173
- const body = JSON.parse(data) as unknown;
174
- if (body && typeof body === 'object') {
187
+ const body: unknown = JSON.parse(data);
188
+ if (isRecord(body)) {
175
189
  const isHealthy =
176
190
  (Object.hasOwn(body, 'healthy') && Reflect.get(body, 'healthy') === true) ||
177
191
  (Object.hasOwn(body, 'success') && Reflect.get(body, 'success') === true);
178
192
  if (isHealthy) {
179
- resolve({ healthy: true });
193
+ const dataValue = Object.hasOwn(body, 'data') ? Reflect.get(body, 'data') : undefined;
194
+ const payloadRecord = isRecord(dataValue) ? dataValue : body;
195
+ const mode = Reflect.get(payloadRecord, 'authenticationMode');
196
+ resolve({
197
+ healthy: true,
198
+ ...(mode === 'authenticated' || mode === 'no_auth' ? { authenticationMode: mode } : {}),
199
+ });
180
200
  } else {
181
201
  resolve({ healthy: false, reason: 'console health JSON was missing healthy/success markers' });
182
202
  }
@@ -387,6 +407,17 @@ export async function planConsoleLaunch(input: OrchestratorInput): Promise<Orche
387
407
  // Step 1: Is there already a healthy console on the preferred port?
388
408
  const health = await probeConsoleHealth({ host, port: preferredPort, token });
389
409
  if (health.healthy) {
410
+ if (token && health.authenticationMode !== 'authenticated') {
411
+ return {
412
+ status: 'refused',
413
+ url: '',
414
+ port: preferredPort,
415
+ host,
416
+ reused: false,
417
+ reason: 'console_authentication_mode_mismatch',
418
+ nextAction: `Stop the Console on port ${preferredPort}, then retry so Companion can start an authenticated Console.`,
419
+ };
420
+ }
390
421
  return {
391
422
  status: 'reused',
392
423
  url: buildConsoleUrl(host, preferredPort),
@@ -396,6 +427,18 @@ export async function planConsoleLaunch(input: OrchestratorInput): Promise<Orche
396
427
  };
397
428
  }
398
429
 
430
+ if (token && health.failureKind === 'unauthorized') {
431
+ return {
432
+ status: 'refused',
433
+ url: '',
434
+ port: preferredPort,
435
+ host,
436
+ reused: false,
437
+ reason: 'console_authentication_failed',
438
+ nextAction: 'Verify PD_CONSOLE_TOKEN matches the running Console, stop that Console, then retry.',
439
+ };
440
+ }
441
+
399
442
  // Step 2: Is the preferred port simply occupied by something else?
400
443
  const preferredInUse = await isPortInUse(host, preferredPort);
401
444