@principles/pd-cli 1.115.0 → 1.117.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.
Files changed (53) hide show
  1. package/dist/commands/candidate.d.ts +23 -0
  2. package/dist/commands/candidate.d.ts.map +1 -1
  3. package/dist/commands/candidate.js +89 -3
  4. package/dist/commands/candidate.js.map +1 -1
  5. package/dist/commands/diagnose.d.ts.map +1 -1
  6. package/dist/commands/diagnose.js +153 -132
  7. package/dist/commands/diagnose.js.map +1 -1
  8. package/dist/commands/runtime-features.d.ts.map +1 -1
  9. package/dist/commands/runtime-features.js +2 -7
  10. package/dist/commands/runtime-features.js.map +1 -1
  11. package/dist/commands/runtime-internalization-integrity-repair.d.ts.map +1 -1
  12. package/dist/commands/runtime-internalization-integrity-repair.js +15 -31
  13. package/dist/commands/runtime-internalization-integrity-repair.js.map +1 -1
  14. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  15. package/dist/commands/runtime-internalization-run-once.js +246 -326
  16. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  17. package/dist/commands/runtime-recovery.d.ts.map +1 -1
  18. package/dist/commands/runtime-recovery.js +9 -8
  19. package/dist/commands/runtime-recovery.js.map +1 -1
  20. package/dist/services/__tests__/cli-output.test.d.ts +18 -0
  21. package/dist/services/__tests__/cli-output.test.d.ts.map +1 -0
  22. package/dist/services/__tests__/cli-output.test.js +103 -0
  23. package/dist/services/__tests__/cli-output.test.js.map +1 -0
  24. package/dist/services/__tests__/runtime-adapter-resolver.test.d.ts +18 -0
  25. package/dist/services/__tests__/runtime-adapter-resolver.test.d.ts.map +1 -0
  26. package/dist/services/__tests__/runtime-adapter-resolver.test.js +651 -0
  27. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -0
  28. package/dist/services/cli-output.d.ts +61 -0
  29. package/dist/services/cli-output.d.ts.map +1 -0
  30. package/dist/services/cli-output.js +72 -0
  31. package/dist/services/cli-output.js.map +1 -0
  32. package/dist/services/runtime-adapter-resolver.d.ts +105 -0
  33. package/dist/services/runtime-adapter-resolver.d.ts.map +1 -0
  34. package/dist/services/runtime-adapter-resolver.js +188 -0
  35. package/dist/services/runtime-adapter-resolver.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/commands/candidate.ts +92 -3
  38. package/src/commands/diagnose.ts +146 -138
  39. package/src/commands/runtime-features.ts +2 -6
  40. package/src/commands/runtime-internalization-integrity-repair.ts +16 -28
  41. package/src/commands/runtime-internalization-run-once.ts +242 -353
  42. package/src/commands/runtime-recovery.ts +9 -7
  43. package/src/services/__tests__/cli-output.test.ts +130 -0
  44. package/src/services/__tests__/runtime-adapter-resolver.test.ts +772 -0
  45. package/src/services/cli-output.ts +95 -0
  46. package/src/services/runtime-adapter-resolver.ts +339 -0
  47. package/tests/commands/candidate-internalization-backfill.test.ts +43 -3
  48. package/tests/commands/candidate-internalize-lineage.test.ts +521 -0
  49. package/tests/commands/candidate-internalize.test.ts +31 -5
  50. package/tests/commands/diagnose.test.ts +7 -3
  51. package/tests/commands/runtime-internalization-run-once.test.ts +11 -0
  52. package/tests/commands/runtime-recovery.test.ts +27 -4
  53. package/tests/services/rulehost-pipeline-e2e.test.ts +40 -7
@@ -0,0 +1,95 @@
1
+ /**
2
+ * PRI-432: Shared CLI output module.
3
+ *
4
+ * Extracts common output patterns from 3 CLI commands:
5
+ * - runtime-features.ts (JSON/text emit)
6
+ * - runtime-recovery.ts (dry-run/confirm conflict + JSON/text emit)
7
+ * - runtime-internalization-integrity-repair.ts (conflict + error catch + JSON/text emit)
8
+ *
9
+ * Design decisions:
10
+ * - formatText stays call-site-specific (domain layout differs per command).
11
+ * This module handles only cross-cutting output concerns.
12
+ * - Functions return exit codes; callers handle process.exit()/process.exitCode.
13
+ * This follows CLI Operator Gate rule #2 (no process.exit in shared modules).
14
+ * - JSON error shape: { ok: false, reason: string, nextAction: string }
15
+ * (matches runtime-internalization-integrity-repair.ts canonical pattern)
16
+ *
17
+ * ERR refs:
18
+ * - ERR-001 (no any): all types explicit
19
+ * - ERR-005 (no as bypass): no type casts
20
+ * - ERR-009 (fail-loud): emitError/emitFlagConflict return exit code 1
21
+ * - ERR-002 (graceful degradation with reason): all error paths include reason + nextAction
22
+ * - ERR-014 (bounded preview): JSON.stringify on known shapes only
23
+ */
24
+
25
+ // ── Types ──────────────────────────────────────────────────────────────────
26
+
27
+ export interface EmitResultOptions<T> {
28
+ /** When true, emit JSON to stdout; when false, emit text via formatText. */
29
+ json: boolean;
30
+ /** Call-site-specific text formatter (domain layout differs per command). */
31
+ formatText: (output: T) => string;
32
+ }
33
+
34
+ export interface EmitErrorOptions {
35
+ /** When true, emit JSON error to stdout; when false, emit text to stderr. */
36
+ json: boolean;
37
+ /** Next action suggestion for the user. */
38
+ nextAction: string;
39
+ }
40
+
41
+ export interface EmitFlagConflictOptions {
42
+ /** When true, emit JSON error to stdout; when false, emit text to stderr. */
43
+ json: boolean;
44
+ }
45
+
46
+ // ── Functions ──────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Emit a result object as JSON (stdout) or text (stdout via formatText).
50
+ * Does NOT set exit code — caller decides based on result status.
51
+ */
52
+ export function emitResult<T extends object>(output: T, opts: EmitResultOptions<T>): void {
53
+ if (opts.json) {
54
+ console.log(JSON.stringify(output, null, 2));
55
+ } else {
56
+ console.log(opts.formatText(output));
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Emit a dry-run/confirm flag conflict error.
62
+ * Returns exit code 1. Caller should process.exit(1) or set process.exitCode = 1.
63
+ *
64
+ * JSON shape: { ok: false, reason: string, nextAction: string }
65
+ * Text shape: console.error with message
66
+ */
67
+ export function emitFlagConflict(opts: EmitFlagConflictOptions): number {
68
+ const reason = 'Error: --dry-run and --confirm are mutually exclusive';
69
+ const nextAction = 'Specify only one of --dry-run or --confirm';
70
+
71
+ if (opts.json) {
72
+ console.log(JSON.stringify({ ok: false, reason, nextAction }, null, 2));
73
+ } else {
74
+ console.error(`${reason}. Specify one or the other.`);
75
+ }
76
+ return 1;
77
+ }
78
+
79
+ /**
80
+ * Emit a structured error from a caught exception.
81
+ * Returns exit code 1. Caller should process.exit(1) or set process.exitCode = 1.
82
+ *
83
+ * JSON shape: { ok: false, reason: string, nextAction: string }
84
+ * Text shape: console.error with "Error: <message>"
85
+ */
86
+ export function emitError(err: unknown, opts: EmitErrorOptions): number {
87
+ const reason = err instanceof Error ? err.message : String(err);
88
+
89
+ if (opts.json) {
90
+ console.log(JSON.stringify({ ok: false, reason, nextAction: opts.nextAction }, null, 2));
91
+ } else {
92
+ console.error(`Error: ${reason}`);
93
+ }
94
+ return 1;
95
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * PRI-431 Step 3: Shared runtime-adapter resolver.
3
+ *
4
+ * Extracted from duplicated `resolveRuntimeAdapter` in:
5
+ * - packages/pd-cli/src/commands/runtime-internalization-run-once.ts (L222-551)
6
+ * - packages/pd-cli/src/commands/diagnose.ts (L186-334, inline)
7
+ *
8
+ * Design decisions:
9
+ * - Test-double payloads stay call-site-specific via `testDoublePayloadBuilder` callback.
10
+ * The 7 internalization runner payloads (philosopher/scribe/artificer/...) are unique
11
+ * to run-once.ts; the diagnostician payload is unique to diagnose.ts. Unifying them
12
+ * would couple unrelated domains.
13
+ * - CLI-gate concerns (process.exit, telemetry, help text) stay at call sites.
14
+ * This resolver throws structured `ConfigResolutionError` that handlers translate.
15
+ * - Lives in pd-cli/services (NOT core) because it constructs adapters and reads
16
+ * CLI feature flags via `loadEffectiveFeatureFlags`, which is a pd-cli service.
17
+ *
18
+ * ERR refs:
19
+ * - ERR-001 (no any): all types explicit
20
+ * - ERR-005 (no as bypass): no type casts
21
+ * - ERR-009 (fail-loud): ConfigResolutionError thrown with structured fields
22
+ * - ERR-013 (Object.hasOwn): feature flag check uses Object.hasOwn
23
+ * - ERR-002 (graceful degradation with reason): structured error includes reason + nextAction
24
+ */
25
+
26
+ import {
27
+ TestDoubleRuntimeAdapter,
28
+ PiAiRuntimeAdapter,
29
+ OpenClawCliRuntimeAdapter,
30
+ L2AgentLoopAdapter,
31
+ buildL2PrincipleReaderFromLedger,
32
+ loadLedger,
33
+ isRuntimeConfigError,
34
+ validateRuntimeConfig,
35
+ } from '@principles/core/runtime-v2';
36
+ import type { PDRuntimeAdapter, PdL2ArtifactReader, RuntimeConfig, RuntimeConfigResult } from '@principles/core/runtime-v2';
37
+ import { loadEffectiveFeatureFlags } from './feature-flag-loader.js';
38
+ import { resolveRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
39
+ import type { ResolvedRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
40
+
41
+ // ── Error class ────────────────────────────────────────────────────────────
42
+
43
+ export type ConfigResolutionErrorKind =
44
+ | 'missing-fields'
45
+ | 'invalid-config'
46
+ | 'unsupported-runtime'
47
+ | 'test-double-refused';
48
+
49
+ export interface ConfigResolutionErrorDetails {
50
+ /** Required fields that are missing (for 'missing-fields' kind). */
51
+ missing?: string[];
52
+ /** Structured next action for the operator (CLI Operator Gate rule #6). */
53
+ nextAction?: string;
54
+ }
55
+
56
+ export class ConfigResolutionError extends Error {
57
+ readonly kind: ConfigResolutionErrorKind;
58
+ readonly missing?: string[];
59
+ readonly nextAction?: string;
60
+
61
+ constructor(
62
+ message: string,
63
+ kind: ConfigResolutionErrorKind,
64
+ details?: ConfigResolutionErrorDetails,
65
+ ) {
66
+ super(message);
67
+ this.name = 'ConfigResolutionError';
68
+ this.kind = kind;
69
+ this.missing = details?.missing;
70
+ this.nextAction = details?.nextAction;
71
+ }
72
+ }
73
+
74
+ // ── Options interface ──────────────────────────────────────────────────────
75
+
76
+ export interface ResolveAdapterOptions {
77
+ /** Runtime kind: 'test-double' | 'pi-ai' | 'openclaw-cli' | 'config' */
78
+ runtimeKind: string;
79
+ /** Workspace directory (for config resolution + feature flags) */
80
+ workspaceDir: string;
81
+ /** Runner kind (for L2 dreamer routing). Optional. */
82
+ runnerKind?: string;
83
+ /** CLI timeout override. Takes precedence over config timeoutMs. */
84
+ timeoutMs?: number;
85
+ /**
86
+ * Whether test-double runtime is allowed. Default: false.
87
+ * When false, 'test-double' runtimeKind throws ConfigResolutionError.
88
+ */
89
+ allowTestDouble?: boolean;
90
+ /**
91
+ * Call-site-specific test-double payload builder.
92
+ * Required when runtimeKind === 'test-double' and allowTestDouble === true.
93
+ * Receives the full opts object so the builder can access taskId, runnerKind, etc.
94
+ */
95
+ testDoublePayloadBuilder?: (opts: ResolveAdapterOptions) => PDRuntimeAdapter;
96
+ /**
97
+ * CLI overrides for pi-ai runtime (from --provider, --model, etc. flags).
98
+ * When provided, these take precedence over config values.
99
+ */
100
+ piAiOverrides?: {
101
+ provider?: string;
102
+ model?: string;
103
+ apiKeyEnv?: string;
104
+ baseUrl?: string;
105
+ maxRetries?: number;
106
+ };
107
+ /**
108
+ * CLI override for openclaw mode (from --openclaw-local / --openclaw-gateway flags).
109
+ * When provided, takes precedence over config openclawMode.
110
+ */
111
+ openclawMode?: 'local' | 'gateway';
112
+ /** PRI-419: L2 artifact reader (only used when l2_dreamer is on). */
113
+ l2ArtifactReader?: PdL2ArtifactReader;
114
+ /** PRI-419: workspace stateDir for L2 principle reader (only used when l2_dreamer is on). */
115
+ l2StateDir?: string;
116
+ /** PRI-431 Step 1d: CLI agentId override for openclaw-cli (from --agent flag). Default: 'main'. */
117
+ agentId?: string;
118
+ /**
119
+ * PRI-431 Step 1d: Whether config resolution errors are tolerated (proceed with CLI overrides alone).
120
+ * When true, pi-ai branch skips validateRuntimeConfig and uses manual missing-field check.
121
+ * Default: false (config errors throw ConfigResolutionError).
122
+ */
123
+ configOptional?: boolean;
124
+ /**
125
+ * PRI-431 Step 1d: Whether to validate that process.env[apiKeyEnv] is set before
126
+ * constructing PiAiRuntimeAdapter. Default: false (env var check stays at call site for backward compat).
127
+ */
128
+ validateApiKeyEnv?: boolean;
129
+ /**
130
+ * PRI-431 Step 1d: Callback invoked with the full resolved config object (including legacyWarnings)
131
+ * after successful config resolution. Useful for telemetry, legacyWarnings printing, etc.
132
+ * NOT called for test-double branch (no config resolution).
133
+ * Called even when config returns error IF configOptional is true.
134
+ */
135
+ onConfigResolved?: (resolved: ResolvedRuntimeFromPdConfig) => void;
136
+ }
137
+
138
+ // ── Resolver function ──────────────────────────────────────────────────────
139
+
140
+ export function resolveRuntimeAdapterFromConfig(opts: ResolveAdapterOptions): PDRuntimeAdapter {
141
+ // ── test-double branch ──────────────────────────────────────────────────
142
+ if (opts.runtimeKind === 'test-double') {
143
+ if (!opts.allowTestDouble) {
144
+ throw new ConfigResolutionError(
145
+ 'runtimeKind "test-double" requires allowTestDouble=true.',
146
+ 'test-double-refused',
147
+ { nextAction: 'Pass --allow-test-double or use a production runtime kind.' },
148
+ );
149
+ }
150
+ if (!opts.testDoublePayloadBuilder) {
151
+ throw new ConfigResolutionError(
152
+ 'runtimeKind "test-double" requires testDoublePayloadBuilder callback.',
153
+ 'missing-fields',
154
+ {
155
+ missing: ['testDoublePayloadBuilder'],
156
+ nextAction:
157
+ 'Provide a testDoublePayloadBuilder function that constructs the test-double adapter.',
158
+ },
159
+ );
160
+ }
161
+ return opts.testDoublePayloadBuilder(opts);
162
+ }
163
+
164
+ // ── Resolve config from .pd/config.yaml (for pi-ai, openclaw-cli, config) ──
165
+ const resolved = resolveRuntimeFromPdConfig(opts.workspaceDir);
166
+ const configResult: RuntimeConfigResult = resolved.result;
167
+
168
+ // PRI-431 Step 1d: invoke onConfigResolved callback (for telemetry, legacyWarnings, etc.)
169
+ opts.onConfigResolved?.(resolved);
170
+
171
+ if (isRuntimeConfigError(configResult)) {
172
+ if (!opts.configOptional) {
173
+ throw new ConfigResolutionError(
174
+ `Config resolution from .pd/config.yaml failed: ${configResult.reason}. ${configResult.message}`,
175
+ 'invalid-config',
176
+ { nextAction: configResult.nextAction },
177
+ );
178
+ }
179
+ // configOptional=true: proceed with CLI overrides alone (pi-ai branch handles missing fields)
180
+ }
181
+
182
+ // ── pi-ai branch ────────────────────────────────────────────────────────
183
+ // When runtimeKind === 'config', configResult must be a valid RuntimeConfig
184
+ // (if it were an error and !configOptional, we threw above; if configOptional,
185
+ // the caller passes an explicit runtimeKind, never 'config'). The type guard
186
+ // also narrows for TypeScript.
187
+ const isPiAi =
188
+ opts.runtimeKind === 'pi-ai' ||
189
+ (opts.runtimeKind === 'config' &&
190
+ !isRuntimeConfigError(configResult) &&
191
+ configResult.runtimeKind === 'pi-ai');
192
+ if (isPiAi) {
193
+ // PRI-431 Step 1d + PR review fix: validateRuntimeConfig is only called when
194
+ // config is valid AND configOptional is false. When configOptional=true (diagnose.ts),
195
+ // skip validateRuntimeConfig to match the original diagnose.ts behavior (which never
196
+ // called it — only did a manual missing-field check on merged values). This avoids
197
+ // a behavior change where validateRuntimeConfig would reject configs missing baseUrl
198
+ // for non-built-in providers, even when the user passes --baseUrl on the CLI.
199
+ if (!opts.configOptional && !isRuntimeConfigError(configResult)) {
200
+ try {
201
+ validateRuntimeConfig(configResult);
202
+ } catch (err) {
203
+ throw new ConfigResolutionError(
204
+ err instanceof Error ? err.message : String(err),
205
+ 'invalid-config',
206
+ );
207
+ }
208
+ }
209
+
210
+ // Merge CLI overrides with config values (overrides take precedence)
211
+ // When configOptional and config failed, configResult fields are unavailable — use empty object
212
+ const configFields: Partial<RuntimeConfig> = isRuntimeConfigError(configResult) ? {} : configResult;
213
+ const provider = opts.piAiOverrides?.provider ?? configFields.provider;
214
+ const model = opts.piAiOverrides?.model ?? configFields.model;
215
+ const apiKeyEnv = opts.piAiOverrides?.apiKeyEnv ?? configFields.apiKeyEnv;
216
+ const baseUrl = opts.piAiOverrides?.baseUrl ?? configFields.baseUrl;
217
+ const maxRetries = opts.piAiOverrides?.maxRetries ?? configFields.maxRetries;
218
+ const adapterTimeoutMs = opts.timeoutMs ?? configFields.timeoutMs;
219
+
220
+ // PRI-431 Step 1d + PR review fix: manual missing-field check on MERGED values
221
+ // when configOptional=true. The original diagnose.ts always did this check on merged
222
+ // values (not just when config failed), so we replicate that here. This covers both
223
+ // the "config failed" case and the "config valid but missing fields not in config" case.
224
+ if (opts.configOptional) {
225
+ const missing: string[] = [];
226
+ if (!provider) missing.push('provider');
227
+ if (!model) missing.push('model');
228
+ if (!apiKeyEnv) missing.push('apiKeyEnv');
229
+ if (missing.length > 0) {
230
+ throw new ConfigResolutionError(
231
+ `Missing required pi-ai config: ${missing.join(', ')}.`,
232
+ 'missing-fields',
233
+ {
234
+ missing,
235
+ nextAction:
236
+ 'Pass --provider, --model, --apiKeyEnv flags or configure .pd/config.yaml runtime profile.',
237
+ },
238
+ );
239
+ }
240
+ }
241
+
242
+ // PRI-431 Step 1d: validate API key env var when requested
243
+ if (opts.validateApiKeyEnv && apiKeyEnv && !process.env[apiKeyEnv]) {
244
+ throw new ConfigResolutionError(
245
+ `Environment variable '${apiKeyEnv}' is not set.`,
246
+ 'invalid-config',
247
+ {
248
+ nextAction:
249
+ 'Set the env var (e.g., export OPENAI_API_KEY=...) or choose a different apiKeyEnv.',
250
+ },
251
+ );
252
+ }
253
+
254
+ // PRI-419: L2 dreamer sub-branch — when l2_dreamer flag is on AND this is the
255
+ // dreamer runner, route through the L2 multi-turn agent loop adapter.
256
+ // Other runners (philosopher/scribe/...) stay on L1.
257
+ if (opts.runnerKind === 'dreamer' && opts.l2ArtifactReader && opts.l2StateDir) {
258
+ const effectiveFlags = loadEffectiveFeatureFlags(opts.workspaceDir);
259
+ const l2Flag = Object.hasOwn(effectiveFlags.flags, 'l2_dreamer')
260
+ ? effectiveFlags.flags.l2_dreamer.enabled
261
+ : false;
262
+ if (l2Flag) {
263
+ return new L2AgentLoopAdapter(
264
+ {
265
+ provider: String(provider),
266
+ model: String(model),
267
+ apiKeyEnv: String(apiKeyEnv),
268
+ baseUrl,
269
+ workspace: opts.workspaceDir,
270
+ totalBudgetMs: adapterTimeoutMs,
271
+ },
272
+ {
273
+ artifactReader: opts.l2ArtifactReader,
274
+ principleReader: buildL2PrincipleReaderFromLedger(loadLedger(opts.l2StateDir)),
275
+ },
276
+ );
277
+ }
278
+ }
279
+
280
+ return new PiAiRuntimeAdapter({
281
+ provider: String(provider),
282
+ model: String(model),
283
+ apiKeyEnv: String(apiKeyEnv),
284
+ maxRetries,
285
+ timeoutMs: adapterTimeoutMs,
286
+ baseUrl,
287
+ workspace: opts.workspaceDir,
288
+ });
289
+ }
290
+
291
+ // ── openclaw-cli branch ─────────────────────────────────────────────────
292
+ const isOpenClawCli =
293
+ opts.runtimeKind === 'openclaw-cli' ||
294
+ (opts.runtimeKind === 'config' &&
295
+ !isRuntimeConfigError(configResult) &&
296
+ configResult.runtimeKind === 'openclaw-cli');
297
+ if (isOpenClawCli) {
298
+ // PRI-431 Step 1d: handle configOptional — when config failed, configFields is empty
299
+ const openclawConfigFields: Partial<RuntimeConfig> = isRuntimeConfigError(configResult)
300
+ ? {}
301
+ : configResult;
302
+ // CLI override takes precedence over config
303
+ const openclawMode = opts.openclawMode ?? openclawConfigFields.openclawMode;
304
+ if (!openclawMode) {
305
+ throw new ConfigResolutionError(
306
+ "runtimeKind 'openclaw-cli' requires openclawMode.",
307
+ 'missing-fields',
308
+ {
309
+ missing: ['openclawMode'],
310
+ nextAction:
311
+ 'Add openclawMode: local|gateway to your .pd/config.yaml runtime profile or use --openclaw-local/--openclaw-gateway flags.',
312
+ },
313
+ );
314
+ }
315
+ // PRI-431 Step 1d: only pass agentId when explicitly provided (backward compat with run-once.ts)
316
+ const openclawAdapterOpts: {
317
+ runtimeMode: 'local' | 'gateway';
318
+ workspaceDir: string;
319
+ agentId?: string;
320
+ } = {
321
+ runtimeMode: openclawMode,
322
+ workspaceDir: opts.workspaceDir,
323
+ };
324
+ if (opts.agentId !== undefined) {
325
+ openclawAdapterOpts.agentId = opts.agentId;
326
+ }
327
+ return new OpenClawCliRuntimeAdapter(openclawAdapterOpts);
328
+ }
329
+
330
+ // ── Unsupported runtime ────────────────────────────────────────────────
331
+ throw new Error(
332
+ `Unsupported runtime kind: ${opts.runtimeKind}. Supported: test-double, pi-ai, openclaw-cli, config`,
333
+ );
334
+ }
335
+
336
+ // ── Re-exports for call-site convenience ───────────────────────────────────
337
+ // These allow call sites to import everything from one module.
338
+ export type { RuntimeConfig, RuntimeConfigResult };
339
+ export { TestDoubleRuntimeAdapter, isRuntimeConfigError, validateRuntimeConfig };
@@ -134,11 +134,25 @@ function setupDefaultMocks(): void {
134
134
  mockGetCandidate.mockImplementation((id: string) =>
135
135
  Promise.resolve({
136
136
  candidateId: id,
137
+ taskId: `diag-task-${id}`,
137
138
  description: `candidate ${id}`,
138
139
  sourceRecommendationJson: JSON.stringify({ kind: 'principle', description: `candidate ${id}` }),
139
140
  }),
140
141
  );
141
- mockGetTask.mockResolvedValue(null);
142
+ // PRI-435: getTask must return a diagnostician task with sourcePainId when called
143
+ // with candidate.taskId (diag-task-*), and null for dreamer task lookups.
144
+ mockGetTask.mockImplementation((taskId: string) => {
145
+ if (taskId.startsWith('diag-task-')) {
146
+ return Promise.resolve({
147
+ taskId,
148
+ taskKind: 'diagnostician',
149
+ status: 'completed',
150
+ diagnosticJson: JSON.stringify({ sourcePainId: `pain-${taskId}` }),
151
+ });
152
+ }
153
+ // Dreamer task lookup → null (no existing dreamer task)
154
+ return Promise.resolve(null);
155
+ });
142
156
  mockCreateTask.mockImplementation((input: { taskId: string }) =>
143
157
  Promise.resolve({ taskId: input.taskId }),
144
158
  );
@@ -260,7 +274,20 @@ describe('pd candidate internalization backfill', () => {
260
274
  if (sql.includes("'pending'")) return [{ candidate_id: 'cand-pending-1' }];
261
275
  return [];
262
276
  });
263
- mockGetTask.mockResolvedValue({ taskId: 'dreamer-cand-pending-1-prompt' });
277
+ // PRI-435: getTask returns diagnostician task for diag-task-* lookups,
278
+ // and dreamer task for dreamer-cand-* lookups (testing idempotency).
279
+ mockGetTask.mockImplementation((taskId: string) => {
280
+ if (taskId.startsWith('diag-task-')) {
281
+ return Promise.resolve({
282
+ taskId,
283
+ taskKind: 'diagnostician',
284
+ status: 'completed',
285
+ diagnosticJson: JSON.stringify({ sourcePainId: `pain-${taskId}` }),
286
+ });
287
+ }
288
+ // Dreamer task already exists → idempotency check
289
+ return Promise.resolve({ taskId: 'dreamer-cand-pending-1-prompt' });
290
+ });
264
291
 
265
292
  await handleCandidateInternalizationBackfill({ workspace: WS, includePending: true, confirm: true, json: true });
266
293
 
@@ -396,11 +423,24 @@ describe('Commander wiring for backfill --include-pending', () => {
396
423
  mockGetCandidate.mockImplementation((id: string) =>
397
424
  Promise.resolve({
398
425
  candidateId: id,
426
+ taskId: `diag-task-${id}`,
399
427
  description: `candidate ${id}`,
400
428
  sourceRecommendationJson: JSON.stringify({ kind: 'principle', description: `candidate ${id}` }),
401
429
  }),
402
430
  );
403
- mockGetTask.mockResolvedValue(null);
431
+ // PRI-435: getTask returns diagnostician task with sourcePainId for diag-task-* lookups,
432
+ // null for dreamer task lookups (no existing dreamer task).
433
+ mockGetTask.mockImplementation((taskId: string) => {
434
+ if (taskId.startsWith('diag-task-')) {
435
+ return Promise.resolve({
436
+ taskId,
437
+ taskKind: 'diagnostician',
438
+ status: 'completed',
439
+ diagnosticJson: JSON.stringify({ sourcePainId: `pain-${taskId}` }),
440
+ });
441
+ }
442
+ return Promise.resolve(null);
443
+ });
404
444
  });
405
445
 
406
446
  afterEach(() => {