@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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * PRI-642 Scope A — real-Commander integration for `pd pain record --session`.
3
+ *
4
+ * Spawns the BUILT pd CLI (dist/index.js) against a real temp workspace with a
5
+ * real trajectory.db, exercising the actual commander registration and the
6
+ * real typed acquisition path — not the in-process handler with mocks
7
+ * (cli-7-test-wiring; SPEC §13 "Real Commander parser/registration tests for
8
+ * --session and --json").
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
11
+ import { promisify } from 'node:util';
12
+ import Database from 'better-sqlite3';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+
17
+ let tmpDir: string;
18
+
19
+ function createWorkspace(): void {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-record-parser-'));
21
+ const stateDir = path.join(tmpDir, '.state');
22
+ fs.mkdirSync(stateDir, { recursive: true });
23
+
24
+ const db = new Database(path.join(stateDir, 'trajectory.db'));
25
+ db.exec("CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)");
26
+ db.exec("CREATE TABLE IF NOT EXISTS assistant_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, sanitized_text TEXT, stop_reason TEXT, created_at TEXT NOT NULL)");
27
+ db.exec("CREATE TABLE IF NOT EXISTS user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)");
28
+ db.exec("CREATE TABLE IF NOT EXISTS tool_calls (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, tool_name TEXT NOT NULL, outcome TEXT NOT NULL, error_type TEXT, exit_code INTEGER, params_json TEXT NOT NULL DEFAULT '{}', result_preview TEXT, created_at TEXT NOT NULL)");
29
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
30
+ .run('real-session-1', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
31
+ db.prepare('INSERT INTO user_turns (session_id, raw_excerpt, correction_detected, created_at) VALUES (?, ?, ?, ?)')
32
+ .run('real-session-1', 'Please fix the output format', 1, '2026-01-01T09:59:00Z');
33
+ db.close();
34
+ }
35
+
36
+ interface CliRunResult {
37
+ status: number | null;
38
+ stdout: string;
39
+ stderr: string;
40
+ }
41
+
42
+ /** Resolves and boundary-validates the built pd CLI entry before spawning it. */
43
+ async function resolveBuiltCliEntry(): Promise<string> {
44
+ const packageRoot = path.resolve(process.cwd());
45
+ const entry = path.resolve(packageRoot, 'dist', 'index.js');
46
+ if (!entry.startsWith(`${packageRoot}${path.sep}`) || !fs.statSync(entry).isFile()) {
47
+ throw new Error(`built pd CLI entry not found or outside the package: ${entry}`);
48
+ }
49
+ return entry;
50
+ }
51
+
52
+ async function runBuiltCli(literalArgv: readonly string[]): Promise<CliRunResult> {
53
+ const { execFile } = await import('node:child_process');
54
+ const execFileAsync = promisify(execFile);
55
+ const entry = await resolveBuiltCliEntry();
56
+ try {
57
+ const { stdout } = await execFileAsync(process.execPath, [entry, ...literalArgv], {
58
+ cwd: tmpDir,
59
+ timeout: 30000,
60
+ encoding: 'utf8',
61
+ env: { ...process.env, PD_WORKSPACE_DIR: tmpDir },
62
+ });
63
+ return { status: 0, stdout, stderr: '' };
64
+ } catch (err) {
65
+ const e = err as { code?: number; stdout?: string; stderr?: string };
66
+ return { status: e.code ?? -1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
67
+ }
68
+ }
69
+
70
+ describe('pd pain record --session (real Commander + real trajectory.db)', () => {
71
+ beforeEach(() => {
72
+ createWorkspace();
73
+ });
74
+
75
+ afterEach(() => {
76
+ try {
77
+ fs.rmSync(tmpDir, { recursive: true, force: true });
78
+ } catch {
79
+ // ignore cleanup errors
80
+ }
81
+ });
82
+
83
+ it('--help registers and documents --session', async () => {
84
+ const result = await runBuiltCli(['pain', 'record', '--help']);
85
+ expect(result.status).toBe(0);
86
+ expect(result.stdout).toContain('--session');
87
+ }, 15_000);
88
+
89
+ it('fails with a single JSON object and reason session_not_found for a nonexistent session (SPEC 12.1.4)', async () => {
90
+ const result = await runBuiltCli([
91
+ 'pain', 'record',
92
+ '--reason', 'parser test pain',
93
+ '--session', 'no-such-session',
94
+ '--workspace', tmpDir,
95
+ '--json',
96
+ ]);
97
+
98
+ // Failed validation exits non-zero.
99
+ expect(result.status).not.toBe(0);
100
+ // cli-1: stdout is exactly one JSON object.
101
+ const trimmed = result.stdout.trim();
102
+ expect(trimmed.length).toBeGreaterThan(0);
103
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
104
+ expect(parsed.status).toBe('failed');
105
+ expect(parsed.reason).toBe('session_not_found');
106
+ expect(typeof parsed.nextAction).toBe('string');
107
+ }, 15_000);
108
+
109
+ it('accepts --session for a session that exists in the trajectory (validation passes, submission proceeds)', async () => {
110
+ const result = await runBuiltCli([
111
+ 'pain', 'record',
112
+ '--reason', 'parser test pain with real session',
113
+ '--session', 'real-session-1',
114
+ '--workspace', tmpDir,
115
+ '--json',
116
+ ]);
117
+
118
+ // The session exists, so session validation must NOT reject it. The run
119
+ // may still fail later (no LLM runtime configured in this temp
120
+ // workspace) — the assertion is only about the validation stage.
121
+ const trimmed = result.stdout.trim();
122
+ expect(trimmed.length).toBeGreaterThan(0);
123
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
124
+ expect(parsed.reason).not.toBe('session_not_found');
125
+ expect(parsed.reason).not.toBe('empty_trajectory');
126
+ }, 15_000);
127
+ });
@@ -21,21 +21,31 @@ vi.mock('fs', () => ({
21
21
  }));
22
22
 
23
23
  vi.mock('../../src/commands/build-trajectory-evidence.js', () => ({
24
- buildTrajectoryEvidenceFromDb: vi.fn().mockReturnValue([
25
- { sourceRef: 'owner_reported:cli', note: 'No session context available' },
26
- ]),
24
+ acquireTrajectoryEvidenceFromDb: vi.fn().mockReturnValue({
25
+ status: 'available',
26
+ entries: [
27
+ { sourceRef: 'owner_message:2026-01-01T10:00:00Z', note: 'Owner correction' },
28
+ { sourceRef: 'agent_turn:2026-01-01T10:01:00Z', note: 'assistant evidence' },
29
+ ],
30
+ }),
27
31
  }));
28
32
 
29
- vi.mock('@principles/core/runtime-v2', () => ({
30
- PainToPrincipleService: vi.fn().mockImplementation(function() {
31
- return {
32
- recordPain: vi.fn(async (input: PainToPrincipleInput) => {
33
- lastRecordPainInput = input;
34
- return mockRecordPainResult;
35
- }),
36
- };
37
- }),
38
- PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
33
+ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
34
+ // PRI-642 review blocker 1: the CLI now evaluates ingress semantics via
35
+ // the REAL core evaluatePainIngress (the shared authority) — only the
36
+ // 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() {
41
+ return {
42
+ recordPain: vi.fn(async (input: PainToPrincipleInput) => {
43
+ lastRecordPainInput = input;
44
+ return mockRecordPainResult;
45
+ }),
46
+ };
47
+ }),
48
+ PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
39
49
  computeEffectivePdConfig: vi.fn().mockReturnValue({
40
50
  runtimeKind: 'pi-ai',
41
51
  provider: 'test-provider',
@@ -58,7 +68,8 @@ vi.mock('@principles/core/runtime-v2', () => ({
58
68
  isBuiltinPiAiProvider: vi.fn().mockReturnValue(true),
59
69
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
60
70
  isFeatureEnabled: vi.fn().mockReturnValue(false),
61
- }));
71
+ };
72
+ });
62
73
 
63
74
  vi.mock('../../src/services/pd-config-loader.js', () => ({
64
75
  loadPdConfig: vi.fn().mockReturnValue({
@@ -77,8 +88,9 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
77
88
  }));
78
89
 
79
90
  import { handlePainRecord } from '../../src/commands/pain-record.js';
80
- import { isBuiltinPiAiProvider } from '@principles/core/runtime-v2';
81
- import type { PainToPrincipleOutput, PainToPrincipleInput, FailureCategory } from '@principles/core/runtime-v2';
91
+ import { isBuiltinPiAiProvider, type PainToPrincipleOutput, type PainToPrincipleInput } from '@principles/core/runtime-v2';
92
+ import type { FailureCategory } from '@principles/core/runtime-v2';
93
+ import { acquireTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
82
94
 
83
95
  // ── Helpers ─────────────────────────────────────────────────────────────────
84
96
 
@@ -351,51 +363,207 @@ describe('pd pain record', () => {
351
363
  expect(lastRecordPainInput!.source).toBe('ci');
352
364
  expect(lastRecordPainInput!.reason).toBe('test pain');
353
365
  expect(lastRecordPainInput!.score).toBe(90);
354
- expect(lastRecordPainInput!.sessionId).toBe('cli');
366
+ // PRI-642: no --session means an explicit unbound Owner report — the
367
+ // 'cli' sentinel session must NOT be fabricated (SPEC §7.4).
368
+ expect(lastRecordPainInput!.sessionId).toBeUndefined();
355
369
  expect(lastRecordPainInput!.agentId).toBe('pd-cli');
356
- // PRI-341: evidence field is now always provided
357
- expect(lastRecordPainInput!.evidence).toBeTruthy();
358
- expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
370
+ // …and no placeholder evidence entry may be submitted.
371
+ expect(lastRecordPainInput!.evidence).toEqual([]);
372
+ expect(lastRecordPainInput!.recordObservability).toBe(false);
373
+ expect(lastRecordPainInput!.provenance).toBe('owner_reported_no_host_trace');
359
374
  });
360
375
 
361
- // ── PRI-341: evidence passthrough and --session flag ──────────────────────
362
-
363
- // 用例 C: recordPain receives non-empty evidence field when session provided
364
- it('C: passes evidence to recordPain when --session is provided', async () => {
365
- // Mock buildTrajectoryEvidenceFromDb to return evidence
366
- const mockEvidence = [
367
- { sourceRef: 'agent_turn:2026-01-01T10:00:00Z', note: 'assistant text evidence' },
368
- ];
369
- vi.doMock('../../src/commands/build-trajectory-evidence.js', () => ({
370
- buildTrajectoryEvidenceFromDb: vi.fn().mockReturnValue(mockEvidence),
371
- }));
376
+ // ── PRI-341 → PRI-642: evidence passthrough and --session flag ─────────────
372
377
 
378
+ // 用例 C: recordPain receives bound provenance + validated evidence when a real session is provided
379
+ it('C: passes bound provenance and evidence to recordPain when --session resolves available', async () => {
373
380
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
374
381
  const exitSpy = mockProcessExit();
375
382
 
376
383
  await handlePainRecord({ reason: 'test pain', session: 'sess-123', json: true });
377
384
 
378
385
  expect(lastRecordPainInput).toBeTruthy();
386
+ expect(acquireTrajectoryEvidenceFromDb).toHaveBeenCalled();
379
387
  expect(lastRecordPainInput!.evidence).toBeTruthy();
380
388
  expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
389
+ expect(lastRecordPainInput!.evidence![0].sourceRef).not.toBe('owner_reported:cli');
381
390
  expect(lastRecordPainInput!.sessionId).toBe('sess-123');
391
+ // SPEC §7.3: provenance must be derived from the validated result —
392
+ // a real session is host-context-bound, never owner_reported_no_host_trace.
393
+ expect(lastRecordPainInput!.provenance).toBe('host_context_bound');
394
+ // PRI-640/SPEC §8.3: the bound path attributes the OpenClaw trajectory.
395
+ expect(lastRecordPainInput!.hostKind).toBe('openclaw');
396
+ expect(lastRecordPainInput!.recordObservability).toBe(true);
382
397
 
383
398
  logSpy.mockRestore();
384
399
  exitSpy.mockRestore();
385
400
  });
386
401
 
387
- // 用例 C2: without session, evidence field still has a placeholder (not empty)
388
- it('C2: passes placeholder evidence when no --session provided', async () => {
402
+ // 用例 C2 (PRI-642 rewrite): without session, no sentinel session, no
403
+ // placeholder evidence an honest unbound Owner report (SPEC §7.4).
404
+ it('C2: submits an honest unbound report when no --session provided', async () => {
389
405
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
390
406
  const exitSpy = mockProcessExit();
391
407
 
392
408
  await handlePainRecord({ reason: 'test pain', json: true });
393
409
 
394
410
  expect(lastRecordPainInput).toBeTruthy();
395
- expect(lastRecordPainInput!.evidence).toBeTruthy();
396
- expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
397
- // Default evidence should be a CLI placeholder
398
- expect(lastRecordPainInput!.evidence![0].sourceRef).toBe('owner_reported:cli');
411
+ expect(lastRecordPainInput!.sessionId).toBeUndefined();
412
+ expect(lastRecordPainInput!.evidence).toEqual([]);
413
+ expect(lastRecordPainInput!.recordObservability).toBe(false);
414
+ expect(lastRecordPainInput!.provenance).toBe('owner_reported_no_host_trace');
415
+
416
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
417
+ // SPEC §7.4: disclose context_unbound and recommend --session.
418
+ const warnings = JSON.stringify(jsonOutput);
419
+ expect(warnings).toMatch(/context_unbound/);
420
+ expect(warnings).toMatch(/--session/);
421
+
422
+ logSpy.mockRestore();
423
+ exitSpy.mockRestore();
424
+ });
425
+
426
+ // ── PRI-642 Scope A: explicit-session validation (SPEC §7.3, §12.1.3–12.1.4) ─
427
+
428
+ it('fails with session_not_found before any task/candidate mutation when --session does not exist', async () => {
429
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
430
+ status: 'unavailable',
431
+ reasonCode: 'session_not_found',
432
+ detail: 'session not present in trajectory.db sessions table',
433
+ } as any);
434
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
435
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
436
+ const exitSpy = mockProcessExit();
437
+
438
+ await handlePainRecord({ reason: 'test pain', session: 'missing-session', json: true });
439
+
440
+ // cli-1: exactly one JSON object on stdout
441
+ expect(logSpy).toHaveBeenCalledTimes(1);
442
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
443
+ expect(jsonOutput.status).toBe('failed');
444
+ expect(jsonOutput.reason).toBe('session_not_found');
445
+ expect(jsonOutput.nextAction).toBeTruthy();
446
+ // cli-2/cli-5: execution stopped — no service mutation, exit 1
447
+ expect(lastRecordPainInput).toBeNull();
448
+ expect(exitSpy).toHaveBeenCalledWith(1);
449
+
450
+ logSpy.mockRestore();
451
+ errorSpy.mockRestore();
452
+ exitSpy.mockRestore();
453
+ });
454
+
455
+ it('fails before any mutation on session_not_found even when process.exit is stubbed to a no-op', async () => {
456
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
457
+ status: 'unavailable',
458
+ reasonCode: 'session_not_found',
459
+ detail: 'session not present',
460
+ } as any);
461
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
462
+ const exitSpy = mockProcessExit();
463
+
464
+ await handlePainRecord({ reason: 'test pain', session: 'missing-session', json: true });
465
+
466
+ // cli-2: with process.exit stubbed, control flow must still stop —
467
+ // recordPain was never invoked and stdout carries exactly one object.
468
+ expect(lastRecordPainInput).toBeNull();
469
+ expect(logSpy).toHaveBeenCalledTimes(1);
470
+ logSpy.mockRestore();
471
+ exitSpy.mockRestore();
472
+ });
473
+
474
+ it('fails before mutation on empty_trajectory (CLI never claims host_context_bound without a verified session)', async () => {
475
+ // Per Evidence Over Assumption: the CLI does not own session identity
476
+ // the way the OpenClaw host command context does. Unverified sessions
477
+ // (any acquisition that does not yield 'available') must refuse before
478
+ // any LLM/task/candidate mutation.
479
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
480
+ status: 'unavailable',
481
+ reasonCode: 'empty_trajectory',
482
+ detail: 'session exists but no usable evidence',
483
+ } as any);
484
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
485
+ const exitSpy = mockProcessExit();
486
+
487
+ await handlePainRecord({ reason: 'test pain', session: 'quiet-session', json: true });
488
+
489
+ expect(lastRecordPainInput).toBeNull();
490
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
491
+ expect(jsonOutput.status).toBe('failed');
492
+ expect(jsonOutput.reason).toBe('empty_trajectory');
493
+ expect(exitSpy).toHaveBeenCalledWith(1);
494
+
495
+ logSpy.mockRestore();
496
+ exitSpy.mockRestore();
497
+ });
498
+
499
+ it('fails before mutation on evidence_read_failed (no LLM/no task when the CLI cannot verify binding)', async () => {
500
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
501
+ status: 'unavailable',
502
+ reasonCode: 'evidence_read_failed',
503
+ detail: 'trajectory.db unreadable',
504
+ } as any);
505
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
506
+ const exitSpy = mockProcessExit();
507
+
508
+ await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
509
+
510
+ expect(lastRecordPainInput).toBeNull();
511
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
512
+ expect(jsonOutput.status).toBe('failed');
513
+ expect(jsonOutput.reason).toBe('evidence_read_failed');
514
+ expect(exitSpy).toHaveBeenCalledWith(1);
515
+
516
+ logSpy.mockRestore();
517
+ exitSpy.mockRestore();
518
+ });
519
+
520
+ it('fails before mutation on trajectory_unavailable (CLI cannot fabricate a session)', async () => {
521
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
522
+ status: 'unavailable',
523
+ reasonCode: 'trajectory_unavailable',
524
+ detail: 'no trajectory.db at workspace .state',
525
+ } as any);
526
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
527
+ const exitSpy = mockProcessExit();
528
+
529
+ await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
530
+
531
+ expect(lastRecordPainInput).toBeNull();
532
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
533
+ expect(jsonOutput.status).toBe('failed');
534
+ expect(jsonOutput.reason).toBe('trajectory_unavailable');
535
+ expect(exitSpy).toHaveBeenCalledWith(1);
536
+
537
+ logSpy.mockRestore();
538
+ exitSpy.mockRestore();
539
+ });
540
+
541
+ // ── PRI-642 Scope A (SPEC §12.1.6): generated-but-unadmitted candidates must
542
+ // not be reported as completed internalization. ─────────────────────────────
543
+
544
+ it('warns that no candidate was admitted/internalized when all candidates are gated', async () => {
545
+ mockRecordPainResult = {
546
+ ...SUCCEEDED_RESULT,
547
+ candidateIds: ['c1', 'c2', 'c3', 'c4'],
548
+ ledgerEntryIds: [],
549
+ admissionResults: [
550
+ { candidateId: 'c1', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'confidence_below_threshold:0.45<0.50', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
551
+ { candidateId: 'c2', recommendationKind: 'prompt', admission: { decision: 'deferred', reason: 'recommendation_kind_defer_not_actionable', nextAction: 'none', evidenceStatus: 'host_context_bound' } },
552
+ { candidateId: 'c3', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'input_evidence_empty', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
553
+ { candidateId: 'c4', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'confidence_below_threshold:0.45<0.50', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
554
+ ],
555
+ } as PainToPrincipleOutput;
556
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
557
+ const exitSpy = mockProcessExit();
558
+
559
+ await handlePainRecord({ reason: 'test pain', json: true });
560
+
561
+ const allOutput = logSpy.mock.calls.map(c => c.join(' ')).join(' ');
562
+ // Explicit admission summary; must not read as completed internalization.
563
+ expect(allOutput).toMatch(/0 (?:of|\/) 4|admitted.*0|0.*admitted/i);
564
+ const jsonOutput = JSON.parse(logSpy.mock.calls.at(-1)![0] ?? '{}');
565
+ const jsonStr = JSON.stringify(jsonOutput);
566
+ expect(jsonStr).toMatch(/no candidate was admitted|admitted:\s*0/i);
399
567
 
400
568
  logSpy.mockRestore();
401
569
  exitSpy.mockRestore();
@@ -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
+ });