@principles/pd-cli 1.133.0 → 1.134.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 (211) hide show
  1. package/dist/commands/__tests__/intent-flag-wiring.test.js +74 -3
  2. package/dist/commands/__tests__/intent-flag-wiring.test.js.map +1 -1
  3. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.d.ts +2 -0
  4. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.d.ts.map +1 -0
  5. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.js +60 -0
  6. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.js.map +1 -0
  7. package/dist/commands/__tests__/rulecode-flag-wiring.test.js +11 -4
  8. package/dist/commands/__tests__/rulecode-flag-wiring.test.js.map +1 -1
  9. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js +9 -0
  10. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js.map +1 -1
  11. package/dist/commands/admission-gate.d.ts +24 -0
  12. package/dist/commands/admission-gate.d.ts.map +1 -0
  13. package/dist/commands/admission-gate.js +29 -0
  14. package/dist/commands/admission-gate.js.map +1 -0
  15. package/dist/commands/candidate.d.ts.map +1 -1
  16. package/dist/commands/candidate.js +72 -6
  17. package/dist/commands/candidate.js.map +1 -1
  18. package/dist/commands/console.d.ts.map +1 -1
  19. package/dist/commands/console.js +40 -0
  20. package/dist/commands/console.js.map +1 -1
  21. package/dist/commands/demo-story-a.d.ts.map +1 -1
  22. package/dist/commands/demo-story-a.js +4 -2
  23. package/dist/commands/demo-story-a.js.map +1 -1
  24. package/dist/commands/diagnose.d.ts.map +1 -1
  25. package/dist/commands/diagnose.js +155 -8
  26. package/dist/commands/diagnose.js.map +1 -1
  27. package/dist/commands/errors-list.d.ts +25 -0
  28. package/dist/commands/errors-list.d.ts.map +1 -0
  29. package/dist/commands/errors-list.js +368 -0
  30. package/dist/commands/errors-list.js.map +1 -0
  31. package/dist/commands/health-codex.d.ts +7 -0
  32. package/dist/commands/health-codex.d.ts.map +1 -0
  33. package/dist/commands/health-codex.js +209 -0
  34. package/dist/commands/health-codex.js.map +1 -0
  35. package/dist/commands/health.d.ts +24 -0
  36. package/dist/commands/health.d.ts.map +1 -1
  37. package/dist/commands/health.js +48 -0
  38. package/dist/commands/health.js.map +1 -1
  39. package/dist/commands/intent.d.ts +21 -4
  40. package/dist/commands/intent.d.ts.map +1 -1
  41. package/dist/commands/intent.js +88 -27
  42. package/dist/commands/intent.js.map +1 -1
  43. package/dist/commands/pain-evidence.d.ts +6 -0
  44. package/dist/commands/pain-evidence.d.ts.map +1 -1
  45. package/dist/commands/pain-evidence.js +6 -0
  46. package/dist/commands/pain-evidence.js.map +1 -1
  47. package/dist/commands/pain-record.d.ts.map +1 -1
  48. package/dist/commands/pain-record.js +21 -0
  49. package/dist/commands/pain-record.js.map +1 -1
  50. package/dist/commands/pain-retry.d.ts +4 -0
  51. package/dist/commands/pain-retry.d.ts.map +1 -1
  52. package/dist/commands/pain-retry.js +327 -32
  53. package/dist/commands/pain-retry.js.map +1 -1
  54. package/dist/commands/proven-channel-baseline.d.ts.map +1 -1
  55. package/dist/commands/proven-channel-baseline.js +4 -2
  56. package/dist/commands/proven-channel-baseline.js.map +1 -1
  57. package/dist/commands/runtime-activation.d.ts +30 -0
  58. package/dist/commands/runtime-activation.d.ts.map +1 -1
  59. package/dist/commands/runtime-activation.js +567 -11
  60. package/dist/commands/runtime-activation.js.map +1 -1
  61. package/dist/commands/runtime-canary.d.ts.map +1 -1
  62. package/dist/commands/runtime-canary.js +32 -7
  63. package/dist/commands/runtime-canary.js.map +1 -1
  64. package/dist/commands/runtime-health-snapshot.d.ts.map +1 -1
  65. package/dist/commands/runtime-health-snapshot.js +4 -2
  66. package/dist/commands/runtime-health-snapshot.js.map +1 -1
  67. package/dist/commands/runtime-init.d.ts +43 -0
  68. package/dist/commands/runtime-init.d.ts.map +1 -0
  69. package/dist/commands/runtime-init.js +264 -0
  70. package/dist/commands/runtime-init.js.map +1 -0
  71. package/dist/commands/runtime-internalization-context-trace.d.ts +25 -0
  72. package/dist/commands/runtime-internalization-context-trace.d.ts.map +1 -0
  73. package/dist/commands/runtime-internalization-context-trace.js +374 -0
  74. package/dist/commands/runtime-internalization-context-trace.js.map +1 -0
  75. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -1
  76. package/dist/commands/runtime-internalization-enqueue-successors.js +3 -1
  77. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -1
  78. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -1
  79. package/dist/commands/runtime-internalization-integrity.js +4 -2
  80. package/dist/commands/runtime-internalization-integrity.js.map +1 -1
  81. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  82. package/dist/commands/runtime-internalization-run-once.js +20 -5
  83. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  84. package/dist/commands/runtime-internalization-run-rulehost.d.ts +1 -0
  85. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  86. package/dist/commands/runtime-internalization-run-rulehost.js +149 -6
  87. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  88. package/dist/commands/runtime-uat.d.ts.map +1 -1
  89. package/dist/commands/runtime-uat.guard.test.js +13 -15
  90. package/dist/commands/runtime-uat.guard.test.js.map +1 -1
  91. package/dist/commands/runtime-uat.js +4 -3
  92. package/dist/commands/runtime-uat.js.map +1 -1
  93. package/dist/config-reader.js +1 -1
  94. package/dist/config-reader.js.map +1 -1
  95. package/dist/index.js +54 -78
  96. package/dist/index.js.map +1 -1
  97. package/dist/services/__tests__/evaluator-runner-deps.test.d.ts +2 -0
  98. package/dist/services/__tests__/evaluator-runner-deps.test.d.ts.map +1 -0
  99. package/dist/services/__tests__/evaluator-runner-deps.test.js +304 -0
  100. package/dist/services/__tests__/evaluator-runner-deps.test.js.map +1 -0
  101. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -1
  102. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
  103. package/dist/services/config-doctor.d.ts.map +1 -1
  104. package/dist/services/config-doctor.js +9 -2
  105. package/dist/services/config-doctor.js.map +1 -1
  106. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -1
  107. package/dist/services/pain-flood-simulation-runner.js +9 -0
  108. package/dist/services/pain-flood-simulation-runner.js.map +1 -1
  109. package/dist/services/pd-config-loader.js +2 -2
  110. package/dist/services/pd-config-loader.js.map +1 -1
  111. package/dist/services/proven-channel-baseline-runner.d.ts.map +1 -1
  112. package/dist/services/proven-channel-baseline-runner.js +10 -2
  113. package/dist/services/proven-channel-baseline-runner.js.map +1 -1
  114. package/dist/services/quality-scorecard/data-extractor.js.map +1 -1
  115. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
  116. package/dist/services/rulehost-pipeline-runner.d.ts +77 -1
  117. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  118. package/dist/services/rulehost-pipeline-runner.js +111 -7
  119. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  120. package/dist/services/rulehost-readiness.js.map +1 -1
  121. package/dist/services/runtime-adapter-resolver.d.ts +1 -0
  122. package/dist/services/runtime-adapter-resolver.d.ts.map +1 -1
  123. package/dist/services/runtime-adapter-resolver.js +3 -0
  124. package/dist/services/runtime-adapter-resolver.js.map +1 -1
  125. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -1
  126. package/dist/services/synthetic-baseline-runner.js +11 -0
  127. package/dist/services/synthetic-baseline-runner.js.map +1 -1
  128. package/dist/utils/production-workspace-guard.d.ts +4 -4
  129. package/dist/utils/production-workspace-guard.d.ts.map +1 -1
  130. package/dist/utils/production-workspace-guard.js +19 -16
  131. package/dist/utils/production-workspace-guard.js.map +1 -1
  132. package/dist/utils/production-workspace-guard.test.js +54 -19
  133. package/dist/utils/production-workspace-guard.test.js.map +1 -1
  134. package/package.json +8 -5
  135. package/src/commands/__tests__/intent-flag-wiring.test.ts +88 -3
  136. package/src/commands/__tests__/pain-retry-maxTokens-flag.test.ts +73 -0
  137. package/src/commands/__tests__/rulecode-flag-wiring.test.ts +31 -24
  138. package/src/commands/__tests__/run-rulehost-flag-wiring.test.ts +12 -0
  139. package/src/commands/admission-gate.ts +30 -0
  140. package/src/commands/candidate.ts +74 -6
  141. package/src/commands/console.ts +40 -0
  142. package/src/commands/demo-story-a.ts +4 -2
  143. package/src/commands/diagnose.ts +158 -10
  144. package/src/commands/errors-list.ts +440 -0
  145. package/src/commands/health-codex.ts +249 -0
  146. package/src/commands/health.ts +60 -0
  147. package/src/commands/intent.ts +98 -29
  148. package/src/commands/pain-evidence.ts +6 -0
  149. package/src/commands/pain-record.ts +21 -0
  150. package/src/commands/pain-retry.ts +353 -35
  151. package/src/commands/proven-channel-baseline.ts +4 -2
  152. package/src/commands/runtime-activation.ts +657 -12
  153. package/src/commands/runtime-canary.ts +31 -5
  154. package/src/commands/runtime-health-snapshot.ts +4 -2
  155. package/src/commands/runtime-init.ts +305 -0
  156. package/src/commands/runtime-internalization-context-trace.ts +445 -0
  157. package/src/commands/runtime-internalization-enqueue-successors.ts +3 -1
  158. package/src/commands/runtime-internalization-integrity.ts +4 -2
  159. package/src/commands/runtime-internalization-run-once.ts +20 -5
  160. package/src/commands/runtime-internalization-run-rulehost.ts +159 -6
  161. package/src/commands/runtime-uat.guard.test.ts +14 -16
  162. package/src/commands/runtime-uat.ts +4 -3
  163. package/src/config-reader.ts +1 -1
  164. package/src/index.ts +69 -78
  165. package/src/services/__tests__/evaluator-runner-deps.test.ts +341 -0
  166. package/src/services/config-doctor.ts +9 -2
  167. package/src/services/pain-flood-simulation-runner.ts +9 -0
  168. package/src/services/pd-config-loader.ts +2 -2
  169. package/src/services/proven-channel-baseline-runner.ts +10 -3
  170. package/src/services/rulehost-pipeline-runner.ts +157 -6
  171. package/src/services/runtime-adapter-resolver.ts +4 -0
  172. package/src/services/synthetic-baseline-runner.ts +11 -0
  173. package/src/utils/production-workspace-guard.test.ts +62 -20
  174. package/src/utils/production-workspace-guard.ts +19 -16
  175. package/tests/bdd/cli-contract.steps.ts +343 -0
  176. package/tests/bdd/context-trace.steps.ts +229 -0
  177. package/tests/bdd/support/gherkin-loader.ts +147 -0
  178. package/tests/bdd/support/repo-root.ts +62 -0
  179. package/tests/bdd/support/vitest-bdd.ts +179 -0
  180. package/tests/commands/admission-gate-coverage.test.ts +79 -0
  181. package/tests/commands/candidate-audit-repair.test.ts +77 -0
  182. package/tests/commands/candidate-intake.test.ts +155 -10
  183. package/tests/commands/candidate-internalization-backfill.test.ts +30 -0
  184. package/tests/commands/cli-help-snapshot.test.ts +15 -1
  185. package/tests/commands/console-open.test.ts +19 -0
  186. package/tests/commands/diagnose.test.ts +619 -14
  187. package/tests/commands/errors-list.test.ts +678 -0
  188. package/tests/commands/health-codex.test.ts +243 -0
  189. package/tests/commands/health-host-wiring.test.ts +76 -0
  190. package/tests/commands/intent.test.ts +4 -4
  191. package/tests/commands/pain-record.test.ts +80 -0
  192. package/tests/commands/pain-retry.test.ts +94 -3
  193. package/tests/commands/run-rulehost-handler.test.ts +133 -0
  194. package/tests/commands/runtime-activation-approve-flag-wiring.test.ts +227 -0
  195. package/tests/commands/runtime-activation-deactivate-flag-wiring.test.ts +167 -0
  196. package/tests/commands/runtime-activation-dispatch-flag-wiring.test.ts +262 -0
  197. package/tests/commands/runtime-activation-list-flag-wiring.test.ts +193 -0
  198. package/tests/commands/runtime-activation-promote-flag-wiring.test.ts +222 -0
  199. package/tests/commands/runtime-activation.test.ts +1090 -5
  200. package/tests/commands/runtime-canary.test.ts +50 -0
  201. package/tests/commands/runtime-features.test.ts +2 -0
  202. package/tests/commands/runtime-init-empty-workspace.test.ts +234 -0
  203. package/tests/commands/runtime-init.test.ts +346 -0
  204. package/tests/commands/runtime-internalization-context-trace.test.ts +110 -0
  205. package/tests/commands/runtime-internalization.test.ts +2 -2
  206. package/tests/config-reader.test.ts +6 -5
  207. package/tests/e2e/cross-package-acceptance.test.ts +55 -13
  208. package/tests/services/mainline-snapshot-assembler.test.ts +6 -3
  209. package/tests/services/resolve-runtime-from-pd-config.test.ts +2 -0
  210. package/tests/services/rulehost-pipeline-runner.test.ts +1 -1
  211. package/vitest.config.ts +1 -1
@@ -1,20 +1,23 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
2
2
  import { Command } from 'commander';
3
3
 
4
- const { MockRuntimeStateManager, mockGetCandidatesByTaskId, mockUpdateCandidateStatus } = vi.hoisted(() => {
4
+ const { MockRuntimeStateManager, mockGetCandidatesByTaskId, mockUpdateCandidateStatus, mockCreateTask, mockGetTask } = vi.hoisted(() => {
5
5
  const mockGetCandidatesByTaskId = vi.fn().mockResolvedValue([]);
6
6
  const mockUpdateCandidateStatus = vi.fn().mockResolvedValue(undefined);
7
+ const mockCreateTask = vi.fn().mockResolvedValue(undefined);
8
+ const mockGetTask = vi.fn().mockResolvedValue({
9
+ taskId: 'test-task-1',
10
+ status: 'pending',
11
+ attemptCount: 0,
12
+ maxAttempts: 3,
13
+ lastError: null,
14
+ });
7
15
 
8
16
  class MockRuntimeStateManager {
9
17
  initialize = vi.fn().mockResolvedValue(undefined);
10
18
  close = vi.fn().mockResolvedValue(undefined);
11
- getTask = vi.fn().mockResolvedValue({
12
- taskId: 'test-task-1',
13
- status: 'pending',
14
- attemptCount: 0,
15
- maxAttempts: 3,
16
- lastError: null,
17
- });
19
+ getTask = mockGetTask;
20
+ createTask = mockCreateTask;
18
21
  getCandidatesByTaskId = mockGetCandidatesByTaskId;
19
22
  updateCandidateStatus = mockUpdateCandidateStatus;
20
23
  connection = {} as Record<string, unknown>;
@@ -26,7 +29,7 @@ const { MockRuntimeStateManager, mockGetCandidatesByTaskId, mockUpdateCandidateS
26
29
  calculateBackoff: () => 10,
27
30
  });
28
31
  }
29
- return { MockRuntimeStateManager, mockGetCandidatesByTaskId, mockUpdateCandidateStatus };
32
+ return { MockRuntimeStateManager, mockGetCandidatesByTaskId, mockUpdateCandidateStatus, mockCreateTask, mockGetTask };
30
33
  }, { validateType: true });
31
34
 
32
35
  const { mockIntake, MockCandidateIntakeService } = vi.hoisted(() => {
@@ -63,6 +66,21 @@ const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
63
66
  return { mockResolveRuntimeFromPdConfig };
64
67
  });
65
68
 
69
+ // BUG-1: capture runner constructor args to verify effectiveConfig wiring
70
+ const { diagRootCauseRunnerCtor, diagDistillerRunnerCtor, diagRouterRunnerCtor } = vi.hoisted(() => {
71
+ const diagRootCauseRunnerCtor = vi.fn().mockImplementation(function () { return {}; });
72
+ const diagDistillerRunnerCtor = vi.fn().mockImplementation(function () { return {}; });
73
+ const diagRouterRunnerCtor = vi.fn().mockImplementation(function () { return {}; });
74
+ return { diagRootCauseRunnerCtor, diagDistillerRunnerCtor, diagRouterRunnerCtor };
75
+ });
76
+
77
+ // BUG-2: capture buildDreamerSeedFromCandidate sourcePainId arg + mock resolver
78
+ const { buildDreamerSeedCalls, mockResolveSourcePainId } = vi.hoisted(() => {
79
+ const buildDreamerSeedCalls: Array<{ candidateId: string; sourcePainId?: string }> = [];
80
+ const mockResolveSourcePainId = vi.fn().mockResolvedValue('pain_test-source-1');
81
+ return { buildDreamerSeedCalls, mockResolveSourcePainId };
82
+ });
83
+
66
84
  vi.mock('../../src/resolve-workspace.js', () => ({
67
85
  resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
68
86
  }));
@@ -80,9 +98,9 @@ vi.mock('@principles/core/runtime-v2', () => {
80
98
  StoreEventEmitter: vi.fn().mockImplementation(function () { return {}; }),
81
99
  storeEmitter: { emitTelemetry: vi.fn() },
82
100
  SplitDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
83
- DiagRootCauseRunner: vi.fn().mockImplementation(function () { return {}; }),
84
- DiagDistillerRunner: vi.fn().mockImplementation(function () { return {}; }),
85
- DiagRouterRunner: vi.fn().mockImplementation(function () { return {}; }),
101
+ DiagRootCauseRunner: diagRootCauseRunnerCtor,
102
+ DiagDistillerRunner: diagDistillerRunnerCtor,
103
+ DiagRouterRunner: diagRouterRunnerCtor,
86
104
  DefaultDiagRootCauseValidator: vi.fn().mockImplementation(function () { return {}; }),
87
105
  DefaultDiagDistillerValidator: vi.fn().mockImplementation(function () { return {}; }),
88
106
  DisabledDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
@@ -97,6 +115,15 @@ vi.mock('@principles/core/runtime-v2', () => {
97
115
  }
98
116
  },
99
117
  CandidateIntakeService: MockCandidateIntakeService,
118
+ // PRI-503: admission gate mock — admit by default so existing diagnose/intake
119
+ // tests keep their original flow. Tests that need to assert refusal behavior
120
+ // can override this mock per-test.
121
+ evaluateCandidateAdmissionFromRecord: vi.fn().mockReturnValue({
122
+ decision: 'admitted',
123
+ reason: 'mock_admitted',
124
+ nextAction: 'none',
125
+ evidenceStatus: 'unknown',
126
+ }),
100
127
  resolveRuntimeConfig: vi.fn().mockReturnValue({
101
128
  runtimeKind: 'pi-ai',
102
129
  provider: 'test-provider',
@@ -123,12 +150,37 @@ vi.mock('@principles/core/runtime-v2', () => {
123
150
  rootCause: 'Test: test root cause',
124
151
  violatedPrinciples: [],
125
152
  evidence: [],
126
- recommendations: [],
153
+ recommendations: [{ kind: 'implementation', description: 'Test recommendation' }],
127
154
  confidence: 0.9,
128
155
  },
129
156
  }),
130
157
  status: vi.fn(),
131
158
  PrincipleTreeLedgerAdapter: MockPrincipleTreeLedgerAdapter,
159
+ // Defect-004 dreamer seed mocks — minimal stubs that mirror real behavior
160
+ // BUG-2: capture sourcePainId arg to verify lineage wiring
161
+ buildDreamerSeedFromCandidate: vi.fn().mockImplementation((candidate: { candidateId: string }, opts: { route: string; ready: boolean; sourcePainId?: string }) => {
162
+ buildDreamerSeedCalls.push({ candidateId: candidate.candidateId, sourcePainId: opts?.sourcePainId });
163
+ return {
164
+ taskId: `dreamer-${candidate.candidateId}`,
165
+ taskKind: 'dreamer' as const,
166
+ channel: 'prompt' as const,
167
+ diagnosticJson: JSON.stringify({ candidateId: candidate.candidateId }),
168
+ status: 'pending' as const,
169
+ attemptCount: 0,
170
+ maxAttempts: 3,
171
+ };
172
+ }),
173
+ CANDIDATE_KIND_TO_ROUTE: {
174
+ principle: 'principle-candidate',
175
+ rule: 'rule-candidate',
176
+ prompt: 'prompt-candidate',
177
+ },
178
+ ROUTE_CHANNEL_MAP: {
179
+ 'principle-candidate': 'prompt',
180
+ 'rule-candidate': 'code_tool_hook',
181
+ 'prompt-candidate': 'prompt',
182
+ },
183
+ MVP_ENABLED_CHANNELS: new Set(['prompt', 'code_tool_hook', 'defer_archive']),
132
184
  };
133
185
  });
134
186
 
@@ -137,7 +189,11 @@ vi.mock('../../src/config-reader.js', () => ({
137
189
  }));
138
190
 
139
191
  vi.mock('../../src/services/pd-config-loader.js', () => ({
140
- loadPdConfig: vi.fn().mockReturnValue({ ok: true, effective: { config: {}, source: 'defaults', warnings: [] } }),
192
+ loadPdConfig: vi.fn().mockReturnValue({
193
+ ok: true,
194
+ effective: { config: { featureFlags: { diagnostician_llm_degradation: true } }, source: 'file', warnings: [] },
195
+ defaults: { config: {}, source: 'defaults', warnings: [] },
196
+ }),
141
197
  computeFlagsFromLoadResult: vi.fn().mockReturnValue({}),
142
198
  }));
143
199
 
@@ -145,6 +201,11 @@ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
145
201
  resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
146
202
  }));
147
203
 
204
+ // BUG-2: mock resolveSourcePainIdFromDiagnostician imported by diagnose.ts from candidate.js
205
+ vi.mock('../../src/commands/candidate.js', () => ({
206
+ resolveSourcePainIdFromDiagnostician: mockResolveSourcePainId,
207
+ }));
208
+
148
209
  import { handleDiagnoseRun, handleDiagnoseStatus, type DiagnoseRunOptions } from '../../src/commands/diagnose.js';
149
210
 
150
211
  const SUCCEEDED_RESULT = {
@@ -382,6 +443,84 @@ describe('pd diagnose run --runtime routing', () => {
382
443
  exitSpy.mockRestore();
383
444
  });
384
445
 
446
+ it('P1: missing --runtime with no config binding refuses with missing_runtime (no test-double default)', async () => {
447
+ // Regression for the silent test-double default bug. Without --runtime and
448
+ // with no usable .pd/config.yaml binding, the command must refuse loudly
449
+ // (rc-9-no-silent-fallback) instead of defaulting to test-double and
450
+ // producing fake/failed diagnostic data in a real workspace.
451
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
452
+ result: { ok: false, reason: 'no_runtime_binding', message: 'no binding', nextAction: 'set runtime' },
453
+ legacyWarnings: [],
454
+ configSource: '.pd/config.yaml',
455
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] } as never,
456
+ });
457
+ mockIsRuntimeConfigError.mockReturnValueOnce(true);
458
+
459
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
460
+ const consoleErrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
461
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
462
+
463
+ await handleDiagnoseRun({
464
+ taskId: 'test-task-1',
465
+ workspace: '/tmp/fake-workspace',
466
+ // runtime intentionally omitted
467
+ json: true,
468
+ } as DiagnoseRunOptions);
469
+
470
+ // Config was consulted (not silently skipped).
471
+ expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith('/tmp/fake-workspace');
472
+ expect(exitSpy).toHaveBeenCalledWith(1);
473
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
474
+ expect(jsonOutput.ok).toBe(false);
475
+ expect(jsonOutput.reason).toBe('missing_runtime');
476
+ expect(jsonOutput.nextAction).toContain('--runtime');
477
+
478
+ consoleLogSpy.mockRestore();
479
+ consoleErrSpy.mockRestore();
480
+ exitSpy.mockRestore();
481
+ });
482
+
483
+ it('P1: missing --runtime resolves kind from .pd/config.yaml (honors config, no test-double default)', async () => {
484
+ // When --runtime is absent but .pd/config.yaml binds openclaw-cli, the
485
+ // resolved kind must come from config — proving test-double is no longer
486
+ // the default. Mirrors DPB-09 but omits --runtime to exercise the new
487
+ // config-resolution path.
488
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
489
+ result: {
490
+ runtimeKind: 'openclaw-cli',
491
+ openclawMode: 'gateway',
492
+ timeoutMs: 300000,
493
+ agentId: 'main',
494
+ },
495
+ legacyWarnings: [],
496
+ configSource: '.pd/config.yaml',
497
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] } as never,
498
+ });
499
+
500
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
501
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
502
+
503
+ await handleDiagnoseRun({
504
+ taskId: 'test-task-1',
505
+ workspace: '/tmp/fake-workspace',
506
+ // runtime intentionally omitted — must be resolved from config
507
+ openclawGateway: true,
508
+ json: false,
509
+ } as DiagnoseRunOptions);
510
+
511
+ expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith('/tmp/fake-workspace');
512
+ const OpenClawCliMock = vi.mocked(
513
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
514
+ );
515
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
516
+ expect.objectContaining({ runtimeMode: 'gateway' }),
517
+ );
518
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
519
+
520
+ consoleSpy.mockRestore();
521
+ exitSpy.mockRestore();
522
+ });
523
+
385
524
  it('DPB-09: openclaw-cli --openclaw-gateway constructs adapter with runtimeMode=gateway', async () => {
386
525
  mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
387
526
  result: {
@@ -1109,3 +1248,469 @@ describe('ERR-067: pd diagnose run retry loop for retried status', () => {
1109
1248
  runMock.mockResolvedValue(DEFAULT_SUCCEEDED_RUN_RESULT);
1110
1249
  });
1111
1250
  });
1251
+
1252
+ // Defect-004 Part 2: dreamer seed after intake
1253
+ describe('Defect-004: pd diagnose run — dreamer seed after intake', () => {
1254
+ beforeEach(async () => {
1255
+ vi.clearAllMocks();
1256
+ const { run } = await import('@principles/core/runtime-v2');
1257
+ vi.mocked(run).mockResolvedValue(SUCCEEDED_RESULT);
1258
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
1259
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
1260
+ mockCreateTask.mockResolvedValue(undefined);
1261
+ // For dreamer seed tests, getTask should return null (dreamer task doesn't exist yet)
1262
+ // so createTask gets called. Individual tests can override this if needed.
1263
+ mockGetTask.mockResolvedValue(null);
1264
+ mockIntake.mockReset();
1265
+ mockIntake.mockResolvedValue({ id: 'ledger-1', title: 'P1', status: 'probation' });
1266
+ });
1267
+
1268
+ it('DREAMER-01: principle candidate — dreamer task created via createTask', async () => {
1269
+ const candidates = [
1270
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'principle' },
1271
+ ];
1272
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1273
+
1274
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1275
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1276
+
1277
+ await handleDiagnoseRun({
1278
+ taskId: 'test-task-1',
1279
+ workspace: '/tmp/fake-workspace',
1280
+ runtime: 'test-double',
1281
+ json: true,
1282
+ } as DiagnoseRunOptions);
1283
+
1284
+ // Verify createTask was called for the dreamer task
1285
+ expect(mockCreateTask).toHaveBeenCalled();
1286
+ const createCall = mockCreateTask.mock.calls[0]?.[0];
1287
+ expect(createCall?.taskKind).toBe('dreamer');
1288
+ expect(createCall?.taskId).toBe('dreamer-cand-1');
1289
+ expect(createCall?.status).toBe('pending');
1290
+
1291
+ consoleSpy.mockRestore();
1292
+ exitSpy.mockRestore();
1293
+ });
1294
+
1295
+ it('DREAMER-02: defer candidate — no dreamer task created', async () => {
1296
+ const candidates = [
1297
+ { candidateId: 'cand-defer', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'defer' },
1298
+ ];
1299
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1300
+
1301
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1302
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1303
+
1304
+ await handleDiagnoseRun({
1305
+ taskId: 'test-task-1',
1306
+ workspace: '/tmp/fake-workspace',
1307
+ runtime: 'test-double',
1308
+ json: true,
1309
+ } as DiagnoseRunOptions);
1310
+
1311
+ // No dreamer task should be created for defer candidates
1312
+ expect(mockCreateTask).not.toHaveBeenCalled();
1313
+
1314
+ consoleSpy.mockRestore();
1315
+ exitSpy.mockRestore();
1316
+ });
1317
+
1318
+ it('DREAMER-03: implementation candidate — no dreamer task created', async () => {
1319
+ const candidates = [
1320
+ { candidateId: 'cand-impl', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'implementation' },
1321
+ ];
1322
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1323
+
1324
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1325
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1326
+
1327
+ await handleDiagnoseRun({
1328
+ taskId: 'test-task-1',
1329
+ workspace: '/tmp/fake-workspace',
1330
+ runtime: 'test-double',
1331
+ json: true,
1332
+ } as DiagnoseRunOptions);
1333
+
1334
+ // No dreamer task should be created for implementation candidates
1335
+ expect(mockCreateTask).not.toHaveBeenCalled();
1336
+
1337
+ consoleSpy.mockRestore();
1338
+ exitSpy.mockRestore();
1339
+ });
1340
+
1341
+ it('DREAMER-04: --no-intake skips dreamer seed', async () => {
1342
+ const candidates = [
1343
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'principle' },
1344
+ ];
1345
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1346
+
1347
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1348
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1349
+
1350
+ await handleDiagnoseRun({
1351
+ taskId: 'test-task-1',
1352
+ workspace: '/tmp/fake-workspace',
1353
+ runtime: 'test-double',
1354
+ json: true,
1355
+ intake: false,
1356
+ } as DiagnoseRunOptions);
1357
+
1358
+ // No dreamer task should be created when intake is disabled
1359
+ expect(mockCreateTask).not.toHaveBeenCalled();
1360
+
1361
+ consoleSpy.mockRestore();
1362
+ exitSpy.mockRestore();
1363
+ });
1364
+
1365
+ it('DREAMER-05: dreamer seed failure surfaces in intake results but does not fail the command', async () => {
1366
+ const candidates = [
1367
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'principle' },
1368
+ ];
1369
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1370
+ // Make createTask throw to simulate seed failure
1371
+ mockCreateTask.mockRejectedValueOnce(new Error('DB write failed'));
1372
+
1373
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1374
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1375
+
1376
+ await handleDiagnoseRun({
1377
+ taskId: 'test-task-1',
1378
+ workspace: '/tmp/fake-workspace',
1379
+ runtime: 'test-double',
1380
+ json: true,
1381
+ } as DiagnoseRunOptions);
1382
+
1383
+ // Command should still succeed (exit 0) — dreamer seed failure is best-effort
1384
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
1385
+
1386
+ // JSON output should include dreamer_seed_failed status
1387
+ const jsonOutput = consoleSpy.mock.calls.find(call => {
1388
+ try {
1389
+ const parsed = JSON.parse(call[0] as string);
1390
+ return parsed.intake && Array.isArray(parsed.intake.candidates);
1391
+ } catch { return false; }
1392
+ });
1393
+ expect(jsonOutput).toBeDefined();
1394
+ const parsed = JSON.parse((jsonOutput as [string])[0]);
1395
+ const seedResult = parsed.intake.candidates.find((c: { candidateId: string; status: string }) => c.candidateId === 'cand-1' && c.status === 'dreamer_seed_failed');
1396
+ expect(seedResult).toBeDefined();
1397
+ expect(seedResult.error).toContain('DB write failed');
1398
+
1399
+ consoleSpy.mockRestore();
1400
+ exitSpy.mockRestore();
1401
+ });
1402
+ });
1403
+
1404
+ // DEFECT-005 (PRI-514): A single defer candidate's intake_failed must NOT poison
1405
+ // the dreamer seed loop for other successfully-consumed candidates. Previously
1406
+ // `intakeFailed` was a global flag gating the ENTIRE dreamer seed loop — one
1407
+ // defer candidate (admission gate deferred) set it true, skipping dreamer seed
1408
+ // for ALL candidates including ones that succeeded intake (EP-03 / ERR-089
1409
+ // sibling-branch defect: one branch's failure punished all sibling branches).
1410
+ describe('DEFECT-005 (PRI-514): defer intake_failed must not poison other candidates dreamer seed', () => {
1411
+ beforeEach(async () => {
1412
+ vi.clearAllMocks();
1413
+ const { run, evaluateCandidateAdmissionFromRecord } = await import('@principles/core/runtime-v2');
1414
+ vi.mocked(run).mockResolvedValue(SUCCEEDED_RESULT);
1415
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
1416
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
1417
+ mockCreateTask.mockResolvedValue(undefined);
1418
+ mockGetTask.mockResolvedValue(null);
1419
+ mockIntake.mockReset();
1420
+ mockIntake.mockResolvedValue({ id: 'ledger-1', title: 'P1', status: 'probation' });
1421
+ // Default: admit all candidates. Individual tests override for defer.
1422
+ vi.mocked(evaluateCandidateAdmissionFromRecord).mockReturnValue({
1423
+ decision: 'admitted',
1424
+ reason: 'mock_admitted',
1425
+ nextAction: 'none',
1426
+ evidenceStatus: 'unknown',
1427
+ });
1428
+ });
1429
+
1430
+ // Restore the factory-default admission decision so later describe blocks
1431
+ // (which do `vi.clearAllMocks()` but do not re-set the implementation) see
1432
+ // `admitted` again. Without this, a `mockReturnValue({deferred})` from
1433
+ // DEFECT-005-B would leak into BUG-2 and refuse all its candidates.
1434
+ afterEach(async () => {
1435
+ const { evaluateCandidateAdmissionFromRecord } = await import('@principles/core/runtime-v2');
1436
+ vi.mocked(evaluateCandidateAdmissionFromRecord).mockReturnValue({
1437
+ decision: 'admitted',
1438
+ reason: 'mock_admitted',
1439
+ nextAction: 'none',
1440
+ evidenceStatus: 'unknown',
1441
+ });
1442
+ });
1443
+
1444
+ it('DEFECT-005-A: defer deferred + principle/rule/prompt consumed — principle/rule/prompt still dreamer_seeded', async () => {
1445
+ // Admission gate defers ONLY defer candidates; admits everything else.
1446
+ const { evaluateCandidateAdmissionFromRecord } = await import('@principles/core/runtime-v2');
1447
+ vi.mocked(evaluateCandidateAdmissionFromRecord).mockImplementation((record) => {
1448
+ if (record.recommendationKind === 'defer') {
1449
+ return {
1450
+ decision: 'deferred',
1451
+ reason: 'recommendation_kind_defer_not_actionable',
1452
+ nextAction: 'review_defer_disposition_manually',
1453
+ evidenceStatus: 'unknown',
1454
+ };
1455
+ }
1456
+ return {
1457
+ decision: 'admitted',
1458
+ reason: 'mock_admitted',
1459
+ nextAction: 'none',
1460
+ evidenceStatus: 'unknown',
1461
+ };
1462
+ });
1463
+
1464
+ const candidates = [
1465
+ { candidateId: 'cand-defer', artifactId: 'art-d', taskId: 'test-task-1', status: 'pending', recommendationKind: 'defer', confidence: 0.9 },
1466
+ { candidateId: 'cand-principle', artifactId: 'art-p', taskId: 'test-task-1', status: 'pending', recommendationKind: 'principle', confidence: 0.9 },
1467
+ { candidateId: 'cand-rule', artifactId: 'art-r', taskId: 'test-task-1', status: 'pending', recommendationKind: 'rule', confidence: 0.9 },
1468
+ { candidateId: 'cand-prompt', artifactId: 'art-pp', taskId: 'test-task-1', status: 'pending', recommendationKind: 'prompt', confidence: 0.9 },
1469
+ { candidateId: 'cand-impl', artifactId: 'art-i', taskId: 'test-task-1', status: 'pending', recommendationKind: 'implementation', confidence: 0.9 },
1470
+ ];
1471
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1472
+
1473
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1474
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1475
+
1476
+ await handleDiagnoseRun({
1477
+ taskId: 'test-task-1',
1478
+ workspace: '/tmp/fake-workspace',
1479
+ runtime: 'test-double',
1480
+ json: true,
1481
+ } as DiagnoseRunOptions);
1482
+
1483
+ const jsonOutput = consoleSpy.mock.calls.find(call => {
1484
+ try {
1485
+ const parsed = JSON.parse(call[0] as string);
1486
+ return parsed.intake && Array.isArray(parsed.intake.candidates);
1487
+ } catch { return false; }
1488
+ });
1489
+ expect(jsonOutput).toBeDefined();
1490
+ const parsed = JSON.parse((jsonOutput as [string])[0]);
1491
+ const results = parsed.intake.candidates as Array<{ candidateId: string; status: string }>;
1492
+
1493
+ // defer candidate: intake_failed (admission gate deferred) — correct.
1494
+ // intakeResults may carry multiple entries per candidate (intake + seed),
1495
+ // so we match on BOTH candidateId and status (same pattern as DREAMER-05).
1496
+ const deferFailed = results.find(r => r.candidateId === 'cand-defer' && r.status === 'intake_failed');
1497
+ expect(deferFailed).toBeDefined();
1498
+
1499
+ // principle/rule/prompt: dreamer_seeded — MUST NOT be poisoned by defer's intake_failed
1500
+ const principleSeeded = results.find(r => r.candidateId === 'cand-principle' && r.status === 'dreamer_seeded');
1501
+ expect(principleSeeded).toBeDefined();
1502
+
1503
+ const ruleSeeded = results.find(r => r.candidateId === 'cand-rule' && r.status === 'dreamer_seeded');
1504
+ expect(ruleSeeded).toBeDefined();
1505
+
1506
+ const promptSeeded = results.find(r => r.candidateId === 'cand-prompt' && r.status === 'dreamer_seeded');
1507
+ expect(promptSeeded).toBeDefined();
1508
+
1509
+ // 3 dreamer tasks created (principle + rule + prompt).
1510
+ // defer was refused at the gate (no intake, no seed).
1511
+ // implementation is skipped by the dreamer seed loop (`if kind === 'implementation' continue`).
1512
+ expect(mockCreateTask).toHaveBeenCalledTimes(3);
1513
+
1514
+ consoleSpy.mockRestore();
1515
+ exitSpy.mockRestore();
1516
+ });
1517
+
1518
+ it('DEFECT-005-B: all candidates intake_failed — 0 dreamer_seeded (boundary)', async () => {
1519
+ // Refuse everything via admission gate
1520
+ const { evaluateCandidateAdmissionFromRecord } = await import('@principles/core/runtime-v2');
1521
+ vi.mocked(evaluateCandidateAdmissionFromRecord).mockReturnValue({
1522
+ decision: 'deferred',
1523
+ reason: 'mock_deferred',
1524
+ nextAction: 'none',
1525
+ evidenceStatus: 'unknown',
1526
+ });
1527
+
1528
+ const candidates = [
1529
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'principle', confidence: 0.9 },
1530
+ { candidateId: 'cand-2', artifactId: 'art-2', taskId: 'test-task-1', status: 'pending', recommendationKind: 'rule', confidence: 0.9 },
1531
+ ];
1532
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1533
+
1534
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1535
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1536
+
1537
+ await handleDiagnoseRun({
1538
+ taskId: 'test-task-1',
1539
+ workspace: '/tmp/fake-workspace',
1540
+ runtime: 'test-double',
1541
+ json: true,
1542
+ } as DiagnoseRunOptions);
1543
+
1544
+ // No dreamer tasks should be created when no candidate was consumed
1545
+ expect(mockCreateTask).not.toHaveBeenCalled();
1546
+
1547
+ const jsonOutput = consoleSpy.mock.calls.find(call => {
1548
+ try {
1549
+ const parsed = JSON.parse(call[0] as string);
1550
+ return parsed.intake && Array.isArray(parsed.intake.candidates);
1551
+ } catch { return false; }
1552
+ });
1553
+ expect(jsonOutput).toBeDefined();
1554
+ const parsed = JSON.parse((jsonOutput as [string])[0]);
1555
+ const results = parsed.intake.candidates as Array<{ candidateId: string; status: string }>;
1556
+ expect(results.every(r => r.status === 'intake_failed')).toBe(true);
1557
+
1558
+ consoleSpy.mockRestore();
1559
+ exitSpy.mockRestore();
1560
+ });
1561
+ });
1562
+
1563
+ // BUG-1 (PRI-442): effectiveConfig must be passed to split-pipeline runners
1564
+ // so that ADR-0019 LLM rate-limit degradation (isDegradationEnabled) can fire.
1565
+ // Without this wiring, isDegradationEnabled() always returns false because
1566
+ // this.config.effectiveConfig is undefined. EP-02: production path wiring.
1567
+ describe('BUG-1 (PRI-442): effectiveConfig wiring to split-pipeline runners', () => {
1568
+ beforeEach(async () => {
1569
+ vi.clearAllMocks();
1570
+ const { run } = await import('@principles/core/runtime-v2');
1571
+ vi.mocked(run).mockResolvedValue(SUCCEEDED_RESULT);
1572
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
1573
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
1574
+ mockIntake.mockReset();
1575
+ });
1576
+
1577
+ it('passes effectiveConfig to DiagRootCauseRunner when split pipeline is enabled', async () => {
1578
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1579
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1580
+
1581
+ await handleDiagnoseRun({
1582
+ taskId: 'test-task-1',
1583
+ workspace: '/tmp/fake-workspace',
1584
+ runtime: 'test-double',
1585
+ json: true,
1586
+ } as DiagnoseRunOptions);
1587
+
1588
+ expect(diagRootCauseRunnerCtor).toHaveBeenCalled();
1589
+ const optionsArg = diagRootCauseRunnerCtor.mock.calls[0]?.[1];
1590
+ expect(optionsArg?.effectiveConfig).toBeDefined();
1591
+ expect(optionsArg?.effectiveConfig).toEqual(
1592
+ expect.objectContaining({ config: expect.anything(), source: 'file' }),
1593
+ );
1594
+
1595
+ consoleSpy.mockRestore();
1596
+ exitSpy.mockRestore();
1597
+ });
1598
+
1599
+ it('passes effectiveConfig to DiagDistillerRunner and DiagRouterRunner', async () => {
1600
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1601
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1602
+
1603
+ await handleDiagnoseRun({
1604
+ taskId: 'test-task-1',
1605
+ workspace: '/tmp/fake-workspace',
1606
+ runtime: 'test-double',
1607
+ json: true,
1608
+ } as DiagnoseRunOptions);
1609
+
1610
+ const distillerOptions = diagDistillerRunnerCtor.mock.calls[0]?.[1];
1611
+ expect(distillerOptions?.effectiveConfig).toBeDefined();
1612
+
1613
+ const routerOptions = diagRouterRunnerCtor.mock.calls[0]?.[1];
1614
+ expect(routerOptions?.effectiveConfig).toBeDefined();
1615
+
1616
+ consoleSpy.mockRestore();
1617
+ exitSpy.mockRestore();
1618
+ });
1619
+
1620
+ // CR-2 (CodeRabbit P2): loadPdConfig mock only covered ok:true. The
1621
+ // ok:false fallback branch (effectiveConfig = defaults) was untested.
1622
+ // This test locks in the fallback behavior + asserts rc-9 observability.
1623
+ it('warns and falls back to defaults when configLoadResult.ok is false', async () => {
1624
+ // Override the module-level mock for this test only
1625
+ const { loadPdConfig } = await import('../../src/services/pd-config-loader.js');
1626
+ vi.mocked(loadPdConfig).mockReturnValueOnce({
1627
+ ok: false,
1628
+ source: 'malformed',
1629
+ configPath: '/tmp/fake-workspace/.pd/config.yaml',
1630
+ errors: [
1631
+ { path: 'featureFlags.diagnostician_llm_degradation', reason: 'expected boolean, got string', nextAction: 'Fix the value to be a boolean' },
1632
+ ],
1633
+ defaults: { config: {}, source: 'defaults', warnings: [] },
1634
+ warnings: [],
1635
+ legacyFilesDetected: [],
1636
+ legacyFileNextActions: [],
1637
+ });
1638
+
1639
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1640
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1641
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1642
+
1643
+ await handleDiagnoseRun({
1644
+ taskId: 'test-task-1',
1645
+ workspace: '/tmp/fake-workspace',
1646
+ runtime: 'test-double',
1647
+ json: true,
1648
+ } as DiagnoseRunOptions);
1649
+
1650
+ // Assert warning was emitted (rc-9: no silent fallback)
1651
+ expect(warnSpy).toHaveBeenCalledWith(
1652
+ expect.stringContaining('[pd diagnose]'),
1653
+ );
1654
+ expect(warnSpy).toHaveBeenCalledWith(
1655
+ expect.stringContaining('malformed'),
1656
+ );
1657
+
1658
+ // Assert runners still received effectiveConfig (the defaults-based one)
1659
+ expect(diagRootCauseRunnerCtor).toHaveBeenCalled();
1660
+ const optionsArg = diagRootCauseRunnerCtor.mock.calls[0]?.[1];
1661
+ expect(optionsArg?.effectiveConfig).toBeDefined();
1662
+ expect(optionsArg?.effectiveConfig).toEqual(
1663
+ expect.objectContaining({ source: 'defaults' }),
1664
+ );
1665
+
1666
+ warnSpy.mockRestore();
1667
+ consoleSpy.mockRestore();
1668
+ exitSpy.mockRestore();
1669
+ });
1670
+ });
1671
+
1672
+ // BUG-2 (PRI-442): sourcePainId must be resolved from the diagnostician task
1673
+ // chain and passed to buildDreamerSeedFromCandidate. Without this, the dreamer
1674
+ // task's diagnosticJson omits sourcePainId, breaking run-rulehost lineage.
1675
+ // rc-6: lineage consistency. ERR-004: never invent lineage.
1676
+ describe('BUG-2 (PRI-442): sourcePainId resolution for dreamer seed', () => {
1677
+ beforeEach(async () => {
1678
+ vi.clearAllMocks();
1679
+ buildDreamerSeedCalls.length = 0;
1680
+ const { run } = await import('@principles/core/runtime-v2');
1681
+ vi.mocked(run).mockResolvedValue(SUCCEEDED_RESULT);
1682
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
1683
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
1684
+ mockCreateTask.mockResolvedValue(undefined);
1685
+ mockGetTask.mockResolvedValue(null);
1686
+ mockIntake.mockReset();
1687
+ mockIntake.mockResolvedValue({ id: 'ledger-1', title: 'P1', status: 'probation' });
1688
+ mockResolveSourcePainId.mockResolvedValue('pain_test-source-1');
1689
+ });
1690
+
1691
+ it('passes resolved sourcePainId to buildDreamerSeedFromCandidate (lineage consistency)', async () => {
1692
+ const candidates = [
1693
+ { candidateId: 'cand-1', artifactId: 'art-1', taskId: 'test-task-1', status: 'pending', recommendationKind: 'principle' },
1694
+ ];
1695
+ mockGetCandidatesByTaskId.mockResolvedValue(candidates);
1696
+
1697
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1698
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
1699
+
1700
+ await handleDiagnoseRun({
1701
+ taskId: 'test-task-1',
1702
+ workspace: '/tmp/fake-workspace',
1703
+ runtime: 'test-double',
1704
+ json: true,
1705
+ } as DiagnoseRunOptions);
1706
+
1707
+ // resolveSourcePainIdFromDiagnostician should have been called
1708
+ expect(mockResolveSourcePainId).toHaveBeenCalled();
1709
+ // buildDreamerSeedFromCandidate should have received the resolved sourcePainId
1710
+ expect(buildDreamerSeedCalls.length).toBeGreaterThan(0);
1711
+ expect(buildDreamerSeedCalls[0].sourcePainId).toBe('pain_test-source-1');
1712
+
1713
+ consoleSpy.mockRestore();
1714
+ exitSpy.mockRestore();
1715
+ });
1716
+ });