@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.
@@ -20,123 +20,122 @@ import {
20
20
  import type { PainEvidenceEntry } from '@principles/core/runtime-v2';
21
21
 
22
22
  /**
23
- * Build trajectory evidence entries by reading trajectory.db directly.
24
- *
25
- * @param stateDir - The .state directory containing trajectory.db
26
- * @param sessionId - The session ID to query turns for
27
- * @param workspaceDir - Workspace directory for path redaction (sanitizeString)
28
- * @returns Array of PainEvidenceEntry (never empty; degraded entries on failure)
23
+ * PRI-642 (SPEC §7.3): discriminated acquisition result. `unavailable` carries
24
+ * a distinct reasonCode so `pd pain record --session` can fail/degrade
25
+ * explicitly BEFORE any LLM/task/candidate mutation, without placeholder
26
+ * evidence.
29
27
  */
30
- export function buildTrajectoryEvidenceFromDb(
31
- stateDir: string,
32
- sessionId: string | undefined,
33
- workspaceDir?: string,
34
- ): PainEvidenceEntry[] {
35
- const evidence: PainEvidenceEntry[] = [];
28
+ export type TrajectoryEvidenceAcquisition =
29
+ | { status: 'available'; entries: PainEvidenceEntry[] }
30
+ | {
31
+ status: 'unavailable';
32
+ reasonCode: 'trajectory_unavailable' | 'session_not_found' | 'empty_trajectory' | 'evidence_read_failed';
33
+ detail: string;
34
+ };
36
35
 
37
- // No session or empty placeholder entry (ERR-002: never return empty array silently)
38
- if (!sessionId || sessionId === 'cli' || sessionId === 'unknown') {
39
- evidence.push({
40
- sourceRef: 'owner_reported:cli',
41
- note: 'No session context available',
42
- });
43
- return evidence;
44
- }
36
+ /** SourceRef markers that carry no real behavior trace (placeholder shapes). */
37
+ const PLACEHOLDER_SOURCE_REFS = new Set([
38
+ 'owner_reported:cli',
39
+ 'owner_message:unavailable',
40
+ 'agent_turn:unavailable',
41
+ 'tool_call_failure:unavailable',
42
+ 'trajectory:empty',
43
+ ]);
45
44
 
46
- const dbPath = path.join(stateDir, 'trajectory.db');
47
- if (!fs.existsSync(dbPath)) {
48
- evidence.push({
49
- sourceRef: 'owner_reported:cli',
50
- note: 'No session context available',
51
- });
52
- return evidence;
53
- }
45
+ interface TrajectoryDbCollection {
46
+ entries: PainEvidenceEntry[];
47
+ realEntryCount: number;
48
+ readFailed: boolean;
49
+ }
54
50
 
55
- let db: Database.Database;
56
- try {
57
- db = new Database(dbPath, { readonly: true });
58
- } catch {
59
- evidence.push({
60
- sourceRef: 'owner_reported:cli',
61
- note: 'No session context available',
62
- });
63
- return evidence;
64
- }
51
+ /**
52
+ * Collect evidence entries from an open trajectory.db for one session.
53
+ * Shared by the typed acquisition API and the legacy array wrapper so the
54
+ * two can never drift.
55
+ */
56
+ function collectEvidenceFromDb(
57
+ db: Database.Database,
58
+ sessionId: string,
59
+ workspaceDir?: string,
60
+ ): TrajectoryDbCollection {
61
+ const evidence: PainEvidenceEntry[] = [];
62
+ let readFailed = false;
65
63
 
64
+ // Try to read user turns with correction detection
66
65
  try {
67
- // Try to read user turns with correction detection
68
- try {
69
- const userTurns = db.prepare(`
66
+ const userTurns = db.prepare(`
70
67
  SELECT id, raw_excerpt, correction_detected, correction_cue, created_at
71
68
  FROM user_turns
72
69
  WHERE session_id = ?
73
70
  ORDER BY id ASC
74
71
  `).all(sessionId) as Record<string, unknown>[];
75
72
 
76
- const lastCorrectionTurn = [...userTurns].reverse().find(t => Boolean(t.correction_detected));
77
- if (lastCorrectionTurn) {
78
- const rawExcerpt = typeof lastCorrectionTurn.raw_excerpt === 'string'
79
- ? lastCorrectionTurn.raw_excerpt
80
- : '';
81
- const sanitizedNote = sanitizeString(
82
- rawExcerpt.slice(0, MAX_EVIDENCE_NOTE_CHARS),
83
- workspaceDir,
84
- );
85
- evidence.push({
86
- sourceRef: `owner_message:${String(lastCorrectionTurn.created_at ?? 'unknown')}`,
87
- note: sanitizedNote,
88
- });
89
- }
90
- } catch {
91
- // user_turns table may not exist — degrade gracefully
92
- if (evidence.length < MAX_EVIDENCE_ENTRIES) {
93
- evidence.push({
94
- sourceRef: 'owner_message:unavailable',
95
- note: 'trajectory_user_turns_unavailable',
96
- });
97
- }
73
+ const lastCorrectionTurn = [...userTurns].reverse().find(t => Boolean(t.correction_detected));
74
+ if (lastCorrectionTurn) {
75
+ const rawExcerpt = typeof lastCorrectionTurn.raw_excerpt === 'string'
76
+ ? lastCorrectionTurn.raw_excerpt
77
+ : '';
78
+ const sanitizedNote = sanitizeString(
79
+ rawExcerpt.slice(0, MAX_EVIDENCE_NOTE_CHARS),
80
+ workspaceDir,
81
+ );
82
+ evidence.push({
83
+ sourceRef: `owner_message:${String(lastCorrectionTurn.created_at ?? 'unknown')}`,
84
+ note: sanitizedNote,
85
+ });
98
86
  }
87
+ } catch {
88
+ readFailed = true;
89
+ // user_turns table may not exist — degrade gracefully
90
+ if (evidence.length < MAX_EVIDENCE_ENTRIES) {
91
+ evidence.push({
92
+ sourceRef: 'owner_message:unavailable',
93
+ note: 'trajectory_user_turns_unavailable',
94
+ });
95
+ }
96
+ }
99
97
 
100
- // Try to read assistant turns (last 3)
101
- try {
102
- const assistantTurns = db.prepare(`
98
+ // Try to read assistant turns (last 3)
99
+ try {
100
+ const assistantTurns = db.prepare(`
103
101
  SELECT id, sanitized_text, stop_reason, created_at
104
102
  FROM assistant_turns
105
103
  WHERE session_id = ?
106
104
  ORDER BY id ASC
107
105
  `).all(sessionId) as Record<string, unknown>[];
108
106
 
109
- const recentAssistant = assistantTurns.slice(-3);
110
- for (const turn of recentAssistant) {
111
- if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
112
- const sanitizedText = typeof turn.sanitized_text === 'string'
113
- ? turn.sanitized_text
114
- : '';
115
- const sanitizedNote = sanitizeString(
116
- sanitizedText.slice(0, MAX_EVIDENCE_NOTE_CHARS),
117
- workspaceDir,
118
- );
119
- // Enhanced: append truncation warning when stop_reason=length
120
- const stopReason = typeof turn.stop_reason === 'string' ? turn.stop_reason : null;
121
- const truncationWarning = stopReason === 'length' ? ' [TRUNCATED: output cut off by length limit]' : '';
122
- evidence.push({
123
- sourceRef: `agent_turn:${String(turn.created_at ?? 'unknown')}`,
124
- note: sanitizedNote + truncationWarning,
125
- });
126
- }
127
- } catch {
128
- // assistant_turns table may not exist — degrade gracefully
129
- if (evidence.length < MAX_EVIDENCE_ENTRIES) {
130
- evidence.push({
131
- sourceRef: 'agent_turn:unavailable',
132
- note: 'trajectory_assistant_turns_unavailable',
133
- });
134
- }
107
+ const recentAssistant = assistantTurns.slice(-3);
108
+ for (const turn of recentAssistant) {
109
+ if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
110
+ const sanitizedText = typeof turn.sanitized_text === 'string'
111
+ ? turn.sanitized_text
112
+ : '';
113
+ const sanitizedNote = sanitizeString(
114
+ sanitizedText.slice(0, MAX_EVIDENCE_NOTE_CHARS),
115
+ workspaceDir,
116
+ );
117
+ // Enhanced: append truncation warning when stop_reason=length
118
+ const stopReason = typeof turn.stop_reason === 'string' ? turn.stop_reason : null;
119
+ const truncationWarning = stopReason === 'length' ? ' [TRUNCATED: output cut off by length limit]' : '';
120
+ evidence.push({
121
+ sourceRef: `agent_turn:${String(turn.created_at ?? 'unknown')}`,
122
+ note: sanitizedNote + truncationWarning,
123
+ });
135
124
  }
125
+ } catch {
126
+ readFailed = true;
127
+ // assistant_turns table may not exist — degrade gracefully
128
+ if (evidence.length < MAX_EVIDENCE_ENTRIES) {
129
+ evidence.push({
130
+ sourceRef: 'agent_turn:unavailable',
131
+ note: 'trajectory_assistant_turns_unavailable',
132
+ });
133
+ }
134
+ }
136
135
 
137
- // PRI-358: Try to read failed tool_calls (last 3 failures, chronological order)
138
- try {
139
- const failedToolCalls = db.prepare(`
136
+ // PRI-358: Try to read failed tool_calls (last 3 failures, chronological order)
137
+ try {
138
+ const failedToolCalls = db.prepare(`
140
139
  SELECT tool_name, error_type, exit_code, result_preview, created_at
141
140
  FROM (
142
141
  SELECT tool_name, error_type, exit_code, result_preview, created_at
@@ -148,39 +147,129 @@ export function buildTrajectoryEvidenceFromDb(
148
147
  ORDER BY created_at ASC
149
148
  `).all(sessionId) as Record<string, unknown>[];
150
149
 
151
- for (const tc of failedToolCalls) {
152
- if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
153
- const toolName = typeof tc.tool_name === 'string' ? tc.tool_name : 'unknown';
154
- const errorType = typeof tc.error_type === 'string' ? tc.error_type : 'unknown';
155
- const exitCode = tc.exit_code != null ? String(tc.exit_code) : 'N/A';
156
- // Enhanced: append resultPreview when available
157
- const resultPreview = typeof tc.result_preview === 'string' ? tc.result_preview : null;
158
- const previewSuffix = resultPreview ? ` | ${resultPreview.slice(0, 200)}` : '';
159
- const note = `Tool ${toolName} failed: ${errorType} (exitCode: ${exitCode})${previewSuffix}`;
160
- evidence.push({
161
- sourceRef: `tool_call_failure:${String(tc.created_at ?? 'unknown')}`,
162
- note: sanitizeString(note.slice(0, MAX_EVIDENCE_NOTE_CHARS), workspaceDir),
163
- });
164
- }
165
- } catch {
166
- // tool_calls table may not exist — degrade gracefully (only when no other evidence)
167
- if (evidence.length === 0) {
168
- evidence.push({
169
- sourceRef: 'tool_call_failure:unavailable',
170
- note: 'trajectory_tool_calls_unavailable',
171
- });
172
- }
150
+ for (const tc of failedToolCalls) {
151
+ if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
152
+ const toolName = typeof tc.tool_name === 'string' ? tc.tool_name : 'unknown';
153
+ const errorType = typeof tc.error_type === 'string' ? tc.error_type : 'unknown';
154
+ const exitCode = tc.exit_code != null ? String(tc.exit_code) : 'N/A';
155
+ // Enhanced: append resultPreview when available
156
+ const resultPreview = typeof tc.result_preview === 'string' ? tc.result_preview : null;
157
+ const previewSuffix = resultPreview ? ` | ${resultPreview.slice(0, 200)}` : '';
158
+ const note = `Tool ${toolName} failed: ${errorType} (exitCode: ${exitCode})${previewSuffix}`;
159
+ evidence.push({
160
+ sourceRef: `tool_call_failure:${String(tc.created_at ?? 'unknown')}`,
161
+ note: sanitizeString(note.slice(0, MAX_EVIDENCE_NOTE_CHARS), workspaceDir),
162
+ });
173
163
  }
174
-
175
- // If no evidence at all from trajectory, provide a meaningful placeholder
164
+ } catch {
165
+ readFailed = true;
166
+ // tool_calls table may not exist — degrade gracefully (only when no other evidence)
176
167
  if (evidence.length === 0) {
177
168
  evidence.push({
178
- sourceRef: 'trajectory:empty',
179
- note: 'trajectory_available_but_empty: no user correction or assistant turns found',
169
+ sourceRef: 'tool_call_failure:unavailable',
170
+ note: 'trajectory_tool_calls_unavailable',
180
171
  });
181
172
  }
173
+ }
174
+
175
+ // If no evidence at all from trajectory, provide a meaningful placeholder
176
+ if (evidence.length === 0) {
177
+ evidence.push({
178
+ sourceRef: 'trajectory:empty',
179
+ note: 'trajectory_available_but_empty: no user correction or assistant turns found',
180
+ });
181
+ }
182
+
183
+ const bounded = evidence.slice(0, MAX_EVIDENCE_ENTRIES);
184
+ const realEntryCount = bounded.reduce(
185
+ (count, entry) => count + (PLACEHOLDER_SOURCE_REFS.has(entry.sourceRef) ? 0 : 1),
186
+ 0,
187
+ );
188
+ return { entries: bounded, realEntryCount, readFailed };
189
+ }
190
+
191
+ /**
192
+ * PRI-642 Scope A typed acquisition from trajectory.db (SPEC §7.3).
193
+ *
194
+ * Validates that the requested session exists in the trajectory `sessions`
195
+ * table (every recorded turn/tool call upserts its session row, so an absent
196
+ * row means the session was never seen) and classifies the outcome:
197
+ * - `available` — session bound, ≥1 real behavior-trace entry;
198
+ * - `session_not_found` — session absent from the sessions table (or a
199
+ * `cli`/`unknown` sentinel — never a real session);
200
+ * - `trajectory_unavailable` — no trajectory.db in this workspace;
201
+ * - `evidence_read_failed` — the DB exists but cannot be opened/read;
202
+ * - `empty_trajectory` — real session, no usable evidence rows.
203
+ */
204
+ export function acquireTrajectoryEvidenceFromDb(
205
+ stateDir: string,
206
+ sessionId: string | undefined,
207
+ workspaceDir?: string,
208
+ ): TrajectoryEvidenceAcquisition {
209
+ if (!sessionId || sessionId === 'cli' || sessionId === 'unknown') {
210
+ return {
211
+ status: 'unavailable',
212
+ reasonCode: 'session_not_found',
213
+ detail: sessionId ? `sentinel_session_id:${sessionId}` : 'missing_session_id',
214
+ };
215
+ }
216
+
217
+ const dbPath = path.join(stateDir, 'trajectory.db');
218
+ if (!fs.existsSync(dbPath)) {
219
+ return {
220
+ status: 'unavailable',
221
+ reasonCode: 'trajectory_unavailable',
222
+ detail: 'trajectory_db_missing',
223
+ };
224
+ }
225
+
226
+ let db: Database.Database;
227
+ try {
228
+ db = new Database(dbPath, { readonly: true });
229
+ } catch (err) {
230
+ return {
231
+ status: 'unavailable',
232
+ reasonCode: 'evidence_read_failed',
233
+ detail: `trajectory_db_unreadable: ${err instanceof Error ? err.message : String(err)}`,
234
+ };
235
+ }
236
+
237
+ try {
238
+ // Session existence: rows are upserted by every turn/tool-call write, so
239
+ // an absent row means the trajectory never saw this session.
240
+ let sessionExists = false;
241
+ try {
242
+ const row = db.prepare('SELECT 1 FROM sessions WHERE session_id = ?').get(sessionId);
243
+ sessionExists = row !== undefined;
244
+ } catch {
245
+ // sessions table missing → cannot validate existence; fall through and
246
+ // let the evidence rows decide (legacy DBs may lack the table).
247
+ sessionExists = true;
248
+ }
249
+ if (!sessionExists) {
250
+ return {
251
+ status: 'unavailable',
252
+ reasonCode: 'session_not_found',
253
+ detail: 'session_not_present_in_trajectory',
254
+ };
255
+ }
182
256
 
183
- return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
257
+ const collection = collectEvidenceFromDb(db, sessionId, workspaceDir);
258
+ if (collection.realEntryCount > 0) {
259
+ return { status: 'available', entries: collection.entries };
260
+ }
261
+ if (collection.readFailed) {
262
+ return {
263
+ status: 'unavailable',
264
+ reasonCode: 'evidence_read_failed',
265
+ detail: 'trajectory_tables_unreadable',
266
+ };
267
+ }
268
+ return {
269
+ status: 'unavailable',
270
+ reasonCode: 'empty_trajectory',
271
+ detail: 'session_present_but_no_usable_evidence',
272
+ };
184
273
  } finally {
185
274
  db.close();
186
275
  }
@@ -20,7 +20,6 @@ import {
20
20
  DiagRouterRunner,
21
21
  DefaultDiagRootCauseValidator,
22
22
  DefaultDiagDistillerValidator,
23
- DisabledDiagnosticianRunner,
24
23
  type DiagnosticianRunnerLike,
25
24
  TestDoubleRuntimeAdapter,
26
25
  PDRuntimeError,
@@ -37,8 +36,8 @@ import {
37
36
  import type { PDRuntimeAdapter, OutputLanguage } from '@principles/core/runtime-v2';
38
37
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
39
38
  import { readOutputLanguageFromWorkspace } from '../config-reader.js';
40
- import { loadPdConfig, computeFlagsFromLoadResult } from '../services/pd-config-loader.js';
41
- import { isFeatureEnabled, SPLIT_PIPELINE_TOTAL_TIMEOUT_MS } from '@principles/core/runtime-v2';
39
+ import { loadPdConfig } from '../services/pd-config-loader.js';
40
+ import { SPLIT_PIPELINE_TOTAL_TIMEOUT_MS, resolveDiagnosticianCapability } from '@principles/core/runtime-v2';
42
41
  import { createHash } from 'node:crypto';
43
42
  /** Layer 0 content-hash (design §6.1); injected so diag writers can attach predecessorSummary hashes. */
44
43
  const contentHashFn = (input: string): string => createHash('sha256').update(input).digest('hex');
@@ -208,6 +207,35 @@ export async function handleDiagnoseRun(opts: DiagnoseRunOptions): Promise<void>
208
207
  return;
209
208
  }
210
209
 
210
+ // PRI-638: this is the CLI's ONLY capability disable check. It reads the
211
+ // canonical authority (internalAgents.agents.diagnostician.enabled) through
212
+ // the same resolver the runtime factory uses, so `pd diagnose` can never
213
+ // mislabel a deliberate Owner kill switch as a missing runtime, a provider
214
+ // failure or a malformed config. No runtime adapter is constructed and no
215
+ // provider is contacted on this path.
216
+ const configLoadResult = loadPdConfig(workspaceDir);
217
+ const capability = resolveDiagnosticianCapability(
218
+ configLoadResult.ok ? configLoadResult.effective : configLoadResult.defaults,
219
+ );
220
+ if (!capability.available) {
221
+ const disabledResult = {
222
+ ok: false,
223
+ status: 'failed',
224
+ reason: capability.reason,
225
+ message: capability.message,
226
+ nextAction: capability.nextAction,
227
+ };
228
+ if (opts.json) {
229
+ console.log(JSON.stringify(disabledResult, null, 2));
230
+ } else {
231
+ console.error(`error: ${capability.message}`);
232
+ console.error(`reason: ${capability.reason}`);
233
+ console.error(`nextAction: ${capability.nextAction}`);
234
+ }
235
+ process.exit(1);
236
+ return;
237
+ }
238
+
211
239
  // Resolve runtime kind. P1 fix (mirrors pain-retry.ts): pd diagnose run
212
240
  // must NOT default to test-double. Without --runtime, the split pipeline
213
241
  // would validate the test-double's stale DiagnosticianOutputV1-shaped
@@ -389,11 +417,11 @@ export async function handleDiagnoseRun(opts: DiagnoseRunOptions): Promise<void>
389
417
  const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
390
418
  const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
391
419
 
392
- // Check if split pipeline is enabled 3 serial LLM calls need more time
393
- const configLoadResult = loadPdConfig(workspaceDir);
394
- const featureFlags = computeFlagsFromLoadResult(configLoadResult);
395
- const isSplitPipeline = isFeatureEnabled(featureFlags, 'diagnostician_split_pipeline');
396
- const pipelineTimeoutMs = isSplitPipeline ? SPLIT_PIPELINE_TOTAL_TIMEOUT_MS : 300_000;
420
+ // PRI-638: implementation selection is gone. The split pipeline is the only
421
+ // Diagnostician implementation in the tree, so it always runs with its
422
+ // documented 3-stage budget; `diagnostician_split_pipeline` no longer
423
+ // selects a runner nor disables capability.
424
+ const pipelineTimeoutMs = SPLIT_PIPELINE_TOTAL_TIMEOUT_MS;
397
425
  // BUG-1 (PRI-442): extract effectiveConfig so ADR-0019 LLM rate-limit
398
426
  // degradation (isDegradationEnabled in base-peer-runner) can read the
399
427
  // diagnostician_llm_degradation feature flag. Without this, the runners
@@ -410,34 +438,29 @@ export async function handleDiagnoseRun(opts: DiagnoseRunOptions): Promise<void>
410
438
  }
411
439
  const effectiveConfig = configLoadResult.ok ? configLoadResult.effective : configLoadResult.defaults;
412
440
 
413
- let runner: DiagnosticianRunnerLike;
414
- if (isSplitPipeline) {
415
- const resolvedKind = typeof runtimeAdapter.kind === 'function' ? runtimeAdapter.kind() : runtimeKind;
416
- const perStageTimeoutMs = pipelineTimeoutMs / 3;
417
- const rootCauseRunner = new DiagRootCauseRunner(
418
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler, contentHashFn },
419
- { owner: 'pd-cli-diagnose', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
420
- );
421
- const distillerRunner = new DiagDistillerRunner(
422
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator(), contentHashFn },
423
- { owner: 'pd-cli-diagnose', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
424
- );
425
- const routerRunner = new DiagRouterRunner(
426
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer, contentHashFn },
427
- { owner: 'pd-cli-diagnose', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
428
- );
429
-
430
- runner = new SplitDiagnosticianRunner({
431
- rootCauseRunner,
432
- distillerRunner,
433
- routerRunner,
434
- stateManager,
435
- committer,
436
- perStageTimeoutMs,
437
- });
438
- } else {
439
- runner = new DisabledDiagnosticianRunner();
440
- }
441
+ const resolvedKind = typeof runtimeAdapter.kind === 'function' ? runtimeAdapter.kind() : runtimeKind;
442
+ const perStageTimeoutMs = pipelineTimeoutMs / 3;
443
+ const rootCauseRunner = new DiagRootCauseRunner(
444
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler, contentHashFn },
445
+ { owner: 'pd-cli-diagnose', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
446
+ );
447
+ const distillerRunner = new DiagDistillerRunner(
448
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator(), contentHashFn },
449
+ { owner: 'pd-cli-diagnose', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
450
+ );
451
+ const routerRunner = new DiagRouterRunner(
452
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer, contentHashFn },
453
+ { owner: 'pd-cli-diagnose', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
454
+ );
455
+
456
+ const runner: DiagnosticianRunnerLike = new SplitDiagnosticianRunner({
457
+ rootCauseRunner,
458
+ distillerRunner,
459
+ routerRunner,
460
+ stateManager,
461
+ committer,
462
+ perStageTimeoutMs,
463
+ });
441
464
 
442
465
  if (!opts.json) {
443
466
  console.log(`\nRunning diagnostician for task: ${opts.taskId}`);