@principles/pd-cli 1.145.0 → 1.145.2

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,185 @@
1
+ /**
2
+ * pd pain list command unit tests — PRI-640 (Host Attribution v0.1).
3
+ *
4
+ * External contract: listPains reads trajectory.db readonly and filters by
5
+ * host (openclaw / codex / unknown); handlePainList preserves the strict
6
+ * --json contract and degraded-failure shapes (cli-1/cli-6).
7
+ */
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import * as fs from 'fs';
10
+ import * as os from 'os';
11
+ import * as path from 'path';
12
+ import Database from 'better-sqlite3';
13
+
14
+ vi.mock('../../src/resolve-workspace.js', () => ({
15
+ resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
16
+ }));
17
+
18
+ import { handlePainList, listPains } from '../../src/commands/pain-list.js';
19
+ import { resolveWorkspaceDir } from '../../src/resolve-workspace.js';
20
+
21
+ const tempDirs: string[] = [];
22
+
23
+ function openWorkspaceDb(prefix: string): { workspaceDir: string; db: Database.Database } {
24
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
25
+ tempDirs.push(workspaceDir);
26
+ const stateDir = path.join(workspaceDir, '.state');
27
+ fs.mkdirSync(stateDir, { recursive: true });
28
+ return { workspaceDir, db: new Database(path.join(stateDir, 'trajectory.db')) };
29
+ }
30
+
31
+ /** Create a temp workspace whose trajectory.db uses the PRI-640 schema (host_kind column). */
32
+ function makeWorkspace(): { workspaceDir: string; db: Database.Database } {
33
+ const { workspaceDir, db } = openWorkspaceDb('pd-pain-list-');
34
+ db.prepare('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, updated_at TEXT NOT NULL)').run();
35
+ db.prepare(`CREATE TABLE pain_events (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ session_id TEXT NOT NULL, source TEXT NOT NULL, score REAL NOT NULL, reason TEXT,
38
+ severity TEXT, origin TEXT, confidence REAL, text TEXT,
39
+ canonical_pain_id TEXT, runtime_task_id TEXT, host_kind TEXT, created_at TEXT NOT NULL
40
+ )`).run();
41
+ db.prepare('CREATE UNIQUE INDEX idx_pain_events_canonical_pain_id ON pain_events(canonical_pain_id) WHERE canonical_pain_id IS NOT NULL').run();
42
+ return { workspaceDir, db };
43
+ }
44
+
45
+ /** Create a temp workspace whose trajectory.db predates PRI-640 (no host_kind column). */
46
+ function makePre640Workspace(): { workspaceDir: string; db: Database.Database } {
47
+ const { workspaceDir, db } = openWorkspaceDb('pd-pain-pre640-');
48
+ db.prepare('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, updated_at TEXT NOT NULL)').run();
49
+ db.prepare(`CREATE TABLE pain_events (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ session_id TEXT NOT NULL, source TEXT NOT NULL, score REAL NOT NULL, reason TEXT,
52
+ severity TEXT, origin TEXT, confidence REAL, text TEXT,
53
+ canonical_pain_id TEXT, runtime_task_id TEXT, created_at TEXT NOT NULL
54
+ )`).run();
55
+ db.prepare('CREATE UNIQUE INDEX idx_pain_events_canonical_pain_id ON pain_events(canonical_pain_id) WHERE canonical_pain_id IS NOT NULL').run();
56
+ return { workspaceDir, db };
57
+ }
58
+
59
+ function seedPain(db: Database.Database, row: { id: number; source: string; canonical: string; host: string | null; task?: string | null; created: string }): void {
60
+ db.prepare(`INSERT INTO pain_events (id, session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, host_kind, created_at)
61
+ VALUES (?, 's1', ?, 80, 'r', 'moderate', 'system_infer', NULL, NULL, ?, ?, ?, ?)`)
62
+ .run(row.id, row.source, row.canonical, row.task ?? null, row.host, row.created);
63
+ }
64
+
65
+ function dbPathOf(db: Database.Database): string {
66
+ return db.name;
67
+ }
68
+
69
+ afterEach(() => {
70
+ for (const dir of tempDirs.splice(0)) {
71
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 });
72
+ }
73
+ });
74
+
75
+ describe('listPains (PRI-640 host filter)', () => {
76
+ it('filters host=openclaw / codex / unknown and reports NULL as unknown', async () => {
77
+ const { workspaceDir, db } = makeWorkspace();
78
+ seedPain(db, { id: 1, source: 'user_correction', canonical: 'pain_oc_1', host: 'openclaw', task: 'diagnosis_pain_oc_1', created: '2026-09-01T10:00:00.000Z' });
79
+ seedPain(db, { id: 2, source: 'tool_failure', canonical: 'pain_cx_1', host: 'codex', created: '2026-09-01T11:00:00.000Z' });
80
+ seedPain(db, { id: 3, source: 'manual', canonical: 'pain_unknown_1', host: null, created: '2026-09-01T12:00:00.000Z' });
81
+ const dbPath = dbPathOf(db);
82
+ db.close();
83
+
84
+ const all = await listPains(dbPath, { limit: 10 });
85
+ expect(all.count).toBe(3);
86
+ expect(all.pains.map((p) => p.host)).toEqual(['unknown', 'codex', 'openclaw']); // newest first
87
+ expect(all.pains.find((p) => p.painId === 'pain_oc_1')).toMatchObject({ host: 'openclaw', source: 'user_correction', runtimeTaskId: 'diagnosis_pain_oc_1', createdAt: '2026-09-01T10:00:00.000Z' });
88
+ expect(all.workspace).toBe(workspaceDir);
89
+
90
+ expect((await listPains(dbPath, { limit: 10, host: 'openclaw' })).pains.map((p) => p.painId)).toEqual(['pain_oc_1']);
91
+ expect((await listPains(dbPath, { limit: 10, host: 'codex' })).pains.map((p) => p.painId)).toEqual(['pain_cx_1']);
92
+ expect((await listPains(dbPath, { limit: 10, host: 'unknown' })).pains.map((p) => p.painId)).toEqual(['pain_unknown_1']);
93
+ expect(all.warnings).toEqual([]);
94
+ });
95
+
96
+ it('reports legacy rows without a canonical id as row:<id>', async () => {
97
+ const { db } = makeWorkspace();
98
+ db.prepare(`INSERT INTO pain_events (session_id, source, score, created_at) VALUES ('s1', 'correction_rejected', 60, '2026-08-01T00:00:00.000Z')`).run();
99
+ const dbPath = dbPathOf(db);
100
+ db.close();
101
+
102
+ const result = await listPains(dbPath, { limit: 10 });
103
+ expect(result.count).toBe(1);
104
+ expect(result.pains[0]?.painId).toBe('row:1');
105
+ expect(result.pains[0]?.host).toBe('unknown');
106
+ });
107
+
108
+ it('degrades observably on a pre-PRI-640 database without the host_kind column (rc-9)', async () => {
109
+ const { db } = makePre640Workspace();
110
+ db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
111
+ VALUES ('s1', 'tool_failure', 70, 'r', 'moderate', 'system_infer', NULL, NULL, 'pain_legacy', NULL, '2026-08-01T00:00:00.000Z')`).run();
112
+ const dbPath = dbPathOf(db);
113
+ db.close();
114
+
115
+ const all = await listPains(dbPath, { limit: 10 });
116
+ expect(all.count).toBe(1);
117
+ expect(all.pains[0]?.host).toBe('unknown');
118
+ expect(all.warnings).toContain('host_kind_column_missing');
119
+
120
+ // Host filters that cannot match degrade to an explicit empty result.
121
+ expect(await listPains(dbPath, { limit: 10, host: 'codex' })).toMatchObject({ count: 0, pains: [] });
122
+ // unknown still returns the legacy rows.
123
+ expect((await listPains(dbPath, { limit: 10, host: 'unknown' })).count).toBe(1);
124
+ });
125
+ });
126
+
127
+ describe('handlePainList (CLI contract)', () => {
128
+ let exitSpy: ReturnType<typeof vi.spyOn>;
129
+ let logSpy: ReturnType<typeof vi.spyOn>;
130
+ let errSpy: ReturnType<typeof vi.spyOn>;
131
+ let workspaceDir: string;
132
+
133
+ beforeEach(() => {
134
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
135
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
136
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
137
+ const made = makeWorkspace();
138
+ workspaceDir = made.workspaceDir;
139
+ seedPain(made.db, { id: 1, source: 'user_correction', canonical: 'pain_cli_oc', host: 'openclaw', task: 't1', created: '2026-09-01T09:00:00.000Z' });
140
+ seedPain(made.db, { id: 2, source: 'tool_failure', canonical: 'pain_cli_cx', host: 'codex', created: '2026-09-01T09:30:00.000Z' });
141
+ made.db.close();
142
+ vi.mocked(resolveWorkspaceDir).mockReturnValue(workspaceDir);
143
+ });
144
+
145
+ afterEach(() => {
146
+ exitSpy.mockRestore();
147
+ logSpy.mockRestore();
148
+ errSpy.mockRestore();
149
+ });
150
+
151
+ it('--json emits one valid JSON object with stable host values', async () => {
152
+ await handlePainList({ json: true });
153
+ expect(exitSpy).not.toHaveBeenCalled();
154
+ 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[] };
156
+ expect(parsed.count).toBe(2);
157
+ expect(parsed.pains.map((p) => `${p.painId}:${p.host}`).sort()).toEqual(['pain_cli_cx:codex', 'pain_cli_oc:openclaw']);
158
+ expect(parsed.hostFilter).toBeNull();
159
+ expect(parsed.warnings).toEqual([]);
160
+ });
161
+
162
+ it('--host filter is reflected in the JSON result', async () => {
163
+ await handlePainList({ json: true, host: 'codex' });
164
+ const parsed = JSON.parse(logSpy.mock.calls.map((args) => String(args[0])).join('\n')) as { count: number; hostFilter: string; pains: unknown[] };
165
+ expect(parsed).toMatchObject({ count: 1, hostFilter: 'codex' });
166
+ expect(parsed.pains).toHaveLength(1);
167
+ });
168
+
169
+ it('an invalid --host value fails loudly with a structured reason (cli-6)', async () => {
170
+ await handlePainList({ json: true, host: 'claude' });
171
+ expect(exitSpy).toHaveBeenCalledWith(1);
172
+ const parsed = JSON.parse(logSpy.mock.calls.map((args) => String(args[0])).join('\n')) as { status: string; reason: string; nextAction: string };
173
+ expect(parsed).toMatchObject({ status: 'failed', reason: 'invalid_host_filter' });
174
+ expect(parsed.nextAction).toContain('--host openclaw');
175
+ });
176
+
177
+ it('a missing trajectory.db degrades with reason and next action (cli-6)', async () => {
178
+ fs.rmSync(path.join(workspaceDir, '.state', 'trajectory.db'));
179
+ await handlePainList({ json: true });
180
+ expect(exitSpy).toHaveBeenCalledWith(1);
181
+ const parsed = JSON.parse(logSpy.mock.calls.map((args) => String(args[0])).join('\n')) as { status: string; reason: string; nextAction: string };
182
+ expect(parsed).toMatchObject({ status: 'failed', reason: 'trajectory_db_not_found' });
183
+ expect(parsed.nextAction).toContain('pd runtime init');
184
+ });
185
+ });
@@ -13,7 +13,7 @@
13
13
  * - Wrong taskKind: rejected with reason + nextAction
14
14
  * - Missing pi-ai config: rejected with reason + nextAction
15
15
  */
16
- import { describe, it, expect, vi, beforeEach } from 'vitest';
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17
17
  import { Command } from 'commander';
18
18
 
19
19
  // ── Mocks ──────────────────────────────────────────────────────────────────────
@@ -144,6 +144,8 @@ vi.mock('@principles/core/runtime-v2', () => {
144
144
  OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
145
145
  PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
146
146
  SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
147
+ // PRI-638: capability gate — available by default; disabled cases override this.
148
+ resolveDiagnosticianCapability: vi.fn((): { available: boolean; reason?: string; message?: string; nextAction?: string } => ({ available: true })),
147
149
  PDRuntimeError: class PDRuntimeError extends Error {
148
150
  constructor(public category: string, message: string) {
149
151
  super(message);
@@ -1048,3 +1050,72 @@ describe('BUG-1 (PRI-442): pain retry — effectiveConfig wiring to split-pipeli
1048
1050
  exitSpy.mockRestore();
1049
1051
  });
1050
1052
  });
1053
+
1054
+ // ── PRI-638: unified capability-disabled semantics ───────────────────────────
1055
+ //
1056
+ // On main, an Owner-disabled Diagnostician surfaced from `pd pain retry` as
1057
+ // `missing_runtime` ("no .pd/config.yaml runtime binding found") — telling the
1058
+ // Owner their config was broken when they had deliberately switched the agent
1059
+ // off. The capability gate now runs BEFORE runtime resolution and reads the
1060
+ // same canonical authority the runtime factory uses.
1061
+
1062
+ describe('PRI-638: pd pain retry when Diagnostician capability is disabled', () => {
1063
+ beforeEach(async () => {
1064
+ vi.clearAllMocks();
1065
+ const runtimeV2 = await import('@principles/core/runtime-v2');
1066
+ vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReturnValue({
1067
+ available: false,
1068
+ reason: 'capability_disabled',
1069
+ message: "Agent 'diagnostician' is disabled",
1070
+ nextAction: "Enable agent 'diagnostician' in .pd/config.yaml internalAgents.agents.diagnostician.enabled",
1071
+ });
1072
+ });
1073
+
1074
+ afterEach(async () => {
1075
+ const runtimeV2 = await import('@principles/core/runtime-v2');
1076
+ vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReset();
1077
+ });
1078
+
1079
+ it('RETRY-638-01: --json refuses with capability_disabled, not missing_runtime', async () => {
1080
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1081
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1082
+
1083
+ await handlePainRetry({
1084
+ painId: 'pain-638',
1085
+ workspace: '/tmp/fake-workspace',
1086
+ json: true,
1087
+ });
1088
+
1089
+ const jsonCall = logSpy.mock.calls.find((call) => String(call[0]).trim().startsWith('{'));
1090
+ expect(jsonCall).toBeDefined();
1091
+ const parsed = JSON.parse(String(jsonCall?.[0]));
1092
+ expect(parsed.reason).toBe('capability_disabled');
1093
+ expect(parsed.reason).not.toBe('missing_runtime');
1094
+ expect(parsed.nextAction).toContain('internalAgents.agents.diagnostician.enabled');
1095
+ expect(exitSpy).toHaveBeenCalledWith(1);
1096
+
1097
+ logSpy.mockRestore();
1098
+ exitSpy.mockRestore();
1099
+ });
1100
+
1101
+ it('RETRY-638-02: capability gate fires before the runtime adapter is built', async () => {
1102
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1103
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1104
+
1105
+ await handlePainRetry({
1106
+ painId: 'pain-638',
1107
+ workspace: '/tmp/fake-workspace',
1108
+ runtime: 'test-double',
1109
+ json: false,
1110
+ });
1111
+
1112
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
1113
+ expect(out).toContain('capability_disabled');
1114
+ const runtimeV2 = await import('@principles/core/runtime-v2');
1115
+ expect(runtimeV2.TestDoubleRuntimeAdapter).not.toHaveBeenCalled();
1116
+ expect(runtimeV2.SplitDiagnosticianRunner).not.toHaveBeenCalled();
1117
+
1118
+ errSpy.mockRestore();
1119
+ exitSpy.mockRestore();
1120
+ });
1121
+ });
@@ -393,6 +393,26 @@ describe('pd runtime init', () => {
393
393
  } finally { rmTmpDir(tmp); }
394
394
  });
395
395
 
396
+ it('--confirm marks every generated flag entry as system provenance (PRI-637)', async () => {
397
+ const tmp = mkTmpDir();
398
+ try {
399
+ buildRuntimeInitOutput(tmp, true);
400
+ const configPath = path.join(tmp, '.pd', 'config.yaml');
401
+ const yaml = (await import('js-yaml')).default;
402
+ const parsed = yaml.load(fs.readFileSync(configPath, 'utf8'), { schema: yaml.JSON_SCHEMA }) as {
403
+ features?: Record<string, { source?: unknown }>;
404
+ };
405
+ const features = parsed.features ?? {};
406
+ // Bootstrap snapshot still carries the full registry map.
407
+ expect(Object.keys(features).length).toBeGreaterThan(0);
408
+ // PRI-637: `pd runtime init` is PD machinery — entries carry source:
409
+ // 'system' (NOT owner intent), matching the runtime-init lifecycle label.
410
+ for (const entry of Object.values(features)) {
411
+ expect(entry?.source).toBe('system');
412
+ }
413
+ } finally { rmTmpDir(tmp); }
414
+ });
415
+
396
416
  it('--confirm skips when config.yaml already exists (preserves user file)', () => {
397
417
  const tmp = mkTmpDir();
398
418
  try {