@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,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
+ }
@@ -24,7 +24,6 @@ import {
24
24
  DiagRouterRunner,
25
25
  DefaultDiagRootCauseValidator,
26
26
  DefaultDiagDistillerValidator,
27
- DisabledDiagnosticianRunner,
28
27
  type DiagnosticianRunnerLike,
29
28
  TestDoubleRuntimeAdapter,
30
29
  OpenClawCliRuntimeAdapter,
@@ -33,8 +32,8 @@ import {
33
32
  isRuntimeConfigError,
34
33
  CandidateIntakeService,
35
34
  run as diagnoseRun,
36
- isFeatureEnabled,
37
35
  SPLIT_PIPELINE_TOTAL_TIMEOUT_MS,
36
+ resolveDiagnosticianCapability,
38
37
  PrincipleTreeLedgerAdapter,
39
38
  SqliteDeadLetterStore,
40
39
  PainSignalBridge,
@@ -44,7 +43,7 @@ import {
44
43
  } from '@principles/core/runtime-v2';
45
44
  import type { PDRuntimeAdapter, RuntimeConfig, OutputLanguage } from '@principles/core/runtime-v2';
46
45
  import type { Command } from 'commander';
47
- import { loadPdConfig, computeFlagsFromLoadResult } from '../services/pd-config-loader.js';
46
+ import { loadPdConfig } from '../services/pd-config-loader.js';
48
47
  import { resolveRuntimeFromPdConfig } from '../services/resolve-runtime-from-pd-config.js';
49
48
  import { createHash } from 'node:crypto';
50
49
  /** Layer 0 content-hash (design §6.1); injected so diag writers can attach predecessorSummary hashes. */
@@ -226,6 +225,9 @@ function refuseExit(opts: PainRetryOptions, payload: { status?: string; painId:
226
225
  }));
227
226
  } else {
228
227
  console.error(`error: ${payload.message ?? payload.reason}`);
228
+ // PRI-638: the reason must be visible in text mode too — an Owner kill
229
+ // switch (capability_disabled) must not read like a runtime fault.
230
+ console.error(`reason: ${payload.reason}`);
229
231
  console.error(`nextAction: ${payload.nextAction}`);
230
232
  }
231
233
  process.exit(1);
@@ -245,6 +247,28 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
245
247
 
246
248
  const { taskId } = resolution;
247
249
 
250
+ // PRI-638: the capability gate comes BEFORE runtime resolution. Previously an
251
+ // Owner-disabled Diagnostician surfaced here as `missing_runtime` ("no
252
+ // .pd/config.yaml runtime binding found"), which told the Owner their config
253
+ // was broken when they had deliberately switched the agent off. This reads the
254
+ // same canonical authority (`internalAgents.agents.diagnostician.enabled`) the
255
+ // runtime factory uses — the CLI owns no kill switch of its own.
256
+ const capability = resolveDiagnosticianCapability(
257
+ (() => {
258
+ const loaded = loadPdConfig(workspaceDir);
259
+ return loaded.ok ? loaded.effective : loaded.defaults;
260
+ })(),
261
+ );
262
+ if (!capability.available) {
263
+ return refuseExit(opts, {
264
+ painId: opts.painId,
265
+ taskId,
266
+ reason: capability.reason,
267
+ message: capability.message,
268
+ nextAction: capability.nextAction,
269
+ });
270
+ }
271
+
248
272
  // Step 2: Look up task and validate
249
273
  const stateManager = new RuntimeStateManager({ workspaceDir });
250
274
 
@@ -522,17 +546,18 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
522
546
  const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
523
547
  const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
524
548
 
525
- // Check if split pipeline is enabled 3 serial LLM calls need more time
526
- const configLoadResult = loadPdConfig(workspaceDir);
527
- const featureFlags = computeFlagsFromLoadResult(configLoadResult);
528
- const isSplitPipeline = isFeatureEnabled(featureFlags, 'diagnostician_split_pipeline');
529
- const pipelineTimeoutMs = isSplitPipeline ? SPLIT_PIPELINE_TOTAL_TIMEOUT_MS : 300_000;
549
+ // PRI-638: implementation selection is gone. The split pipeline is the only
550
+ // Diagnostician implementation, so it always runs with its documented
551
+ // 3-stage budget; `diagnostician_split_pipeline` no longer selects a runner
552
+ // nor disables capability (the capability gate above owns that).
553
+ const pipelineTimeoutMs = SPLIT_PIPELINE_TOTAL_TIMEOUT_MS;
530
554
  // BUG-1 (PRI-442): extract effectiveConfig so ADR-0019 LLM rate-limit
531
555
  // degradation (isDegradationEnabled in base-peer-runner) can read the
532
556
  // diagnostician_llm_degradation feature flag. Without this, the runners
533
557
  // receive no effectiveConfig and degradation silently never fires.
534
558
  // CR-1 (CodeRabbit P2, rc-9): warn when config load failed so the
535
559
  // fallback to defaults is observable — no silent degradation.
560
+ const configLoadResult = loadPdConfig(workspaceDir);
536
561
  if (!configLoadResult.ok) {
537
562
  const errSummary = configLoadResult.errors
538
563
  .map((e) => `${e.path}: ${e.reason}`)
@@ -543,34 +568,29 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
543
568
  }
544
569
  const effectiveConfig = configLoadResult.ok ? configLoadResult.effective : configLoadResult.defaults;
545
570
 
546
- let runner: DiagnosticianRunnerLike;
547
- if (isSplitPipeline) {
548
- const resolvedKind = typeof runtimeAdapter.kind === 'function' ? runtimeAdapter.kind() : runtimeKind;
549
- const perStageTimeoutMs = pipelineTimeoutMs / 3;
550
- const rootCauseRunner = new DiagRootCauseRunner(
551
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler, contentHashFn },
552
- { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
553
- );
554
- const distillerRunner = new DiagDistillerRunner(
555
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator(), contentHashFn },
556
- { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
557
- );
558
- const routerRunner = new DiagRouterRunner(
559
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer, contentHashFn },
560
- { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
561
- );
562
-
563
- runner = new SplitDiagnosticianRunner({
564
- rootCauseRunner,
565
- distillerRunner,
566
- routerRunner,
567
- stateManager,
568
- committer,
569
- perStageTimeoutMs,
570
- });
571
- } else {
572
- runner = new DisabledDiagnosticianRunner();
573
- }
571
+ const resolvedKind = typeof runtimeAdapter.kind === 'function' ? runtimeAdapter.kind() : runtimeKind;
572
+ const perStageTimeoutMs = pipelineTimeoutMs / 3;
573
+ const rootCauseRunner = new DiagRootCauseRunner(
574
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler, contentHashFn },
575
+ { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
576
+ );
577
+ const distillerRunner = new DiagDistillerRunner(
578
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator(), contentHashFn },
579
+ { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
580
+ );
581
+ const routerRunner = new DiagRouterRunner(
582
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer, contentHashFn },
583
+ { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
584
+ );
585
+
586
+ const runner: DiagnosticianRunnerLike = new SplitDiagnosticianRunner({
587
+ rootCauseRunner,
588
+ distillerRunner,
589
+ routerRunner,
590
+ stateManager,
591
+ committer,
592
+ perStageTimeoutMs,
593
+ });
574
594
 
575
595
  // ── Dead letter replay branch ──────────────────────────────────────────
576
596
  // When task was null but a dead letter was found, replay the pain signal
@@ -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)')
@@ -133,6 +133,9 @@ vi.mock('@principles/core/runtime-v2', () => {
133
133
  OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
134
134
  PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
135
135
  SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
136
+ // PRI-638: capability gate — available by default so the BDD scenarios
137
+ // exercise the pre-existing success/failure JSON contracts unchanged.
138
+ resolveDiagnosticianCapability: vi.fn(() => ({ available: true })),
136
139
  PDRuntimeError: class PDRuntimeError extends Error {
137
140
  constructor(public category: string, message: string) {
138
141
  super(message);
@@ -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
+ });