@principles/pd-cli 1.111.0 → 1.113.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 (28) hide show
  1. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.d.ts +24 -0
  2. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.d.ts.map +1 -0
  3. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js +223 -0
  4. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js.map +1 -0
  5. package/dist/commands/runtime-internalization-run-rulehost.d.ts +23 -0
  6. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -0
  7. package/dist/commands/runtime-internalization-run-rulehost.js +364 -0
  8. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -0
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/services/demo-rule-compiler.d.ts +24 -0
  12. package/dist/services/demo-rule-compiler.d.ts.map +1 -0
  13. package/dist/services/demo-rule-compiler.js +53 -0
  14. package/dist/services/demo-rule-compiler.js.map +1 -0
  15. package/dist/services/rulehost-pipeline-runner.d.ts +124 -0
  16. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -0
  17. package/dist/services/rulehost-pipeline-runner.js +334 -0
  18. package/dist/services/rulehost-pipeline-runner.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/commands/__tests__/run-rulehost-flag-wiring.test.ts +280 -0
  21. package/src/commands/runtime-internalization-run-rulehost.ts +417 -0
  22. package/src/index.ts +3 -0
  23. package/src/services/demo-rule-compiler.ts +71 -0
  24. package/src/services/rulehost-pipeline-runner.ts +585 -0
  25. package/tests/commands/diagnose.test.ts +178 -1
  26. package/tests/services/resolve-runtime-from-pd-config.test.ts +59 -0
  27. package/tests/services/rulehost-pipeline-e2e.test.ts +477 -0
  28. package/tests/services/rulehost-pipeline-runner.test.ts +519 -0
@@ -0,0 +1,585 @@
1
+ /**
2
+ * RuleHost Pipeline Runner — full-chain internalization driver (PRI-429).
3
+ *
4
+ * Drives a pain signal all the way to a validated rule artifact in ONE call:
5
+ * pain → dreamer → philosopher → scribe → artificer↔evaluator adversarial loop
6
+ *
7
+ * This is the "last mile" wiring that makes `runAdversarialLoop` reachable
8
+ * from a real host entry point (the PRI-428 pr-review finding: the loop had no
9
+ * production caller). Operators invoke it via
10
+ * `pd runtime internalization run-rulehost --pain-id <id>`
11
+ *
12
+ * Design:
13
+ * - Does NOT reuse the lease-based InternalizationOrchestrator. Instead it
14
+ * chains runners directly (mirrors full-chain-real-llm.test.ts), because
15
+ * the lease model is single-step + successor-proposal and cannot host a
16
+ * synchronous multi-round loop.
17
+ * - The artificer↔evaluator stage delegates to `runAdversarialLoop` (PRI-428),
18
+ * which never throws and degrades to { decision: 'rejected' } with a reason.
19
+ * - gateDeps (the sandbox adapter) is built from `compileDemoRule` +
20
+ * `evaluateInRefinerSandbox`. Per the Explore finding, `compileDemoRule` is
21
+ * byte-equivalent to openclaw-plugin's `loadRuleImplementationModule`; the
22
+ * duplication is a package-boundary necessity (pd-cli cannot import
23
+ * openclaw-plugin), not a capability gap.
24
+ *
25
+ * @see docs/plans/rulehost-mvp-activation.md
26
+ * @see runAdversarialLoop in @principles/core/runtime-v2
27
+ */
28
+ import {
29
+ RuntimeStateManager,
30
+ StoreEventEmitter,
31
+ DreamerRunner,
32
+ DefaultDreamerValidator,
33
+ PhilosopherRunner,
34
+ DefaultPhilosopherValidator,
35
+ ScribeRunner,
36
+ DefaultScribeValidator,
37
+ ArtificerRunner,
38
+ DefaultArtificerValidator,
39
+ EvaluatorRunner,
40
+ DefaultEvaluatorValidator,
41
+ createPITaskDiagnosticJson,
42
+ runAdversarialLoop,
43
+ evaluateInRefinerSandbox,
44
+ DEFAULT_MAX_ROUNDS,
45
+ } from '@principles/core/runtime-v2';
46
+ import type {
47
+ AdversarialLoopResult,
48
+ PDRuntimeAdapter,
49
+ PeerRunnerResult,
50
+ RefinerRuleHostGateDeps,
51
+ PIArtifactStore,
52
+ } from '@principles/core/runtime-v2';
53
+ /* eslint-disable @typescript-eslint/no-use-before-define -- helpers declared after main, matching codebase convention */
54
+ import { compileDemoRule } from './demo-rule-compiler.js';
55
+
56
+ // ── Types ────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Code-rule capability (atomic: ArtificerL2 + Evaluator).
60
+ *
61
+ * Per the user correction (2026-06-18): ArtificerL2 and Evaluator are atomic —
62
+ * both must run or neither runs. When enabled, the adversarial loop runs with
63
+ * the artificerAdapter (L2 write-test-fix). When disabled or not provided, the
64
+ * pipeline degrades to text-principle-only.
65
+ *
66
+ * The caller (CLI handler) resolves per-agent config (enabled/runtimeProfile)
67
+ * and constructs the ArtificerL2Adapter when both artificer + evaluator are
68
+ * enabled. The pipeline runner branches on `enabled` — it does NOT do config
69
+ * resolution itself (separation of concerns: service vs I/O).
70
+ */
71
+ export interface CodeRuleCapability {
72
+ /** Whether the capability is enabled (atomic: both artificer + evaluator enabled in config). */
73
+ readonly enabled: boolean;
74
+ /**
75
+ * The ArtificerL2Adapter (or test-double) for the artificer stage. Required
76
+ * when enabled. When enabled, this adapter replaces the base runtimeAdapter
77
+ * for the ArtificerRunner only; EvaluatorRunner still uses the base adapter.
78
+ * Ignored when disabled (may be omitted or set to the base adapter).
79
+ */
80
+ readonly artificerAdapter?: PDRuntimeAdapter;
81
+ /** Structured reason when disabled (for degradation reporting). Required when disabled. */
82
+ readonly disabledReason?: string;
83
+ }
84
+
85
+ export interface RuleHostAgentAdapters {
86
+ readonly dreamer: PDRuntimeAdapter;
87
+ readonly philosopher: PDRuntimeAdapter;
88
+ readonly scribe: PDRuntimeAdapter;
89
+ readonly evaluator: PDRuntimeAdapter;
90
+ }
91
+
92
+ export interface RuleHostPipelineOptions {
93
+ /** Workspace directory containing .state/ (SQLite stores). */
94
+ readonly workspaceDir: string;
95
+ /** Pain ID whose internalization chain to drive. */
96
+ readonly painId: string;
97
+ /**
98
+ * Runtime adapter (LLM). Caller constructs it (PiAi / test-double) so the
99
+ * service stays runtime-agnostic. Mirrors full-chain-real-llm.test.ts.
100
+ *
101
+ * Used for: dreamer, philosopher, scribe, and evaluator (when capability ON).
102
+ * The artificer stage uses codeRuleCapability.artificerAdapter when enabled.
103
+ */
104
+ readonly runtimeAdapter: PDRuntimeAdapter;
105
+ /** Production per-agent adapters. Tests may omit this to use runtimeAdapter for every stage. */
106
+ readonly agentAdapters?: RuleHostAgentAdapters;
107
+ /**
108
+ * Code-rule capability (atomic: ArtificerL2 + Evaluator). When omitted, the
109
+ * capability is treated as OFF with reason 'code_rule_capability not provided'.
110
+ */
111
+ readonly codeRuleCapability?: CodeRuleCapability;
112
+ /** Internalization channel for created tasks (default 'code_tool_hook'). */
113
+ readonly channel?: 'prompt' | 'code_tool_hook' | 'defer_archive';
114
+ /** Max adversarial rounds (PRD cap = 2). */
115
+ readonly maxRounds?: number;
116
+ /** Per-LLM-call timeout (default 300_000). */
117
+ readonly timeoutMs?: number;
118
+ /** Runner poll interval (default 100). */
119
+ readonly pollIntervalMs?: number;
120
+ /**
121
+ * Max stage retry attempts for transient `retried` status (default 2).
122
+ * Each retry gets fresh state from the state manager (Runtime Contract Rule 7).
123
+ * When exhausted, the stage is marked 'degraded' with a structured reason.
124
+ */
125
+ readonly maxStageRetries?: number;
126
+ /** Correlation prefix for task IDs. */
127
+ readonly correlationId?: string;
128
+ /** Progress callback (stage start/complete). Optional. */
129
+ readonly onProgress?: (stage: string, status: 'start' | 'succeeded' | 'failed' | 'degraded' | 'skipped', detail?: string) => void;
130
+ /**
131
+ * Called once after the internal RuntimeStateManager + artifactStore are
132
+ * constructed, so the caller (e.g. a test-double adapter) can wire the store
133
+ * for artifactId resolution. Optional.
134
+ */
135
+ readonly onStoreReady?: (store: PIArtifactStore) => void;
136
+ }
137
+
138
+ export type RuleHostPipelineStageStatus = 'succeeded' | 'failed' | 'skipped' | 'degraded';
139
+
140
+ export interface RuleHostPipelineStage {
141
+ readonly name: 'pain_lookup' | 'dreamer' | 'philosopher' | 'scribe' | 'adversarial_loop';
142
+ readonly status: RuleHostPipelineStageStatus;
143
+ readonly taskId?: string;
144
+ readonly reason?: string;
145
+ }
146
+
147
+ /**
148
+ * Final pipeline decision.
149
+ *
150
+ * - `candidate_ready_for_owner_review`: adversarial loop approved. A validated
151
+ * rule artifact exists and is WAITING for owner review. This is NOT owner
152
+ * approval — it means the candidate is ready for the owner to review.
153
+ * - `text_principle_only`: code-rule capability OFF (artificer or evaluator
154
+ * disabled). No rule artifact; a text principle artifact is produced for
155
+ * prompt-channel fallback.
156
+ * - `generation_rejected`: pipeline failed (no dreamer task, stage failure, or
157
+ * evaluator rejected the candidate). No rule artifact.
158
+ */
159
+ export interface RuleHostPipelineResult {
160
+ readonly decision: 'candidate_ready_for_owner_review' | 'text_principle_only' | 'generation_rejected';
161
+ readonly painId: string;
162
+ readonly stages: RuleHostPipelineStage[];
163
+ /** Scribe task that fed the adversarial loop (or text-principle path). */
164
+ readonly scribeTaskId: string | null;
165
+ /** From the adversarial loop result, when reached. */
166
+ readonly adversarialLoop?: AdversarialLoopResult;
167
+ /** Rule artifact ID when candidate_ready_for_owner_review; null otherwise. */
168
+ readonly ruleArtifactId: string | null;
169
+ /** Principle artifact ID (always present when scribe ran). */
170
+ readonly principleArtifactId: string | null;
171
+ /** Structured reason when decision is not candidate_ready_for_owner_review. */
172
+ readonly degradationReason?: string;
173
+ }
174
+
175
+ // ── gateDeps builder ─────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Build the sandbox gate deps for adversarial replay.
179
+ *
180
+ * `compileDemoRule` is byte-equivalent to openclaw-plugin's
181
+ * `loadRuleImplementationModule` (same vm.createContext, same normalize, same
182
+ * evaluate-shape check). pd-cli cannot import openclaw-plugin (package
183
+ * boundary), so this duplicate is intentional and capability-identical, not a
184
+ * stripped demo. See demo-rule-compiler.ts header comment.
185
+ */
186
+ export function createSandboxGateDeps(): RefinerRuleHostGateDeps {
187
+ return {
188
+ evaluateInSandbox: (code, goldenTrace, opts) => {
189
+ // compileDemoRule throws on bad code. The throw propagates out of
190
+ // evaluateInSandbox; EvaluatorRunner (and runAdversarialLoop's
191
+ // evaluator_run_threw catch) classifies it as a sandbox failure, so it
192
+ // surfaces as a rejected round rather than an uncaught exception.
193
+ // evaluateInRefinerSandbox separately catches its own runtime throws
194
+ // (from executing the evaluate function) and classifies them as
195
+ // validation_failed / runtime_error.
196
+ const evaluateCode = compileDemoRule(code, 'rulehost-pipeline');
197
+ return evaluateInRefinerSandbox(code, goldenTrace, { evaluateCode, ...opts });
198
+ },
199
+ };
200
+ }
201
+
202
+ // ── Pipeline ─────────────────────────────────────────────────────────────────
203
+
204
+ const DEFAULT_TIMEOUT_MS = 300_000;
205
+ const DEFAULT_POLL_MS = 100;
206
+ const DEFAULT_MAX_STAGE_RETRIES = 2;
207
+
208
+ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promise<RuleHostPipelineResult> {
209
+ const channel = opts.channel ?? 'code_tool_hook';
210
+ const maxRounds = opts.maxRounds ?? DEFAULT_MAX_ROUNDS;
211
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
212
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS;
213
+ const maxStageRetries = opts.maxStageRetries ?? DEFAULT_MAX_STAGE_RETRIES;
214
+ const correlation = opts.correlationId ?? `rulehost-${opts.painId}`;
215
+ const onProgress = opts.onProgress ?? (() => { /* noop */ });
216
+
217
+ // ── Resolve code-rule capability (atomic: ArtificerL2 + Evaluator) ──
218
+ // Per user correction (2026-06-18): both must be enabled or neither runs.
219
+ // When OFF, degrade to text-principle-only after scribe. When ON, run the
220
+ // adversarial loop with the artificerAdapter (L2 write-test-fix).
221
+ const capability = opts.codeRuleCapability;
222
+ const capabilityEnabled = capability?.enabled === true;
223
+ const capabilityDisabledReason = capability?.enabled === false
224
+ ? (capability.disabledReason ?? 'code_rule_capability is disabled')
225
+ : 'code_rule_capability not provided — default OFF (set codeRuleCapability.enabled=true when both artificer + evaluator agents are enabled)';
226
+
227
+ const stages: RuleHostPipelineStage[] = [];
228
+ const stateManager = new RuntimeStateManager({ workspaceDir: opts.workspaceDir });
229
+ await stateManager.initialize();
230
+
231
+ try {
232
+ const artifactStore = stateManager.piArtifactStore;
233
+ const eventEmitter = new StoreEventEmitter();
234
+ // Allow the caller's adapter to resolve real artifactIds (needed by
235
+ // test-double adapters whose scripted outputs must match store-assigned IDs).
236
+ opts.onStoreReady?.(artifactStore);
237
+ const owner = 'rulehost-pipeline';
238
+ const agentAdapters = opts.agentAdapters ?? {
239
+ dreamer: opts.runtimeAdapter,
240
+ philosopher: opts.runtimeAdapter,
241
+ scribe: opts.runtimeAdapter,
242
+ evaluator: opts.runtimeAdapter,
243
+ };
244
+ const runnerOptsFor = (adapter: PDRuntimeAdapter) => ({ owner, runtimeKind: adapter.kind(), pollIntervalMs, timeoutMs });
245
+
246
+ // ── Stage: pain lookup ──
247
+ // Find a dreamer task already seeded for this pain (the pain→dreamer bridge
248
+ // runs via `pd pain record` → PainSignalBridge.onPainDetected). We need the
249
+ // dreamer task to start the chain. If none exists, the operator must run
250
+ // `pd pain record` first — fail loud with guidance.
251
+ //
252
+ // D fix (PRI-429): exact sourcePainId match via Object.hasOwn on parsed
253
+ // diagnosticJson. No substring matching (pain-1 must NOT match pain-10).
254
+ onProgress('pain_lookup', 'start', `painId=${opts.painId}`);
255
+ const dreamerLookup = await findDreamerTaskForPain(stateManager, opts.painId);
256
+ if (dreamerLookup.status === 'ambiguous') {
257
+ const reason = `ambiguous_dreamer_tasks_for_pain: ${dreamerLookup.taskIds.join(',')}`;
258
+ stages.push({ name: 'pain_lookup', status: 'failed', reason });
259
+ onProgress('pain_lookup', 'failed', reason);
260
+ return rejectedResult(opts.painId, stages, reason);
261
+ }
262
+ if (dreamerLookup.status === 'not_found') {
263
+ stages.push({ name: 'pain_lookup', status: 'failed', reason: 'no_dreamer_task_seeded_for_pain' });
264
+ onProgress('pain_lookup', 'failed', 'no dreamer task seeded');
265
+ return rejectedResult(opts.painId, stages, 'no_dreamer_task_seeded_for_pain: run `pd pain record` first');
266
+ }
267
+ const dreamerSeedTaskId = dreamerLookup.taskId;
268
+ stages.push({ name: 'pain_lookup', status: 'succeeded', taskId: dreamerSeedTaskId });
269
+ onProgress('pain_lookup', 'succeeded', `dreamerTaskId=${dreamerSeedTaskId}`);
270
+
271
+ // ── Stage: dreamer ──
272
+ onProgress('dreamer', 'start');
273
+ const dreamerRunner = new DreamerRunner(
274
+ { stateManager, runtimeAdapter: agentAdapters.dreamer, eventEmitter, validator: new DefaultDreamerValidator(), artifactStore },
275
+ runnerOptsFor(agentAdapters.dreamer),
276
+ );
277
+ const dreamerResult = await runStage(dreamerRunner, dreamerSeedTaskId, { maxStageRetries, pollIntervalMs });
278
+ stages.push(stageFromResult('dreamer', dreamerSeedTaskId, dreamerResult));
279
+ if (dreamerResult.status !== 'succeeded') {
280
+ onProgress('dreamer', 'failed', dreamerResult.failureReason);
281
+ return rejectedResult(opts.painId, stages, `dreamer_failed: ${dreamerResult.failureReason ?? dreamerResult.status}`);
282
+ }
283
+ onProgress('dreamer', 'succeeded');
284
+
285
+ // ── Stage: philosopher ──
286
+ onProgress('philosopher', 'start');
287
+ const philosopherTaskId = `${correlation}-philosopher-${Date.now().toString(36)}`;
288
+ await createInternalizationTask(stateManager, philosopherTaskId, 'philosopher', [dreamerSeedTaskId], channel, timeoutMs);
289
+ const philosopherRunner = new PhilosopherRunner(
290
+ { stateManager, runtimeAdapter: agentAdapters.philosopher, eventEmitter, validator: new DefaultPhilosopherValidator(), artifactStore },
291
+ runnerOptsFor(agentAdapters.philosopher),
292
+ );
293
+ const philosopherResult = await runStage(philosopherRunner, philosopherTaskId, { maxStageRetries, pollIntervalMs });
294
+ stages.push(stageFromResult('philosopher', philosopherTaskId, philosopherResult));
295
+ if (philosopherResult.status !== 'succeeded') {
296
+ onProgress('philosopher', 'failed', philosopherResult.failureReason);
297
+ return rejectedResult(opts.painId, stages, `philosopher_failed: ${philosopherResult.failureReason ?? philosopherResult.status}`);
298
+ }
299
+ onProgress('philosopher', 'succeeded');
300
+
301
+ // ── Stage: scribe ──
302
+ onProgress('scribe', 'start');
303
+ const scribeTaskId = `${correlation}-scribe-${Date.now().toString(36)}`;
304
+ await createInternalizationTask(stateManager, scribeTaskId, 'scribe', [philosopherTaskId], channel, timeoutMs);
305
+ const scribeRunner = new ScribeRunner(
306
+ { stateManager, runtimeAdapter: agentAdapters.scribe, eventEmitter, validator: new DefaultScribeValidator(), artifactStore },
307
+ runnerOptsFor(agentAdapters.scribe),
308
+ );
309
+ const scribeResult = await runStage(scribeRunner, scribeTaskId, { maxStageRetries, pollIntervalMs });
310
+ stages.push(stageFromResult('scribe', scribeTaskId, scribeResult));
311
+ if (scribeResult.status !== 'succeeded') {
312
+ onProgress('scribe', 'failed', scribeResult.failureReason);
313
+ return rejectedResult(opts.painId, stages, `scribe_failed: ${scribeResult.failureReason ?? scribeResult.status}`);
314
+ }
315
+ onProgress('scribe', 'succeeded');
316
+
317
+ // ── Atomic capability branching ──
318
+ // Per user correction (2026-06-18): ArtificerL2 + Evaluator are atomic.
319
+ // When OFF (or not provided), skip the adversarial loop entirely and
320
+ // degrade to text-principle-only. The scribe's principle artifact remains
321
+ // for prompt-channel fallback.
322
+ if (!capabilityEnabled) {
323
+ stages.push({ name: 'adversarial_loop', status: 'skipped', reason: capabilityDisabledReason });
324
+ onProgress('adversarial_loop', 'skipped', capabilityDisabledReason);
325
+ return await textPrincipleOnlyResult({ painId: opts.painId, stages, scribeTaskId, disabledReason: capabilityDisabledReason, artifactStore });
326
+ }
327
+
328
+ // ── Stage: adversarial loop (artificer↔evaluator) ──
329
+ // Capability ON: use the artificerAdapter (L2 write-test-fix) for the
330
+ // artificer stage, and the base runtimeAdapter for the evaluator stage.
331
+ if (!capability?.artificerAdapter) {
332
+ // Contract violation: enabled but no adapter provided. Fail loud.
333
+ stages.push({ name: 'adversarial_loop', status: 'failed', reason: 'artificerAdapter not provided' });
334
+ onProgress('adversarial_loop', 'failed', 'artificerAdapter not provided');
335
+ return rejectedResult(opts.painId, stages, 'code_rule_capability enabled but artificerAdapter not provided');
336
+ }
337
+ onProgress('adversarial_loop', 'start');
338
+ const artificerRunner = new ArtificerRunner(
339
+ { stateManager, runtimeAdapter: capability.artificerAdapter, eventEmitter, validator: new DefaultArtificerValidator(), artifactStore },
340
+ runnerOptsFor(capability.artificerAdapter),
341
+ );
342
+ const evaluatorRunner = new EvaluatorRunner(
343
+ { stateManager, runtimeAdapter: agentAdapters.evaluator, eventEmitter, validator: new DefaultEvaluatorValidator(), artifactStore },
344
+ { ...runnerOptsFor(agentAdapters.evaluator), gateDeps: createSandboxGateDeps() },
345
+ );
346
+
347
+ const loopResult = await runAdversarialLoop({
348
+ artificerRunner,
349
+ evaluatorRunner,
350
+ stateManager,
351
+ artifactStore,
352
+ scribeTaskId,
353
+ maxRounds,
354
+ correlationId: correlation,
355
+ channel,
356
+ });
357
+
358
+ const loopStatus: RuleHostPipelineStageStatus = loopResult.decision === 'approved' ? 'succeeded' : 'degraded';
359
+ stages.push({ name: 'adversarial_loop', status: loopStatus, taskId: loopResult.finalEvaluatorTaskId, reason: loopResult.degradationReason });
360
+ onProgress('adversarial_loop', loopResult.decision === 'approved' ? 'succeeded' : 'degraded', loopResult.degradationReason);
361
+
362
+ // Map adversarial loop decision to pipeline decision:
363
+ // 'approved' → candidate_ready_for_owner_review (NOT owner approval)
364
+ // 'rejected' → generation_rejected
365
+ const pipelineDecision = loopResult.decision === 'approved'
366
+ ? 'candidate_ready_for_owner_review' as const
367
+ : 'generation_rejected' as const;
368
+
369
+ return {
370
+ decision: pipelineDecision,
371
+ painId: opts.painId,
372
+ stages,
373
+ scribeTaskId,
374
+ adversarialLoop: loopResult,
375
+ ruleArtifactId: loopResult.ruleArtifactId,
376
+ principleArtifactId: loopResult.principleArtifactId,
377
+ degradationReason: loopResult.degradationReason,
378
+ };
379
+ } finally {
380
+ await stateManager.close();
381
+ }
382
+ }
383
+
384
+ // ── Helpers ──────────────────────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Find the dreamer task seeded for a given pain ID.
388
+ *
389
+ * D fix (PRI-429): exact sourcePainId match via Object.hasOwn on parsed
390
+ * diagnosticJson. No substring matching — 'pain-1' must NOT match 'pain-10'.
391
+ *
392
+ * The sourcePainId is stored as a top-level key in diagnosticJson (outside the
393
+ * pi_metadata envelope), mirroring the pattern in source-trace-locator.test.ts
394
+ * and PainSignalBridge. Malformed JSON is skipped (not crashed). Missing
395
+ * sourcePainId is skipped (no match).
396
+ *
397
+ * ERR refs:
398
+ * - ERR-001: parsed JSON treated as unknown
399
+ * - ERR-005/007: no `as` bypass; type narrowing via typeof + Object.hasOwn
400
+ * - ERR-013: Object.hasOwn for untrusted key checks
401
+ * - ERR-009: missing sourcePainId = no match (fail loud, not silent skip)
402
+ */
403
+ type DreamerTaskLookup =
404
+ | { readonly status: 'found'; readonly taskId: string }
405
+ | { readonly status: 'not_found' }
406
+ | { readonly status: 'ambiguous'; readonly taskIds: readonly string[] };
407
+
408
+ async function findDreamerTaskForPain(stateManager: RuntimeStateManager, painId: string): Promise<DreamerTaskLookup> {
409
+ const tasks = await stateManager.listTasks();
410
+ const dreamerTasks = tasks.filter((t) =>
411
+ t.taskKind === 'dreamer' && (t.status === 'pending' || t.status === 'retry_wait'),
412
+ );
413
+ const matches: string[] = [];
414
+ for (const t of dreamerTasks) {
415
+ if (typeof t.diagnosticJson !== 'string') continue;
416
+ let parsed: unknown;
417
+ try {
418
+ parsed = JSON.parse(t.diagnosticJson);
419
+ } catch {
420
+ // Malformed JSON — skip this task, don't crash (Runtime Contract Rule 9:
421
+ // graceful degradation with observable behavior — the task is simply not
422
+ // a match, and the caller will report no_dreamer_task_seeded_for_pain).
423
+ continue;
424
+ }
425
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
426
+ if (!Object.hasOwn(parsed, 'sourcePainId')) continue;
427
+ const stored = Reflect.get(parsed, 'sourcePainId');
428
+ if (typeof stored === 'string' && stored === painId) {
429
+ matches.push(t.taskId);
430
+ }
431
+ }
432
+ matches.sort();
433
+ if (matches.length === 0) return { status: 'not_found' };
434
+ if (matches.length > 1) return { status: 'ambiguous', taskIds: matches };
435
+ return { status: 'found', taskId: matches[0] };
436
+ }
437
+
438
+ // eslint-disable-next-line @typescript-eslint/max-params
439
+ async function createInternalizationTask(
440
+ stateManager: RuntimeStateManager,
441
+ taskId: string,
442
+ taskKind: string,
443
+ dependencyTaskIds: string[],
444
+ channel: 'prompt' | 'code_tool_hook' | 'defer_archive',
445
+ timeoutMs: number,
446
+ ): Promise<void> {
447
+ await stateManager.createTask({
448
+ taskId,
449
+ taskKind,
450
+ status: 'pending',
451
+ attemptCount: 0,
452
+ maxAttempts: 3,
453
+ diagnosticJson: createPITaskDiagnosticJson({
454
+ dependencyTaskIds,
455
+ channel,
456
+ timeoutMs,
457
+ inputArtifactRefs: [],
458
+ outputArtifactRefs: [],
459
+ }),
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Run a single pipeline stage with bounded retry for transient `retried` status.
465
+ *
466
+ * Runtime Contract Rule 7: retry/repair loops must distinguish current, next,
467
+ * and recorded state. Each iteration calls `runner.run(taskId)` fresh — the
468
+ * runner reads from the state manager which has the updated task state
469
+ * (including any `retry_wait` → `pending` transition from the previous
470
+ * iteration). No stale state is carried across iterations.
471
+ *
472
+ * `retried` is NOT terminal — it means the runner hit a transient error and
473
+ * marked the task for retry. The pipeline retries up to `maxStageRetries`
474
+ * times (default 2). When exhausted, the stage is marked 'degraded' with a
475
+ * structured reason, and the caller treats it as a non-success outcome.
476
+ */
477
+ interface RunStageOptions {
478
+ readonly maxStageRetries: number;
479
+ readonly pollIntervalMs: number;
480
+ }
481
+
482
+ async function runStage(
483
+ runner: { run(id: string): Promise<PeerRunnerResult<unknown>> },
484
+ taskId: string,
485
+ opts: RunStageOptions,
486
+ ): Promise<PeerRunnerResult<unknown>> {
487
+ let attempt = 0;
488
+ // First attempt is not a retry — it's the initial run.
489
+ let result = await runner.run(taskId);
490
+ while (result.status === 'retried' && attempt < opts.maxStageRetries) {
491
+ attempt++;
492
+ // Brief wait before retry to allow transient conditions to clear.
493
+ // The state manager has already marked the task as retry_wait; the runner
494
+ // will re-read it on the next .run() call (fresh state per iteration).
495
+ await new Promise<void>((resolve) => setTimeout(resolve, opts.pollIntervalMs));
496
+ result = await runner.run(taskId);
497
+ }
498
+ return result;
499
+ }
500
+
501
+ function stageFromResult(
502
+ name: RuleHostPipelineStage['name'],
503
+ taskId: string,
504
+ result: PeerRunnerResult<unknown>,
505
+ ): RuleHostPipelineStage {
506
+ // `retried` after exhausting retries is a transient-exhausted state, not a
507
+ // hard failure. Mark as 'degraded' so the caller can distinguish "stage
508
+ // failed permanently" from "stage exhausted transient retries".
509
+ if (result.status === 'succeeded') {
510
+ return { name, taskId, status: 'succeeded' };
511
+ }
512
+ if (result.status === 'retried') {
513
+ return {
514
+ name,
515
+ taskId,
516
+ status: 'degraded',
517
+ reason: `transient_retry_exhausted: ${result.failureReason ?? result.status}`,
518
+ };
519
+ }
520
+ return {
521
+ name,
522
+ taskId,
523
+ status: 'failed',
524
+ reason: result.failureReason ?? result.status,
525
+ };
526
+ }
527
+
528
+ function rejectedResult(painId: string, stages: RuleHostPipelineStage[], degradationReason: string): RuleHostPipelineResult {
529
+ return {
530
+ decision: 'generation_rejected',
531
+ painId,
532
+ stages,
533
+ scribeTaskId: null,
534
+ ruleArtifactId: null,
535
+ principleArtifactId: null,
536
+ degradationReason,
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Build a text-principle-only result when the code-rule capability is OFF.
542
+ *
543
+ * The scribe stage already produced a principle artifact (stored via the
544
+ * artifact store). We look it up by the scribe task ID so the caller can
545
+ * reference it for prompt-channel fallback.
546
+ */
547
+ interface TextPrincipleOnlyParams {
548
+ readonly painId: string;
549
+ readonly stages: RuleHostPipelineStage[];
550
+ readonly scribeTaskId: string;
551
+ readonly disabledReason: string;
552
+ readonly artifactStore: PIArtifactStore;
553
+ }
554
+
555
+ async function textPrincipleOnlyResult(
556
+ params: TextPrincipleOnlyParams,
557
+ ): Promise<RuleHostPipelineResult> {
558
+ const { painId, stages, scribeTaskId, disabledReason, artifactStore } = params;
559
+ // Look up the principle artifact produced by the scribe stage.
560
+ try {
561
+ const arts = await artifactStore.listBySourceTaskId(scribeTaskId);
562
+ const principleArt = arts.find((a) => a.artifactKind === 'principle');
563
+ return {
564
+ decision: 'text_principle_only',
565
+ painId,
566
+ stages,
567
+ scribeTaskId,
568
+ ruleArtifactId: null,
569
+ principleArtifactId: principleArt?.artifactId ?? null,
570
+ degradationReason: `code_rule_capability_off: ${disabledReason}`,
571
+ };
572
+ } catch (error: unknown) {
573
+ const message = error instanceof Error ? error.message : String(error);
574
+ return {
575
+ decision: 'text_principle_only',
576
+ painId,
577
+ stages,
578
+ scribeTaskId,
579
+ ruleArtifactId: null,
580
+ principleArtifactId: null,
581
+ degradationReason: `code_rule_capability_off: ${disabledReason}; principle_artifact_lookup_failed: ${message}`,
582
+ };
583
+ }
584
+
585
+ }