@principles/pd-cli 1.145.1 → 1.146.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.
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Tests for buildTrajectoryEvidenceFromDb — PRI-341
2
+ * Tests for acquireTrajectoryEvidenceFromDb — PRI-341 extraction behavior,
3
+ * PRI-642 typed classification (the legacy array wrapper was removed with
4
+ * its last production consumer).
3
5
  *
4
6
  * Uses real temporary SQLite DBs (trajectory.db) to validate evidence extraction.
5
7
  */
@@ -9,7 +11,7 @@ import * as fs from 'fs';
9
11
  import * as path from 'path';
10
12
  import * as os from 'os';
11
13
  import { MAX_EVIDENCE_ENTRIES } from '@principles/core/runtime-v2';
12
- import { buildTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
14
+ import { acquireTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
13
15
 
14
16
  // ── Helpers ─────────────────────────────────────────────────────────────────
15
17
 
@@ -127,7 +129,7 @@ function insertToolCall(
127
129
 
128
130
  // ── Tests ───────────────────────────────────────────────────────────────────
129
131
 
130
- describe('buildTrajectoryEvidenceFromDb — PRI-341', () => {
132
+ describe('acquireTrajectoryEvidenceFromDbbehavior via typed API (PRI-341 extraction, PRI-642 wrapper removal)', () => {
131
133
  beforeEach(() => {
132
134
  createStateDir();
133
135
  });
@@ -140,198 +142,261 @@ describe('buildTrajectoryEvidenceFromDb — PRI-341', () => {
140
142
  }
141
143
  });
142
144
 
143
- // 用例 A: trajectory.db contains 3 assistant turns for session '123'
144
- // returns evidence with length ≤ MAX_EVIDENCE_ENTRIES, each entry has sourceRef and note
145
- it('A: returns evidence entries from assistant turns ( MAX_EVIDENCE_ENTRIES)', () => {
145
+ /** Available-class entries, or the unavailable reason for assertions. */
146
+ function acquire(sessionId: string | undefined) {
147
+ return acquireTrajectoryEvidenceFromDb(stateDir, sessionId, tmpDir);
148
+ }
149
+
150
+ function entriesOf(sessionId: string) {
151
+ const result = acquire(sessionId);
152
+ expect(result.status).toBe('available');
153
+ return result.status === 'available' ? result.entries : [];
154
+ }
155
+
156
+ function insertSession(db: Database.Database, sessionId: string): void {
157
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
158
+ .run(sessionId, '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
159
+ }
160
+
161
+ it('returns available entries (≤ MAX_EVIDENCE_ENTRIES) from assistant turns', () => {
146
162
  const db = createTrajectoryDb();
163
+ insertSession(db, '123');
147
164
  insertAssistantTurn(db, '123', 'First assistant response about backups', '2026-01-01T10:00:00Z');
148
165
  insertAssistantTurn(db, '123', 'Second response about validation', '2026-01-01T10:01:00Z');
149
166
  insertAssistantTurn(db, '123', 'Third response about error handling', '2026-01-01T10:02:00Z');
150
167
  db.close();
151
168
 
152
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
169
+ const entries = entriesOf('123');
153
170
 
154
- expect(evidence.length).toBeGreaterThan(0);
155
- expect(evidence.length).toBeLessThanOrEqual(MAX_EVIDENCE_ENTRIES);
156
- for (const entry of evidence) {
171
+ expect(entries.length).toBeGreaterThan(0);
172
+ expect(entries.length).toBeLessThanOrEqual(MAX_EVIDENCE_ENTRIES);
173
+ for (const entry of entries) {
157
174
  expect(entry.sourceRef).toBeTruthy();
158
175
  expect(entry.note).toBeTruthy();
159
- expect(typeof entry.sourceRef).toBe('string');
160
- expect(typeof entry.note).toBe('string');
161
176
  }
162
177
  });
163
178
 
164
- // 用例 B: sessionId is undefined or trajectory.db doesn't exist
165
- // returns placeholder entry, does not throw, does not return empty array
166
- it('B: returns placeholder when sessionId is undefined', () => {
167
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, undefined, tmpDir);
179
+ it('classifies an undefined session as session_not_found (no placeholder entries)', () => {
180
+ const db = createTrajectoryDb();
181
+ db.close();
182
+ const result = acquire(undefined);
183
+ expect(result.status).toBe('unavailable');
184
+ if (result.status !== 'unavailable') return;
185
+ expect(result.reasonCode).toBe('session_not_found');
186
+ });
168
187
 
169
- expect(evidence.length).toBeGreaterThan(0);
170
- expect(evidence[0].sourceRef).toBe('owner_reported:cli');
171
- expect(evidence[0].note).toBeTruthy();
188
+ it('surfaces user correction turns with correctionDetected=true', () => {
189
+ const db = createTrajectoryDb();
190
+ insertSession(db, '123');
191
+ insertUserTurn(db, '123', 'Please fix the backup logic', true, '2026-01-01T09:59:00Z');
192
+ insertAssistantTurn(db, '123', 'I will fix the backup logic', '2026-01-01T10:00:00Z');
193
+ db.close();
194
+
195
+ const entries = entriesOf('123');
196
+
197
+ const ownerEntry = entries.find(e => e.sourceRef.startsWith('owner_message:'));
198
+ expect(ownerEntry).toBeDefined();
199
+ expect(ownerEntry!.note).toContain('fix the backup logic');
172
200
  });
173
201
 
174
- it('B2: returns placeholder when trajectory.db does not exist', () => {
175
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
202
+ it('extracts failed tool_calls as evidence entries (PRI-358)', () => {
203
+ const db = createTrajectoryDb();
204
+ insertSession(db, '123');
205
+ insertToolCall(db, '123', 'bash', 'failure', 'non_zero_exit', 1, '2026-01-01T10:00:00Z');
206
+ insertToolCall(db, '123', 'write_file', 'success', null, 0, '2026-01-01T10:01:00Z');
207
+ insertToolCall(db, '123', 'bash', 'failure', 'timeout', 124, '2026-01-01T10:02:00Z');
208
+ db.close();
209
+
210
+ const entries = entriesOf('123');
176
211
 
177
- expect(evidence.length).toBeGreaterThan(0);
178
- expect(evidence[0].sourceRef).toBe('owner_reported:cli');
212
+ const failureEntries = entries.filter(e => e.sourceRef.startsWith('tool_call_failure:'));
213
+ expect(failureEntries.length).toBe(2);
214
+ expect(failureEntries[0].note).toContain('bash');
215
+ expect(failureEntries[0].note).toContain('non_zero_exit');
216
+ expect(failureEntries[1].note).toContain('timeout');
179
217
  });
180
218
 
181
- it('B3: returns placeholder when sessionId is "cli"', () => {
182
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, 'cli', tmpDir);
219
+ it('limits failed tool_calls to 3 entries', () => {
220
+ const db = createTrajectoryDb();
221
+ insertSession(db, '123');
222
+ insertToolCall(db, '123', 'bash', 'failure', 'err1', 1, '2026-01-01T10:00:00Z');
223
+ insertToolCall(db, '123', 'bash', 'failure', 'err2', 2, '2026-01-01T10:01:00Z');
224
+ insertToolCall(db, '123', 'bash', 'failure', 'err3', 3, '2026-01-01T10:02:00Z');
225
+ insertToolCall(db, '123', 'bash', 'failure', 'err4', 4, '2026-01-01T10:03:00Z');
226
+ db.close();
227
+
228
+ const entries = entriesOf('123');
183
229
 
184
- expect(evidence.length).toBeGreaterThan(0);
185
- expect(evidence[0].sourceRef).toBe('owner_reported:cli');
230
+ const failureEntries = entries.filter(e => e.sourceRef.startsWith('tool_call_failure:'));
231
+ expect(failureEntries.length).toBe(3);
186
232
  });
187
233
 
188
- // Additional: user correction turns are surfaced
189
- it('surfaces user correction turns with correctionDetected=true', () => {
234
+ it('handles a missing tool_calls table gracefully (classified unavailable, no throw)', () => {
235
+ const dbPath = path.join(stateDir, 'trajectory.db');
236
+ const db = new Database(dbPath);
237
+ db.exec("CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)");
238
+ db.exec("CREATE TABLE IF NOT EXISTS assistant_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, sanitized_text TEXT, created_at TEXT NOT NULL)");
239
+ db.exec("CREATE TABLE IF NOT EXISTS user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, turn_index INTEGER NOT NULL DEFAULT 0, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)");
240
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)').run('123', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
241
+ db.close();
242
+
243
+ const result = acquire('123');
244
+ expect(result.status).toBe('unavailable');
245
+ if (result.status !== 'unavailable') return;
246
+ expect(['empty_trajectory', 'evidence_read_failed']).toContain(result.reasonCode);
247
+ });
248
+
249
+ it('includes resultPreview in tool failure evidence note', () => {
190
250
  const db = createTrajectoryDb();
191
- insertUserTurn(db, '123', 'Please fix the backup logic', true, '2026-01-01T09:59:00Z');
192
- insertAssistantTurn(db, '123', 'I will fix the backup logic', '2026-01-01T10:00:00Z');
251
+ insertSession(db, '123');
252
+ insertToolCall(db, '123', 'bash', 'failure', 'ENOENT', 1, '2026-01-01T10:00:00Z', 'Error: no such file or directory');
193
253
  db.close();
194
254
 
195
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
255
+ const entries = entriesOf('123');
196
256
 
197
- expect(evidence.length).toBeGreaterThan(0);
198
- const ownerEntry = evidence.find(e => e.sourceRef.startsWith('owner_message:'));
199
- expect(ownerEntry).toBeDefined();
200
- expect(ownerEntry!.note).toContain('fix the backup logic');
257
+ const failureEntry = entries.find(e => e.sourceRef.startsWith('tool_call_failure:'));
258
+ expect(failureEntry).toBeDefined();
259
+ expect(failureEntry!.note).toContain('ENOENT');
260
+ expect(failureEntry!.note).toContain('Error: no such file or directory');
261
+ });
262
+
263
+ it('includes truncation warning when stop_reason is length', () => {
264
+ const db = createTrajectoryDb();
265
+ insertSession(db, '123');
266
+ db.prepare("INSERT INTO assistant_turns (session_id, sanitized_text, stop_reason, created_at) VALUES (?, ?, ?, ?)")
267
+ .run('123', 'Partial output truncated...', 'length', '2026-01-01T10:00:00Z');
268
+ db.close();
269
+
270
+ const entries = entriesOf('123');
271
+
272
+ const agentEntry = entries.find(e => e.sourceRef.startsWith('agent_turn:'));
273
+ expect(agentEntry).toBeDefined();
274
+ expect(agentEntry!.note).toContain('Partial output truncated...');
275
+ expect(agentEntry!.note).toContain('[TRUNCATED: output cut off by length limit]');
201
276
  });
202
277
 
203
- // Additional: empty trajectory DB (tables exist but no rows) meaningful placeholder
204
- it('returns trajectory:empty placeholder when DB has no turns for session', () => {
278
+ it('does not include truncation warning when stop_reason is end_turn', () => {
205
279
  const db = createTrajectoryDb();
280
+ insertSession(db, '123');
281
+ db.prepare("INSERT INTO assistant_turns (session_id, sanitized_text, stop_reason, created_at) VALUES (?, ?, ?, ?)")
282
+ .run('123', 'Complete output', 'end_turn', '2026-01-01T10:00:00Z');
206
283
  db.close();
207
284
 
208
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, 'nonexistent-session', tmpDir);
285
+ const entries = entriesOf('123');
209
286
 
210
- expect(evidence.length).toBeGreaterThan(0);
211
- expect(evidence[0].sourceRef).toBe('trajectory:empty');
287
+ const agentEntry = entries.find(e => e.sourceRef.startsWith('agent_turn:'));
288
+ expect(agentEntry).toBeDefined();
289
+ expect(agentEntry!.note).toBe('Complete output');
290
+ expect(agentEntry!.note).not.toContain('[TRUNCATED');
212
291
  });
292
+ });
293
+
294
+ // ── PRI-642 Scope A — typed acquisition from trajectory.db (SPEC §7.3) ───────
213
295
 
214
- // ── PRI-358: Failed tool_calls evidence ────────────────────────────────────
215
-
216
- describe('PRI-358: failed tool_calls evidence', () => {
217
- it('extracts failed tool_calls as evidence entries', () => {
218
- const db = createTrajectoryDb();
219
- insertToolCall(db, '123', 'bash', 'failure', 'non_zero_exit', 1, '2026-01-01T10:00:00Z');
220
- insertToolCall(db, '123', 'write_file', 'success', null, 0, '2026-01-01T10:01:00Z');
221
- insertToolCall(db, '123', 'bash', 'failure', 'timeout', 124, '2026-01-01T10:02:00Z');
222
- db.close();
223
-
224
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
225
-
226
- const failureEntries = evidence.filter(e => e.sourceRef.startsWith('tool_call_failure:'));
227
- expect(failureEntries.length).toBe(2);
228
- expect(failureEntries[0].note).toContain('bash');
229
- expect(failureEntries[0].note).toContain('non_zero_exit');
230
- expect(failureEntries[1].note).toContain('timeout');
231
- });
232
-
233
- it('does not add tool_call_failure entries when no failures exist', () => {
234
- const db = createTrajectoryDb();
235
- insertToolCall(db, '123', 'bash', 'success', null, 0, '2026-01-01T10:00:00Z');
236
- db.close();
237
-
238
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
239
-
240
- const failureEntries = evidence.filter(e => e.sourceRef.startsWith('tool_call_failure:'));
241
- expect(failureEntries.length).toBe(0);
242
- });
243
-
244
- it('limits failed tool_calls to 3 entries', () => {
245
- const db = createTrajectoryDb();
246
- insertToolCall(db, '123', 'bash', 'failure', 'err1', 1, '2026-01-01T10:00:00Z');
247
- insertToolCall(db, '123', 'bash', 'failure', 'err2', 2, '2026-01-01T10:01:00Z');
248
- insertToolCall(db, '123', 'bash', 'failure', 'err3', 3, '2026-01-01T10:02:00Z');
249
- insertToolCall(db, '123', 'bash', 'failure', 'err4', 4, '2026-01-01T10:03:00Z');
250
- db.close();
251
-
252
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
253
-
254
- const failureEntries = evidence.filter(e => e.sourceRef.startsWith('tool_call_failure:'));
255
- expect(failureEntries.length).toBe(3);
256
- });
257
-
258
- it('handles missing tool_calls table gracefully', () => {
259
- // Create DB without tool_calls table
260
- const dbPath = path.join(stateDir, 'trajectory.db');
261
- const db = new Database(dbPath);
262
- db.exec(`
263
- CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)
264
- `);
265
- db.exec(`
266
- CREATE TABLE IF NOT EXISTS assistant_turns (
267
- id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL,
268
- sanitized_text TEXT, created_at TEXT NOT NULL
269
- )
270
- `);
271
- db.exec(`
272
- CREATE TABLE IF NOT EXISTS user_turns (
273
- id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL,
274
- turn_index INTEGER NOT NULL DEFAULT 0, raw_excerpt TEXT,
275
- correction_detected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
276
- )
277
- `);
278
- db.close();
279
-
280
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
281
-
282
- // Should not throw, should have some evidence (trajectory:empty or unavailable)
283
- expect(evidence.length).toBeGreaterThan(0);
284
- // Should NOT have tool_call_failure:unavailable since we have no other evidence
285
- // and the table simply doesn't exist (not an error condition worth reporting)
286
- });
296
+ describe('acquireTrajectoryEvidenceFromDb typed CLI acquisition (PRI-642 Scope A)', () => {
297
+ beforeEach(() => {
298
+ createStateDir();
287
299
  });
288
300
 
289
- // ── Trajectory v2 enhancement: resultPreview + stopReason ─────────────────
290
-
291
- describe('trajectory v2 enhancement: resultPreview and stopReason', () => {
292
- it('includes resultPreview in tool failure evidence note', () => {
293
- const db = createTrajectoryDb();
294
- insertToolCall(db, '123', 'bash', 'failure', 'ENOENT', 1, '2026-01-01T10:00:00Z', 'Error: no such file or directory');
295
- db.close();
296
-
297
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
298
-
299
- const failureEntry = evidence.find(e => e.sourceRef.startsWith('tool_call_failure:'));
300
- expect(failureEntry).toBeDefined();
301
- expect(failureEntry!.note).toContain('ENOENT');
302
- expect(failureEntry!.note).toContain('Error: no such file or directory');
303
- });
304
-
305
- it('includes truncation warning when stop_reason is length', () => {
306
- const db = createTrajectoryDb();
307
- db.prepare(`
308
- INSERT INTO assistant_turns (session_id, sanitized_text, stop_reason, created_at)
309
- VALUES (?, ?, ?, ?)
310
- `).run('123', 'Partial output truncated...', 'length', '2026-01-01T10:00:00Z');
311
- db.close();
312
-
313
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
314
-
315
- const agentEntry = evidence.find(e => e.sourceRef.startsWith('agent_turn:'));
316
- expect(agentEntry).toBeDefined();
317
- expect(agentEntry!.note).toContain('Partial output truncated...');
318
- expect(agentEntry!.note).toContain('[TRUNCATED: output cut off by length limit]');
319
- });
320
-
321
- it('does not include truncation warning when stop_reason is end_turn', () => {
322
- const db = createTrajectoryDb();
323
- db.prepare(`
324
- INSERT INTO assistant_turns (session_id, sanitized_text, stop_reason, created_at)
325
- VALUES (?, ?, ?, ?)
326
- `).run('123', 'Complete output', 'end_turn', '2026-01-01T10:00:00Z');
327
- db.close();
328
-
329
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, '123', tmpDir);
330
-
331
- const agentEntry = evidence.find(e => e.sourceRef.startsWith('agent_turn:'));
332
- expect(agentEntry).toBeDefined();
333
- expect(agentEntry!.note).toBe('Complete output');
334
- expect(agentEntry!.note).not.toContain('[TRUNCATED');
335
- });
301
+ afterEach(() => {
302
+ try {
303
+ fs.rmSync(tmpDir, { recursive: true, force: true });
304
+ } catch {
305
+ // ignore cleanup errors
306
+ }
307
+ });
308
+
309
+ it('returns available with entries for a session that has turns', async () => {
310
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
311
+ const db = createTrajectoryDb();
312
+ // Turn writers upsert the sessions row — mirror the real DB shape.
313
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
314
+ .run('real-session', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
315
+ insertUserTurn(db, 'real-session', 'Owner correction', true, '2026-01-01T09:59:00Z');
316
+ insertAssistantTurn(db, 'real-session', 'assistant text', '2026-01-01T10:00:00Z');
317
+ db.close();
318
+
319
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'real-session', tmpDir);
320
+
321
+ expect(result.status).toBe('available');
322
+ if (result.status !== 'available') return;
323
+ expect(result.entries.length).toBeGreaterThan(0);
324
+ expect(result.entries.some(e => e.sourceRef.startsWith('owner_message:'))).toBe(true);
325
+ expect(result.entries.some(e => e.sourceRef.startsWith('agent_turn:'))).toBe(true);
326
+ });
327
+
328
+ it('returns unavailable/session_not_found for a session absent from the sessions table (SPEC 12.1.4)', async () => {
329
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
330
+ const db = createTrajectoryDb();
331
+ insertAssistantTurn(db, 'other-session', 'unrelated', '2026-01-01T10:00:00Z');
332
+ db.close();
333
+
334
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'no-such-session', tmpDir);
335
+
336
+ expect(result.status).toBe('unavailable');
337
+ if (result.status !== 'unavailable') return;
338
+ expect(result.reasonCode).toBe('session_not_found');
339
+ });
340
+
341
+ it('returns unavailable/session_not_found for the "cli" and "unknown" sentinels', async () => {
342
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
343
+ const db = createTrajectoryDb();
344
+ db.close();
345
+
346
+ const cliResult = acquireTrajectoryEvidenceFromDb(stateDir, 'cli', tmpDir);
347
+ const unknownResult = acquireTrajectoryEvidenceFromDb(stateDir, 'unknown', tmpDir);
348
+
349
+ expect(cliResult.status).toBe('unavailable');
350
+ expect(unknownResult.status).toBe('unavailable');
351
+ if (cliResult.status !== 'unavailable' || unknownResult.status !== 'unavailable') return;
352
+ expect(cliResult.reasonCode).toBe('session_not_found');
353
+ expect(unknownResult.reasonCode).toBe('session_not_found');
354
+ });
355
+
356
+ it('returns unavailable/trajectory_unavailable when trajectory.db is missing', async () => {
357
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
358
+
359
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
360
+
361
+ expect(result.status).toBe('unavailable');
362
+ if (result.status !== 'unavailable') return;
363
+ expect(result.reasonCode).toBe('trajectory_unavailable');
364
+ });
365
+
366
+ it('returns unavailable/empty_trajectory when the session exists but has no turns or tool calls', async () => {
367
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
368
+ const db = createTrajectoryDb();
369
+ db.prepare(`
370
+ INSERT INTO sessions (session_id, started_at, updated_at)
371
+ VALUES (?, ?, ?)
372
+ `).run('quiet-session', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
373
+ db.close();
374
+
375
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'quiet-session', tmpDir);
376
+
377
+ expect(result.status).toBe('unavailable');
378
+ if (result.status !== 'unavailable') return;
379
+ expect(result.reasonCode).toBe('empty_trajectory');
380
+ });
381
+
382
+ it('returns a different reasonCode for unreadable DB vs empty trajectory (exec-prompt item 5)', async () => {
383
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
384
+ const db = createTrajectoryDb();
385
+ db.close();
386
+ // Corrupt the DB file after close so the read-only open fails.
387
+ const dbPath = path.join(stateDir, 'trajectory.db');
388
+ fs.writeFileSync(dbPath, Buffer.from('this is not a sqlite database at all'));
389
+
390
+ const unreadable = acquireTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
391
+
392
+ const stateDir2 = createStateDir();
393
+ const db2 = createTrajectoryDb();
394
+ db2.close();
395
+
396
+ expect(unreadable.status).toBe('unavailable');
397
+ if (unreadable.status !== 'unavailable') return;
398
+ // Unreadable DB must not share a reasonCode with a real-but-empty session.
399
+ expect(unreadable.reasonCode).not.toBe('empty_trajectory');
400
+ expect(unreadable.reasonCode).toBe('evidence_read_failed');
336
401
  });
337
402
  });
@@ -108,6 +108,8 @@ vi.mock('@principles/core/runtime-v2', () => {
108
108
  OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
109
109
  PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
110
110
  SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
111
+ // PRI-638: capability gate — available by default; disabled cases override this.
112
+ resolveDiagnosticianCapability: vi.fn((): { available: boolean; reason?: string; message?: string; nextAction?: string } => ({ available: true })),
111
113
  PDRuntimeError: class PDRuntimeError extends Error {
112
114
  constructor(public category: string, message: string) {
113
115
  super(message);
@@ -1714,3 +1716,83 @@ describe('BUG-2 (PRI-442): sourcePainId resolution for dreamer seed', () => {
1714
1716
  exitSpy.mockRestore();
1715
1717
  });
1716
1718
  });
1719
+
1720
+ // ── PRI-638: unified capability-disabled semantics ───────────────────────────
1721
+ //
1722
+ // The CLI owns no kill switch of its own: it reads the canonical authority
1723
+ // (internalAgents.agents.diagnostician.enabled) through the same resolver the
1724
+ // runtime factory uses. Owner-disabled must come out as a structured
1725
+ // `capability_disabled` result — never as missing_runtime / config failure —
1726
+ // with no adapter constructed and no provider contacted.
1727
+
1728
+ describe('PRI-638: pd diagnose run when Diagnostician capability is disabled', () => {
1729
+ beforeEach(async () => {
1730
+ vi.clearAllMocks();
1731
+ const runtimeV2 = await import('@principles/core/runtime-v2');
1732
+ vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReturnValue({
1733
+ available: false,
1734
+ reason: 'capability_disabled',
1735
+ message: "Agent 'diagnostician' is disabled",
1736
+ nextAction: "Enable agent 'diagnostician' in .pd/config.yaml internalAgents.agents.diagnostician.enabled",
1737
+ });
1738
+ });
1739
+
1740
+ afterEach(async () => {
1741
+ const runtimeV2 = await import('@principles/core/runtime-v2');
1742
+ vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReset();
1743
+ });
1744
+
1745
+ it('DIAG-638-01: --json emits a structured capability_disabled result and exits 1', async () => {
1746
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1747
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1748
+
1749
+ await handleDiagnoseRun({
1750
+ taskId: 'diag_task-1',
1751
+ workspace: '/tmp/fake-workspace',
1752
+ runtime: 'test-double',
1753
+ json: true,
1754
+ } as DiagnoseRunOptions);
1755
+
1756
+ const jsonLine = logSpy.mock.calls
1757
+ .map((c) => String(c[0]))
1758
+ .find((line) => line.trim().startsWith('{'));
1759
+ expect(jsonLine).toBeDefined();
1760
+ const parsed = JSON.parse(jsonLine as string);
1761
+ expect(parsed.reason).toBe('capability_disabled');
1762
+ expect(parsed.nextAction).toContain('internalAgents.agents.diagnostician.enabled');
1763
+ expect(parsed.message).toContain('disabled');
1764
+
1765
+ // Kill switch fires before any runtime machinery: no adapter, no runner.
1766
+ const runtimeV2 = await import('@principles/core/runtime-v2');
1767
+ expect(runtimeV2.TestDoubleRuntimeAdapter).not.toHaveBeenCalled();
1768
+ expect(runtimeV2.SplitDiagnosticianRunner).not.toHaveBeenCalled();
1769
+ expect(exitSpy).toHaveBeenCalledWith(1);
1770
+
1771
+ logSpy.mockRestore();
1772
+ exitSpy.mockRestore();
1773
+ });
1774
+
1775
+ it('DIAG-638-02: human-readable output names the reason and the recovery action', async () => {
1776
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1777
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1778
+
1779
+ await handleDiagnoseRun({
1780
+ taskId: 'diag_task-1',
1781
+ workspace: '/tmp/fake-workspace',
1782
+ runtime: 'test-double',
1783
+ json: false,
1784
+ } as DiagnoseRunOptions);
1785
+
1786
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
1787
+ expect(out).toContain('diagnostician');
1788
+ expect(out).toContain('capability_disabled');
1789
+ expect(out).toContain('internalAgents.agents.diagnostician.enabled');
1790
+
1791
+ const runtimeV2b = await import('@principles/core/runtime-v2');
1792
+ expect(runtimeV2b.TestDoubleRuntimeAdapter).not.toHaveBeenCalled();
1793
+ expect(exitSpy).toHaveBeenCalledWith(1);
1794
+
1795
+ errSpy.mockRestore();
1796
+ exitSpy.mockRestore();
1797
+ });
1798
+ });
@@ -31,36 +31,42 @@ vi.mock('../../src/commands/build-trajectory-evidence.js', () => ({
31
31
  ]),
32
32
  }));
33
33
 
34
- vi.mock('@principles/core/runtime-v2', () => ({
35
- PainToPrincipleService: vi.fn().mockImplementation(function(this: Record<string, unknown>, opts: Record<string, unknown>) {
36
- lastServiceOpts = opts;
37
- return {
38
- recordPain: vi.fn(async () => mockRecordPainResult),
39
- };
40
- }),
41
- PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
42
- computeEffectivePdConfig: vi.fn().mockReturnValue({
43
- runtimeKind: 'pi-ai',
44
- provider: 'test-provider',
45
- model: 'test-model',
46
- apiKeyEnv: 'TEST_KEY',
47
- timeoutMs: 300000,
48
- agentId: 'main',
49
- language: 'zh-CN',
50
- warnings: [],
51
- }),
52
- resolveRuntimeConfig: vi.fn().mockReturnValue({
53
- runtimeKind: 'pi-ai',
54
- provider: 'test-provider',
55
- model: 'test-model',
56
- apiKeyEnv: 'TEST_KEY',
57
- timeoutMs: 300000,
58
- agentId: 'main',
59
- }),
60
- isRuntimeConfigError: vi.fn().mockReturnValue(false),
61
- resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
62
- isFeatureEnabled: vi.fn().mockImplementation(() => mockIsFeatureEnabledReturn),
63
- }));
34
+ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
35
+ // PRI-642 review blocker 1: keep the REAL core evaluatePainIngress (the
36
+ // shared semantic authority) — only service/IO classes are mocked.
37
+ const actual = await importOriginal<typeof import('@principles/core/runtime-v2')>();
38
+ return {
39
+ ...actual,
40
+ PainToPrincipleService: vi.fn().mockImplementation(function(this: Record<string, unknown>, opts: Record<string, unknown>) {
41
+ lastServiceOpts = opts;
42
+ return {
43
+ recordPain: vi.fn(async () => mockRecordPainResult),
44
+ };
45
+ }),
46
+ PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
47
+ computeEffectivePdConfig: vi.fn().mockReturnValue({
48
+ runtimeKind: 'pi-ai',
49
+ provider: 'test-provider',
50
+ model: 'test-model',
51
+ apiKeyEnv: 'TEST_KEY',
52
+ timeoutMs: 300000,
53
+ agentId: 'main',
54
+ language: 'zh-CN',
55
+ warnings: [],
56
+ }),
57
+ resolveRuntimeConfig: vi.fn().mockReturnValue({
58
+ runtimeKind: 'pi-ai',
59
+ provider: 'test-provider',
60
+ model: 'test-model',
61
+ apiKeyEnv: 'TEST_KEY',
62
+ timeoutMs: 300000,
63
+ agentId: 'main',
64
+ }),
65
+ isRuntimeConfigError: vi.fn().mockReturnValue(false),
66
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
67
+ isFeatureEnabled: vi.fn().mockImplementation(() => mockIsFeatureEnabledReturn),
68
+ };
69
+ });
64
70
 
65
71
  vi.mock('../../src/services/pd-config-loader.js', () => ({
66
72
  loadPdConfig: vi.fn().mockReturnValue({