@principles/pd-cli 1.144.0 → 1.145.1

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * pd pain list command — PRI-640 (Governance Signal Host Attribution v0.1)
3
+ *
4
+ * Read-only: lists canonical pain_events rows from trajectory.db with their
5
+ * host attribution. `--host` filters by openclaw / codex / unknown.
6
+ *
7
+ * Usage:
8
+ * pd pain list [--workspace <path>] [--limit N] [--host openclaw|codex|unknown] [--json]
9
+ *
10
+ * Host semantics (SPEC §6/§12): host_kind is observability metadata only.
11
+ * NULL (legacy / manual / unprovable) is reported as `unknown` — never guessed.
12
+ * On a pre-PRI-640 database without the host_kind column every row reports
13
+ * `unknown` and a `host_kind_column_missing` warning is emitted (rc-9).
14
+ */
15
+
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
19
+
20
+ export type PainListHostFilter = 'openclaw' | 'codex' | 'unknown';
21
+
22
+ export interface PainListOptions {
23
+ workspace?: string;
24
+ limit?: number;
25
+ host?: string;
26
+ json?: boolean;
27
+ }
28
+
29
+ export interface PainListEntry {
30
+ /** Canonical pain identity; `row:<id>` when the legacy row has none. */
31
+ painId: string;
32
+ source: string;
33
+ /** 'openclaw' | 'codex' | 'unknown' — unknown covers NULL/unprovable. */
34
+ host: PainListHostFilter | 'unknown';
35
+ score: number;
36
+ severity: string | null;
37
+ createdAt: string;
38
+ runtimeTaskId: string | null;
39
+ }
40
+
41
+ export interface PainListResult {
42
+ count: number;
43
+ pains: PainListEntry[];
44
+ workspace: string;
45
+ hostFilter: PainListHostFilter | null;
46
+ warnings: string[];
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function ownField(row: unknown, key: string): unknown {
54
+ return isRecord(row) && Object.hasOwn(row, key) ? row[key] : undefined;
55
+ }
56
+
57
+ /** Runtime Contract #1/#4: validate each DB row; skip malformed rows loudly. */
58
+ function toPainEntry(row: unknown): PainListEntry | null {
59
+ const id = ownField(row, 'id');
60
+ const source = ownField(row, 'source');
61
+ const score = ownField(row, 'score');
62
+ const createdAt = ownField(row, 'created_at');
63
+ if (typeof id !== 'number' || typeof source !== 'string' || typeof score !== 'number' || typeof createdAt !== 'string') {
64
+ return null;
65
+ }
66
+ const canonicalPainId = ownField(row, 'canonical_pain_id');
67
+ const hostKind = ownField(row, 'host_kind');
68
+ const severity = ownField(row, 'severity');
69
+ const runtimeTaskId = ownField(row, 'runtime_task_id');
70
+ const host: PainListEntry['host'] =
71
+ hostKind === 'openclaw' || hostKind === 'codex' ? hostKind : 'unknown';
72
+ return {
73
+ painId: typeof canonicalPainId === 'string' && canonicalPainId.length > 0 ? canonicalPainId : `row:${id}`,
74
+ source,
75
+ host,
76
+ score,
77
+ severity: typeof severity === 'string' ? severity : null,
78
+ createdAt,
79
+ runtimeTaskId: typeof runtimeTaskId === 'string' && runtimeTaskId.length > 0 ? runtimeTaskId : null,
80
+ };
81
+ }
82
+
83
+ export async function listPains(dbPath: string, options: { limit?: number; host?: PainListHostFilter } = {}): Promise<PainListResult> {
84
+ const Database = (await import('better-sqlite3')).default;
85
+ const db = new Database(dbPath, { readonly: true });
86
+ try {
87
+ const warnings: string[] = [];
88
+ const columns: unknown[] = db.prepare('PRAGMA table_info(pain_events)').all();
89
+ const hasHostKindColumn = columns.some((row) => ownField(row, 'name') === 'host_kind');
90
+ if (!hasHostKindColumn) {
91
+ warnings.push('host_kind_column_missing');
92
+ }
93
+
94
+ const params: (string | number)[] = [];
95
+ let query = 'SELECT id, source, score, severity, created_at, canonical_pain_id, runtime_task_id';
96
+ query += hasHostKindColumn ? ', host_kind' : ", NULL AS host_kind";
97
+ query += ' FROM pain_events WHERE 1=1';
98
+ if (options.host === 'unknown') {
99
+ query += hasHostKindColumn ? ' AND host_kind IS NULL' : '';
100
+ } else if (options.host === 'openclaw' || options.host === 'codex') {
101
+ if (!hasHostKindColumn) {
102
+ return { count: 0, pains: [], workspace: path.dirname(path.dirname(dbPath)), hostFilter: options.host, warnings };
103
+ }
104
+ query += ' AND host_kind = ?';
105
+ params.push(options.host);
106
+ }
107
+ query += ' ORDER BY created_at DESC, id DESC';
108
+ const limit = options.limit ?? 20;
109
+ query += ' LIMIT ?';
110
+ params.push(limit);
111
+
112
+ const rawRows: unknown[] = db.prepare(query).all(...params);
113
+ const skipped = rawRows.length - rawRows.map(toPainEntry).filter((entry) => entry !== null).length;
114
+ if (skipped > 0) {
115
+ warnings.push(`malformed_rows_skipped:${skipped}`);
116
+ }
117
+ const pains = rawRows
118
+ .map(toPainEntry)
119
+ .filter((entry): entry is PainListEntry => entry !== null);
120
+ return {
121
+ count: pains.length,
122
+ pains,
123
+ workspace: path.dirname(path.dirname(dbPath)),
124
+ hostFilter: options.host ?? null,
125
+ warnings,
126
+ };
127
+ } finally {
128
+ db.close();
129
+ }
130
+ }
131
+
132
+ function printHuman(result: PainListResult): void {
133
+ const filterNote = result.hostFilter ? ` (host: ${result.hostFilter})` : '';
134
+ console.log(`Pain events — ${result.count} shown${filterNote}`);
135
+ console.log('─'.repeat(96));
136
+ for (const pain of result.pains) {
137
+ const painId = pain.painId.length > 40 ? `${pain.painId.slice(0, 37)}...` : pain.painId;
138
+ console.log(` ${pain.createdAt} host=${pain.host.padEnd(8)} score=${String(pain.score).padEnd(3)} ${pain.severity ?? '-'.padEnd(6)} ${pain.source}`);
139
+ console.log(` id: ${painId}${pain.runtimeTaskId ? ` | task: ${pain.runtimeTaskId}` : ''}`);
140
+ }
141
+ console.log('─'.repeat(96));
142
+ for (const warning of result.warnings) {
143
+ console.error(`WARN: ${warning}`);
144
+ }
145
+ if (result.warnings.includes('host_kind_column_missing')) {
146
+ console.error('Next: run `pd runtime init --workspace <dir>` (or restart the OpenClaw plugin) to migrate this workspace schema.');
147
+ }
148
+ }
149
+
150
+ export async function handlePainList(opts: PainListOptions): Promise<void> {
151
+ const { workspace, limit: rawLimit, host: rawHost, json } = opts;
152
+
153
+ if (rawHost !== undefined && rawHost !== 'openclaw' && rawHost !== 'codex' && rawHost !== 'unknown') {
154
+ const message = `invalid host filter: expected openclaw | codex | unknown, got ${rawHost}`;
155
+ if (json) {
156
+ console.log(JSON.stringify({ status: 'failed', reason: 'invalid_host_filter', message, nextAction: 'Pass --host openclaw, --host codex, or --host unknown.' }));
157
+ } else {
158
+ console.error('Error: ' + message);
159
+ console.error('Next: Pass --host openclaw, --host codex, or --host unknown.');
160
+ }
161
+ process.exit(1);
162
+ return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
163
+ }
164
+ const hostFilter: PainListHostFilter | undefined = rawHost;
165
+
166
+ let effectiveLimit = 20;
167
+ if (rawLimit !== undefined) {
168
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 10000) {
169
+ const message = `invalid limit: limit must be an integer between 1 and 10000, got ${rawLimit}`;
170
+ if (json) {
171
+ console.log(JSON.stringify({ status: 'failed', reason: 'invalid_limit', message, nextAction: 'Pass --limit with a valid integer (1-10000).' }));
172
+ } else {
173
+ console.error('Error: ' + message);
174
+ console.error('Next: Pass --limit with a valid integer (1-10000).');
175
+ }
176
+ process.exit(1);
177
+ return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
178
+ }
179
+ effectiveLimit = rawLimit;
180
+ }
181
+
182
+ const workspaceDir = resolveWorkspaceDir(workspace);
183
+ const dbPath = path.join(workspaceDir, '.state', 'trajectory.db');
184
+ if (!fs.existsSync(dbPath)) {
185
+ const message = `trajectory database not found at ${dbPath}`;
186
+ if (json) {
187
+ console.log(JSON.stringify({ status: 'failed', reason: 'trajectory_db_not_found', message, nextAction: 'Initialize the PD workspace first (pd runtime init), or pass --workspace pointing at an initialized workspace.' }));
188
+ } else {
189
+ console.error('Error: ' + message);
190
+ console.error('Next: Initialize the PD workspace first (pd runtime init), or pass --workspace pointing at an initialized workspace.');
191
+ }
192
+ process.exit(1);
193
+ return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
194
+ }
195
+
196
+ const result = await listPains(dbPath, { limit: effectiveLimit, ...(hostFilter ? { host: hostFilter } : {}) });
197
+
198
+ if (json) {
199
+ console.log(JSON.stringify(result, null, 2));
200
+ return;
201
+ }
202
+ printHuman(result);
203
+ }
@@ -95,12 +95,23 @@ const DB_NAMES = {
95
95
  */
96
96
  function buildConfigYaml(workspaceDir: string): string {
97
97
  const config = getDefaultPdConfig();
98
+ // PRI-637: `pd runtime init` is PD machinery writing a bootstrap snapshot, so
99
+ // every flag entry records `source: 'system'` — an ORIGIN HINT, NOT proof that
100
+ // the value lacks Owner intent (direct .pd/config.yaml editing is a supported
101
+ // path; an Owner may later pin behavior on top of this entry). Labels are
102
+ // metadata only; effective values are unchanged. If a flag graduates, system-
103
+ // origin entries are cleanup CANDIDATES that still require explicit Owner
104
+ // confirmation before removal.
105
+ const features: Record<string, unknown> = {};
106
+ for (const [id, entry] of Object.entries(config.features)) {
107
+ features[id] = { category: entry.category, enabled: entry.enabled, source: 'system' };
108
+ }
98
109
  const yamlObj: Record<string, unknown> = {
99
110
  version: config.version,
100
111
  workspace: {
101
112
  default: workspaceDir,
102
113
  },
103
- features: config.features,
114
+ features,
104
115
  runtimeProfiles: config.runtimeProfiles,
105
116
  internalAgents: config.internalAgents,
106
117
  ui: config.ui,
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { Command } from 'commander';
10
10
  import { handlePainRecord } from './commands/pain-record.js';
11
11
  import { registerPainRetryCommand } from './commands/pain-retry.js';
12
12
  import { handlePainEvidence } from './commands/pain-evidence.js';
13
+ import { handlePainList } from './commands/pain-list.js';
13
14
  import { handleSamplesList } from './commands/samples-list.js';
14
15
  import { handleSamplesReview } from './commands/samples-review.js';
15
16
  import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
@@ -130,6 +131,17 @@ painCmd
130
131
 
131
132
  registerPainRetryCommand(painCmd);
132
133
 
134
+ painCmd
135
+ .command('list')
136
+ .description('List canonical pain events with host attribution (PRI-640)')
137
+ .option('-w, --workspace <path>', 'Workspace directory')
138
+ .option('-l, --limit <number>', 'Max entries to show (default: 20)', parseInt)
139
+ .option('--host <kind>', 'Filter by host: openclaw | codex | unknown')
140
+ .option('--json', 'Output raw JSON')
141
+ .action(async (opts) => {
142
+ await handlePainList(opts);
143
+ });
144
+
133
145
  painCmd
134
146
  .command('evidence')
135
147
  .description('Show recent TRIGGER_DECISION log entries — pain admission gate decisions only (PEAT-B2)')
@@ -588,6 +600,56 @@ codexCmd
588
600
  });
589
601
  });
590
602
 
603
+ // ── pd codex ingest catch-up — bounded manual transcript lag recovery ────────
604
+ // Codex Governance Closure Slice C (PRI-624, SPEC §15): the manual-mode
605
+ // counterpart of the Companion worker's catch-up step. Zero transcript I/O
606
+ // when codex_conversation_ingestion is disabled.
607
+ const codexIngestCmd = codexCmd
608
+ .command('ingest')
609
+ .description('Codex conversation-ingestion operations');
610
+
611
+ codexIngestCmd
612
+ .command('catch-up')
613
+ .description('Catch up transcript lag from durable checkpoints (bounded, non-destructive, no LLM)')
614
+ .option('-w, --workspace <path>', 'Workspace directory')
615
+ .option('--max-rollouts <n>', 'Maximum rollouts to catch up per pass (1-32, default 8)')
616
+ .option('--json', 'Output raw JSON')
617
+ .action(async (opts) => {
618
+ const { handleCodexIngestCatchUp } = await import('./commands/codex-ingest-catchup.js');
619
+ const maxRollouts = typeof opts.maxRollouts === 'string' ? Number.parseInt(opts.maxRollouts, 10) : undefined;
620
+ await handleCodexIngestCatchUp({
621
+ workspace: opts.workspace,
622
+ json: opts.json === true,
623
+ ...(maxRollouts !== undefined && Number.isInteger(maxRollouts) ? { maxRollouts } : {}),
624
+ });
625
+ });
626
+
627
+ // ── pd codex worker — Workspace-scoped governance worker ─────────────────────
628
+ // Codex Governance Closure Slice C (PRI-624, SPEC §13; ADR-0020 §11.1): the
629
+ // one Owner-approved background worker (catch-up → reconciliation → one
630
+ // Diagnostician execution → one bounded downstream consumer cycle). The
631
+ // Companion spawns this per registered workspace; --once is the manual and
632
+ // supervised-restart form.
633
+ codexCmd
634
+ .command('worker')
635
+ .description('Run the Codex workspace governance worker (catch-up, reconciliation, diagnostician, downstream)')
636
+ .option('-w, --workspace <path>', 'Workspace directory')
637
+ .option('--once', 'Run exactly one bounded cycle and exit')
638
+ .option('--interval <ms>', 'Cycle interval for continuous mode (default 120000, minimum 1000)')
639
+ .option('--status', 'Report the SPEC §15 worker mode without executing anything')
640
+ .option('--json', 'Output raw JSON')
641
+ .action(async (opts) => {
642
+ const { handleCodexWorker } = await import('./commands/codex-worker.js');
643
+ const intervalMs = typeof opts.interval === 'string' ? Number.parseInt(opts.interval, 10) : undefined;
644
+ await handleCodexWorker({
645
+ workspace: opts.workspace,
646
+ json: opts.json === true,
647
+ once: opts.once === true,
648
+ status: opts.status === true,
649
+ ...(intervalMs !== undefined && Number.isInteger(intervalMs) ? { intervalMs } : {}),
650
+ });
651
+ });
652
+
591
653
  runtimeHealthCmd
592
654
  .command('gfi')
593
655
  .description('GFI workspace snapshot — active vs stale session breakdown')
@@ -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
+ });
@@ -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