@principles/pd-cli 1.147.17 → 1.149.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.
@@ -105,6 +105,39 @@ describe('listPains (PRI-640 host filter)', () => {
105
105
  expect(result.pains[0]?.host).toBe('unknown');
106
106
  });
107
107
 
108
+ it('PRI-743: byHost reports the full-table distribution independent of --host/--limit', async () => {
109
+ const { db } = makeWorkspace();
110
+ seedPain(db, { id: 1, source: 'user_correction', canonical: 'pain_oc_1', host: 'openclaw', created: '2026-09-01T10:00:00.000Z' });
111
+ seedPain(db, { id: 2, source: 'user_correction', canonical: 'pain_oc_2', host: 'openclaw', created: '2026-09-01T10:05:00.000Z' });
112
+ seedPain(db, { id: 3, source: 'tool_failure', canonical: 'pain_cx_1', host: 'codex', created: '2026-09-01T11:00:00.000Z' });
113
+ seedPain(db, { id: 4, source: 'manual', canonical: 'pain_null_1', host: null, created: '2026-09-01T12:00:00.000Z' });
114
+ seedPain(db, { id: 5, source: 'manual', canonical: 'pain_null_2', host: null, created: '2026-09-01T12:05:00.000Z' });
115
+ const dbPath = dbPathOf(db);
116
+ db.close();
117
+
118
+ // --limit narrows the row list but must NOT narrow the distribution.
119
+ const limited = await listPains(dbPath, { limit: 2 });
120
+ expect(limited.count).toBe(2);
121
+ expect(limited.byHost).toEqual({ openclaw: 2, codex: 1, unknown: 2 });
122
+
123
+ // --host narrows the row list but must NOT narrow the distribution.
124
+ const filtered = await listPains(dbPath, { limit: 10, host: 'codex' });
125
+ expect(filtered.count).toBe(1);
126
+ expect(filtered.byHost).toEqual({ openclaw: 2, codex: 1, unknown: 2 });
127
+ });
128
+
129
+ it('PRI-743: byHost is null (never guessed) on a pre-PRI-640 database', async () => {
130
+ const { db } = makePre640Workspace();
131
+ db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
132
+ VALUES ('s1', 'tool_failure', 70, 'r', 'moderate', 'system_infer', NULL, NULL, 'pain_legacy', NULL, '2026-08-01T00:00:00.000Z')`).run();
133
+ const dbPath = dbPathOf(db);
134
+ db.close();
135
+
136
+ const result = await listPains(dbPath, { limit: 10 });
137
+ expect(result.byHost).toBeNull();
138
+ expect(result.warnings).toContain('host_kind_column_missing');
139
+ });
140
+
108
141
  it('degrades observably on a pre-PRI-640 database without the host_kind column (rc-9)', async () => {
109
142
  const { db } = makePre640Workspace();
110
143
  db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
@@ -152,11 +185,12 @@ describe('handlePainList (CLI contract)', () => {
152
185
  await handlePainList({ json: true });
153
186
  expect(exitSpy).not.toHaveBeenCalled();
154
187
  const raw = logSpy.mock.calls.map((args) => String(args[0])).join('\n');
155
- const parsed = JSON.parse(raw) as { count: number; pains: { host: string; painId: string }[]; hostFilter: unknown; warnings: string[] };
188
+ const parsed = JSON.parse(raw) as { count: number; pains: { host: string; painId: string }[]; hostFilter: unknown; warnings: string[]; byHost: { openclaw: number; codex: number; unknown: number } | null };
156
189
  expect(parsed.count).toBe(2);
157
190
  expect(parsed.pains.map((p) => `${p.painId}:${p.host}`).sort()).toEqual(['pain_cli_cx:codex', 'pain_cli_oc:openclaw']);
158
191
  expect(parsed.hostFilter).toBeNull();
159
192
  expect(parsed.warnings).toEqual([]);
193
+ expect(parsed.byHost).toEqual({ openclaw: 1, codex: 1, unknown: 0 });
160
194
  });
161
195
 
162
196
  it('--host filter is reflected in the JSON result', async () => {
@@ -86,6 +86,33 @@ describe('pd pain record --session (real Commander + real trajectory.db)', () =>
86
86
  expect(result.stdout).toContain('--session');
87
87
  }, 15_000);
88
88
 
89
+ it('PRI-743: --help registers --host with the openclaw|codex contract', async () => {
90
+ const result = await runBuiltCli(['pain', 'record', '--help']);
91
+ expect(result.status).toBe(0);
92
+ expect(result.stdout).toContain('--host <kind>');
93
+ // Commander wraps help text; normalize whitespace before asserting content.
94
+ const normalized = result.stdout.replace(/\s+/g, ' ');
95
+ expect(normalized).toContain('openclaw | codex');
96
+ expect(normalized).toContain('codex refuses');
97
+ }, 15_000);
98
+
99
+ it('PRI-743: an invalid --host value exits non-zero with a single structured JSON object', async () => {
100
+ const result = await runBuiltCli([
101
+ 'pain', 'record',
102
+ '--reason', 'parser test pain',
103
+ '--host', 'claude',
104
+ '--workspace', tmpDir,
105
+ '--json',
106
+ ]);
107
+
108
+ expect(result.status).not.toBe(0);
109
+ const trimmed = result.stdout.trim();
110
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
111
+ expect(parsed.status).toBe('failed');
112
+ expect(parsed.reason).toBe('invalid_host_kind');
113
+ expect(typeof parsed.nextAction).toBe('string');
114
+ }, 15_000);
115
+
89
116
  it('fails with a single JSON object and reason session_not_found for a nonexistent session (SPEC 12.1.4)', async () => {
90
117
  const result = await runBuiltCli([
91
118
  'pain', 'record',
@@ -399,6 +399,83 @@ describe('pd pain record', () => {
399
399
  exitSpy.mockRestore();
400
400
  });
401
401
 
402
+ // ── PRI-743: explicit --host attribution ──────────────────────────────────
403
+
404
+ it('PRI-743: --host openclaw records without the default-assumption disclosure', async () => {
405
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
406
+ const exitSpy = mockProcessExit();
407
+
408
+ await handlePainRecord({ reason: 'explicit openclaw', session: 'sess-oc', host: 'openclaw', json: true });
409
+
410
+ expect(lastRecordPainInput).toBeTruthy();
411
+ expect(lastRecordPainInput!.sessionId).toBe('sess-oc');
412
+ expect(lastRecordPainInput!.hostKind).toBe('openclaw');
413
+ expect(lastRecordPainInput!.provenance).toBe('host_context_bound');
414
+
415
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { hostAttribution?: string; warnings?: string[] };
416
+ expect(jsonOutput.hostAttribution).toBe('openclaw');
417
+ expect((jsonOutput.warnings ?? []).some((w) => w.includes("defaulted to 'openclaw'"))).toBe(false);
418
+
419
+ logSpy.mockRestore();
420
+ exitSpy.mockRestore();
421
+ });
422
+
423
+ it('PRI-743: --host codex refuses loudly — the CLI cannot verify Codex lineage (rc-6)', async () => {
424
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
425
+ const exitSpy = mockProcessExit();
426
+
427
+ await handlePainRecord({ reason: 'codex mistake', session: 'sess-codex', host: 'codex', json: true });
428
+
429
+ // cli-1: exactly one JSON object on stdout
430
+ expect(logSpy).toHaveBeenCalledTimes(1);
431
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { status: string; reason: string; nextAction: string };
432
+ expect(jsonOutput).toMatchObject({ status: 'failed', reason: 'codex_lineage_unverifiable_by_cli' });
433
+ expect(jsonOutput.nextAction).toContain('Codex ingestion');
434
+ // cli-2/cli-5: refused before any evidence acquisition or service mutation
435
+ expect(lastRecordPainInput).toBeNull();
436
+ expect(acquireTrajectoryEvidenceFromDb).not.toHaveBeenCalled();
437
+ expect(exitSpy).toHaveBeenCalledWith(1);
438
+
439
+ logSpy.mockRestore();
440
+ exitSpy.mockRestore();
441
+ });
442
+
443
+ it('PRI-743: omitting --host keeps the openclaw default but discloses the assumption (rc-9)', async () => {
444
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
445
+ const exitSpy = mockProcessExit();
446
+
447
+ await handlePainRecord({ reason: 'legacy path', session: 'sess-123', json: true });
448
+
449
+ expect(lastRecordPainInput).toBeTruthy();
450
+ expect(lastRecordPainInput!.hostKind).toBe('openclaw');
451
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { hostAttribution?: string; warnings?: string[] };
452
+ expect(jsonOutput.hostAttribution).toBe('openclaw');
453
+ expect((jsonOutput.warnings ?? []).some((w) => w.includes("defaulted to 'openclaw'") && w.includes('--host codex'))).toBe(true);
454
+
455
+ logSpy.mockRestore();
456
+ exitSpy.mockRestore();
457
+ });
458
+
459
+ it('PRI-743: an invalid --host value fails loudly before any mutation (cli-5/cli-6)', async () => {
460
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
461
+ const exitSpy = mockProcessExit();
462
+
463
+ await handlePainRecord({ reason: 'typo host', session: 'sess-123', host: 'claude', json: true });
464
+
465
+ // cli-1: exactly one JSON object on stdout
466
+ expect(logSpy).toHaveBeenCalledTimes(1);
467
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { status: string; reason: string; nextAction: string };
468
+ expect(jsonOutput).toMatchObject({ status: 'failed', reason: 'invalid_host_kind' });
469
+ expect(jsonOutput.nextAction).toContain('--host codex');
470
+ // cli-2/cli-5: execution stopped before any evidence acquisition or service mutation
471
+ expect(lastRecordPainInput).toBeNull();
472
+ expect(acquireTrajectoryEvidenceFromDb).not.toHaveBeenCalled();
473
+ expect(exitSpy).toHaveBeenCalledWith(1);
474
+
475
+ logSpy.mockRestore();
476
+ exitSpy.mockRestore();
477
+ });
478
+
402
479
  // 用例 C2 (PRI-642 rewrite): without session, no sentinel session, no
403
480
  // placeholder evidence — an honest unbound Owner report (SPEC §7.4).
404
481
  it('C2: submits an honest unbound report when no --session provided', async () => {
@@ -73,10 +73,6 @@ const EXPECTED_TRAJECTORY_TABLES = [
73
73
  'exports_audit', 'evolution_tasks', 'evolution_events',
74
74
  ];
75
75
 
76
- const EXPECTED_WORKFLOW_TABLES = [
77
- 'schema_version', 'subagent_workflows', 'subagent_workflow_events',
78
- ];
79
-
80
76
  const EXPECTED_TRAJECTORY_INDEXES = [
81
77
  'idx_assistant_turns_session_id',
82
78
  'idx_assistant_turns_created_at',
@@ -110,11 +106,11 @@ describe('pd runtime init — empty workspace integration', () => {
110
106
  // ── EMPTY-01: initialize empty workspace ───────────────────────────────────
111
107
 
112
108
  describe('EMPTY-01: initialize empty workspace', () => {
113
- it('returns ok=true with 3 initialized databases', () => {
109
+ it('returns ok=true with 2 initialized databases', () => {
114
110
  const output = buildRuntimeInitOutput(tmpDir, true);
115
111
  expect(output.ok).toBe(true);
116
112
  expect(output.mode).toBe('confirm');
117
- expect(output.databases).toHaveLength(3);
113
+ expect(output.databases).toHaveLength(2);
118
114
  for (const db of output.databases) {
119
115
  expect(db.status).toBe('initialized');
120
116
  }
@@ -150,14 +146,10 @@ describe('pd runtime init — empty workspace integration', () => {
150
146
  }
151
147
  });
152
148
 
153
- it('creates subagent_workflows.db with all expected tables', () => {
149
+ it('no longer creates subagent_workflows.db (legacy workflow store retired in PRI-737)', () => {
154
150
  buildRuntimeInitOutput(tmpDir, true);
155
151
  const wfDbPath = path.join(tmpDir, '.state', 'subagent_workflows.db');
156
- expect(fs.existsSync(wfDbPath)).toBe(true);
157
- const tables = getTableNames(wfDbPath);
158
- for (const expected of EXPECTED_WORKFLOW_TABLES) {
159
- expect(tables).toContain(expected);
160
- }
152
+ expect(fs.existsSync(wfDbPath)).toBe(false);
161
153
  });
162
154
 
163
155
  it('pain_events table has canonical_pain_id and runtime_task_id columns', () => {
@@ -229,7 +221,7 @@ describe('pd runtime init — empty workspace integration', () => {
229
221
  // Second initialization (should be idempotent)
230
222
  const output2 = buildRuntimeInitOutput(tmpDir, true);
231
223
  expect(output2.ok).toBe(true);
232
- expect(output2.databases).toHaveLength(3);
224
+ expect(output2.databases).toHaveLength(2);
233
225
  for (const db of output2.databases) {
234
226
  expect(db.status).toBe('initialized');
235
227
  }
@@ -2,8 +2,8 @@
2
2
  * runtime-init tests — pd runtime init command (unit tests with mocked DB layer).
3
3
  *
4
4
  * Covers:
5
- * - INIT-01: --dry-run (default) reports 3 DBs as 'skipped', no real writes
6
- * - INIT-02: --confirm calls all 3 init functions, output shows 'initialized'
5
+ * - INIT-01: --dry-run (default) reports 2 DBs as 'skipped', no real writes
6
+ * - INIT-02: --confirm calls all init functions, output shows 'initialized'
7
7
  * - INIT-03: --json outputs single parseable JSON object (cli-1)
8
8
  * - INIT-04: --dry-run + --confirm mutually exclusive (cli-4)
9
9
  * - INIT-05: --confirm failure sets exitCode=1 with reason + nextAction (cli-6)
@@ -24,7 +24,6 @@ import * as fs from 'node:fs';
24
24
  const mockState = vi.hoisted(() => {
25
25
  return {
26
26
  initTrajectorySchema: vi.fn(),
27
- initWorkflowSchema: vi.fn(),
28
27
  sqliteConnectionCtor: vi.fn(),
29
28
  sqliteConnectionGetDb: vi.fn(),
30
29
  sqliteConnectionGetWarnings: vi.fn(),
@@ -37,7 +36,6 @@ const mockState = vi.hoisted(() => {
37
36
 
38
37
  vi.mock('principles-disciple', () => ({
39
38
  initTrajectorySchema: mockState.initTrajectorySchema,
40
- initWorkflowSchema: mockState.initWorkflowSchema,
41
39
  }));
42
40
 
43
41
  vi.mock('@principles/core/runtime-v2', async () => {
@@ -87,12 +85,6 @@ function setupDefaultConfirmMocks(): void {
87
85
  warnings: [],
88
86
  });
89
87
 
90
- // initWorkflowSchema mock
91
- mockState.initWorkflowSchema.mockReturnValue({
92
- tables: ['schema_version', 'subagent_workflows', 'subagent_workflow_events'],
93
- warnings: [],
94
- });
95
-
96
88
  // SchemaConformanceReadModel mock
97
89
  mockState.schemaConformanceCtor.mockImplementation(function () {
98
90
  return {
@@ -113,19 +105,18 @@ describe('pd runtime init', () => {
113
105
  // ── INIT-01: dry-run (default) ─────────────────────────────────────────────
114
106
 
115
107
  describe('INIT-01: dry-run (default)', () => {
116
- it('reports 3 DBs as skipped without calling init functions', () => {
108
+ it('reports 2 DBs as skipped without calling init functions', () => {
117
109
  const tmp = mkTmpDir();
118
110
  try {
119
111
  const output = buildRuntimeInitOutput(tmp, false);
120
112
  expect(output.ok).toBe(true);
121
113
  expect(output.mode).toBe('dry-run');
122
- expect(output.databases).toHaveLength(3);
114
+ expect(output.databases).toHaveLength(2);
123
115
  for (const db of output.databases) {
124
116
  expect(db.status).toBe('skipped');
125
117
  }
126
118
  // No init functions should be called in dry-run mode
127
119
  expect(mockState.initTrajectorySchema).not.toHaveBeenCalled();
128
- expect(mockState.initWorkflowSchema).not.toHaveBeenCalled();
129
120
  expect(mockState.sqliteConnectionCtor).not.toHaveBeenCalled();
130
121
  } finally { rmTmpDir(tmp); }
131
122
  });
@@ -136,12 +127,10 @@ describe('pd runtime init', () => {
136
127
  const output = buildRuntimeInitOutput(tmp, false);
137
128
  const stateDb = output.databases.find(d => d.name === 'state.db');
138
129
  const trajDb = output.databases.find(d => d.name === 'trajectory.db');
139
- const wfDb = output.databases.find(d => d.name === 'subagent_workflows.db');
140
130
  expect(stateDb?.tables).toContain('tasks');
141
131
  expect(stateDb?.tables).toContain('runs');
142
132
  expect(trajDb?.tables).toContain('pain_events');
143
133
  expect(trajDb?.tables).toContain('sessions');
144
- expect(wfDb?.tables).toContain('subagent_workflows');
145
134
  } finally { rmTmpDir(tmp); }
146
135
  });
147
136
  });
@@ -149,20 +138,19 @@ describe('pd runtime init', () => {
149
138
  // ── INIT-02: --confirm ─────────────────────────────────────────────────────
150
139
 
151
140
  describe('INIT-02: --confirm', () => {
152
- it('calls all 3 init functions and reports initialized status', () => {
141
+ it('calls all init functions and reports initialized status', () => {
153
142
  const tmp = mkTmpDir();
154
143
  try {
155
144
  const output = buildRuntimeInitOutput(tmp, true);
156
145
  expect(output.ok).toBe(true);
157
146
  expect(output.mode).toBe('confirm');
158
- expect(output.databases).toHaveLength(3);
147
+ expect(output.databases).toHaveLength(2);
159
148
  for (const db of output.databases) {
160
149
  expect(db.status).toBe('initialized');
161
150
  }
162
151
  expect(mockState.sqliteConnectionCtor).toHaveBeenCalledTimes(1);
163
152
  expect(mockState.sqliteConnectionGetDb).toHaveBeenCalledTimes(1);
164
153
  expect(mockState.initTrajectorySchema).toHaveBeenCalledWith(tmp);
165
- expect(mockState.initWorkflowSchema).toHaveBeenCalledWith(tmp);
166
154
  } finally { rmTmpDir(tmp); }
167
155
  });
168
156
 
@@ -256,7 +244,6 @@ describe('pd runtime init', () => {
256
244
  it('does not call any init functions on flag conflict', async () => {
257
245
  await handleRuntimeInit({ dryRun: true, confirm: true, json: true });
258
246
  expect(mockState.initTrajectorySchema).not.toHaveBeenCalled();
259
- expect(mockState.initWorkflowSchema).not.toHaveBeenCalled();
260
247
  expect(mockState.sqliteConnectionCtor).not.toHaveBeenCalled();
261
248
  });
262
249
  });
@@ -305,19 +292,6 @@ describe('pd runtime init', () => {
305
292
  } finally { rmTmpDir(tmp); }
306
293
  });
307
294
 
308
- it('sets exitCode=1 and includes reason when subagent_workflows.db fails', () => {
309
- const tmp = mkTmpDir();
310
- try {
311
- mockState.initWorkflowSchema.mockImplementation(() => {
312
- throw new Error('locked');
313
- });
314
- const output = buildRuntimeInitOutput(tmp, true);
315
- expect(output.ok).toBe(false);
316
- expect(output.reason).toContain('subagent_workflows.db');
317
- expect(output.reason).toContain('locked');
318
- } finally { rmTmpDir(tmp); }
319
- });
320
-
321
295
  it('handler sets process.exitCode=1 on failure with --json', async () => {
322
296
  const tmp = mkTmpDir();
323
297
  try {