@principles/pd-cli 1.128.2 → 1.130.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 (41) hide show
  1. package/dist/commands/runtime-canary.d.ts.map +1 -1
  2. package/dist/commands/runtime-canary.js +10 -11
  3. package/dist/commands/runtime-canary.js.map +1 -1
  4. package/dist/commands/runtime-internalization-queue.d.ts.map +1 -1
  5. package/dist/commands/runtime-internalization-queue.js +13 -16
  6. package/dist/commands/runtime-internalization-queue.js.map +1 -1
  7. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  8. package/dist/commands/runtime-internalization-run-rulehost.js +61 -7
  9. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  10. package/dist/services/__tests__/rulehost-readiness.test.d.ts +2 -0
  11. package/dist/services/__tests__/rulehost-readiness.test.d.ts.map +1 -0
  12. package/dist/services/__tests__/rulehost-readiness.test.js +314 -0
  13. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -0
  14. package/dist/services/__tests__/runtime-adapter-resolver.test.js +20 -12
  15. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
  16. package/dist/services/rulehost-readiness.d.ts +62 -0
  17. package/dist/services/rulehost-readiness.d.ts.map +1 -0
  18. package/dist/services/rulehost-readiness.js +214 -0
  19. package/dist/services/rulehost-readiness.js.map +1 -0
  20. package/dist/services/runtime-adapter-resolver.d.ts +1 -1
  21. package/dist/services/runtime-adapter-resolver.js +3 -3
  22. package/dist/services/runtime-adapter-resolver.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/commands/runtime-canary.ts +11 -13
  25. package/src/commands/runtime-internalization-queue.ts +14 -19
  26. package/src/commands/runtime-internalization-run-rulehost.ts +68 -6
  27. package/src/services/__tests__/rulehost-readiness.test.ts +366 -0
  28. package/src/services/__tests__/runtime-adapter-resolver.test.ts +20 -12
  29. package/src/services/rulehost-readiness.ts +326 -0
  30. package/src/services/runtime-adapter-resolver.ts +3 -3
  31. package/tests/commands/run-rulehost-handler.test.ts +277 -0
  32. package/tests/commands/runtime-canary.test.ts +46 -36
  33. package/tests/commands/runtime-internalization-queue.test.ts +11 -24
  34. package/tests/commands/runtime-internalization-run-once.test.ts +14 -6
  35. package/tests/services/rulehost-pipeline-e2e.test.ts +71 -61
  36. package/dist/services/feature-flag-loader.d.ts +0 -6
  37. package/dist/services/feature-flag-loader.d.ts.map +0 -1
  38. package/dist/services/feature-flag-loader.js +0 -53
  39. package/dist/services/feature-flag-loader.js.map +0 -1
  40. package/src/services/feature-flag-loader.ts +0 -73
  41. package/tests/services/feature-flag-loader.test.ts +0 -207
@@ -0,0 +1,326 @@
1
+ /**
2
+ * RuleHost Readiness Resolver — PRI-461
3
+ *
4
+ * Checks all preconditions for `run-rulehost` BEFORE constructing adapters,
5
+ * returning one of three user-visible statuses:
6
+ * - `ready`: all agents enabled with pi-ai profiles and API keys; code-rule capability ON
7
+ * - `text_principle_only`: dreamer/philosopher/scribe ready, but code-rule capability OFF
8
+ * (flag disabled, or artificer/evaluator not ready)
9
+ * - `refused`: dreamer/philosopher/scribe chain broken (disabled, wrong profile, missing API key,
10
+ * or config malformed)
11
+ *
12
+ * This module NEVER throws. It always returns a structured result with reason + nextAction,
13
+ * so the CLI handler can emit a clear status instead of an opaque adapter-resolution failure.
14
+ *
15
+ * ERR refs:
16
+ * - EP-02 / ERR-024, ERR-025: wired into the production handler path (runtime-internalization-run-rulehost.ts)
17
+ * - EP-03: refused/text_principle_only include reason + nextAction
18
+ * - EP-07: uses the same config source as the pipeline (resolveRuntimeFromPdConfig)
19
+ * - EP-09: tests cover the default installed config pattern
20
+ */
21
+
22
+ import {
23
+ resolveAgentRuntimeBinding,
24
+ checkAgentRuntimeReadiness,
25
+ computeFeatureFlagsFromConfig,
26
+ isFeatureEnabled,
27
+ } from '@principles/core/runtime-v2';
28
+ import type {
29
+ EffectivePdConfig,
30
+ RuntimeProfile,
31
+ } from '@principles/core/runtime-v2';
32
+ import { resolveRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
33
+
34
+ // ── Types ────────────────────────────────────────────────────────────────────
35
+
36
+ export type RuleHostReadinessStatus = 'ready' | 'text_principle_only' | 'refused';
37
+
38
+ export interface AgentReadiness {
39
+ readonly status: 'ready' | 'disabled' | 'not_ready' | 'needs_setup' | 'wrong_profile_type';
40
+ readonly reason?: string;
41
+ readonly nextAction?: string;
42
+ readonly profileId?: string;
43
+ readonly profileType?: string;
44
+ }
45
+
46
+ export interface CodeRuleCapabilityReadiness {
47
+ readonly enabled: boolean;
48
+ readonly disabledReason?: string;
49
+ }
50
+
51
+ export interface RuleHostReadinessResult {
52
+ readonly status: RuleHostReadinessStatus;
53
+ readonly reason: string;
54
+ readonly nextAction: string;
55
+ readonly agentStatuses: {
56
+ readonly dreamer: AgentReadiness;
57
+ readonly philosopher: AgentReadiness;
58
+ readonly scribe: AgentReadiness;
59
+ readonly artificer: AgentReadiness;
60
+ readonly evaluator: AgentReadiness;
61
+ };
62
+ readonly codeRuleCapability: CodeRuleCapabilityReadiness;
63
+ }
64
+
65
+ /**
66
+ * The 5 internal agents that RuleHost readiness checks.
67
+ * Narrowed from InternalAgentName so array iteration indexes a known-shape map.
68
+ */
69
+ type RuleHostAgentName = 'dreamer' | 'philosopher' | 'scribe' | 'artificer' | 'evaluator';
70
+
71
+ /**
72
+ * Mutable builder type for agentStatuses during construction.
73
+ * The readonly RuleHostReadinessResult['agentStatuses'] is assigned from this.
74
+ */
75
+ interface AgentStatusesMap {
76
+ dreamer: AgentReadiness;
77
+ philosopher: AgentReadiness;
78
+ scribe: AgentReadiness;
79
+ artificer: AgentReadiness;
80
+ evaluator: AgentReadiness;
81
+ }
82
+
83
+ // ── Constants ─────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Agents required for the text-principle path (dreamer → philosopher → scribe).
87
+ * If any of these is not ready, the pipeline cannot produce even text principles.
88
+ */
89
+ const REQUIRED_AGENTS: readonly RuleHostAgentName[] = ['dreamer', 'philosopher', 'scribe'];
90
+
91
+ /**
92
+ * Agents required for the code-rule capability (artificer + evaluator).
93
+ * Both must be ready for the adversarial loop to run.
94
+ */
95
+ const CODE_RULE_AGENTS: readonly RuleHostAgentName[] = ['artificer', 'evaluator'];
96
+
97
+ // ── Implementation ────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Check a single agent's readiness for the RuleHost pipeline.
101
+ *
102
+ * Checks (in order):
103
+ * 1. Agent enabled (via resolveAgentRuntimeBinding)
104
+ * 2. Profile exists (via resolveAgentRuntimeBinding)
105
+ * 3. Profile type is 'pi-ai' (RuleHost needs PiAiRuntimeAdapter)
106
+ * 4. Profile is ready (via checkAgentRuntimeReadiness — provider/model/apiKeyEnv set, env var exists)
107
+ *
108
+ * Returns a structured AgentReadiness result. Never throws.
109
+ */
110
+ function checkAgentReadiness(
111
+ effective: EffectivePdConfig,
112
+ agentName: RuleHostAgentName,
113
+ getEnvVar: (name: string) => string | undefined,
114
+ ): AgentReadiness {
115
+ const binding = resolveAgentRuntimeBinding(effective, agentName);
116
+
117
+ if (!binding.ok) {
118
+ return {
119
+ status: binding.readiness === 'disabled' ? 'disabled' : binding.readiness === 'needs_setup' ? 'needs_setup' : 'not_ready',
120
+ reason: binding.reason,
121
+ nextAction: binding.nextAction,
122
+ };
123
+ }
124
+
125
+ const profile: RuntimeProfile = binding.profile;
126
+ const profileType = profile.type;
127
+
128
+ // RuleHost pipeline constructs PiAiRuntimeAdapter instances, so the profile
129
+ // must be pi-ai. OpenClaw profiles delegate to OpenClaw's own runtime, which
130
+ // is not available in the RuleHost pipeline context.
131
+ if (profileType !== 'pi-ai') {
132
+ return {
133
+ status: 'wrong_profile_type',
134
+ reason: `Agent '${agentName}' uses profile '${binding.profileId}' with type '${profileType}', but RuleHost requires pi-ai profile type`,
135
+ nextAction: `Add a pi-ai runtime profile to .pd/config.yaml and assign it to ${agentName} via internalAgents.agents.${agentName}.runtimeProfile`,
136
+ profileId: binding.profileId,
137
+ profileType,
138
+ };
139
+ }
140
+
141
+ const readiness = checkAgentRuntimeReadiness(profile, getEnvVar);
142
+ if (readiness.readiness !== 'ready') {
143
+ return {
144
+ status: readiness.readiness === 'needs_setup' ? 'needs_setup' : 'not_ready',
145
+ reason: readiness.reason,
146
+ nextAction: readiness.nextAction,
147
+ profileId: binding.profileId,
148
+ profileType,
149
+ };
150
+ }
151
+
152
+ return {
153
+ status: 'ready',
154
+ profileId: binding.profileId,
155
+ profileType,
156
+ };
157
+ }
158
+
159
+ // ── Helpers (defined before public function to satisfy no-use-before-define) ──
160
+
161
+ interface ReadinessResultParts {
162
+ readonly agentStatuses: AgentStatusesMap;
163
+ readonly codeRuleCapability: CodeRuleCapabilityReadiness;
164
+ }
165
+
166
+ function emptyAgentStatuses(): AgentStatusesMap {
167
+ const empty: AgentReadiness = { status: 'not_ready', reason: 'not checked' };
168
+ return {
169
+ dreamer: empty,
170
+ philosopher: empty,
171
+ scribe: empty,
172
+ artificer: empty,
173
+ evaluator: empty,
174
+ };
175
+ }
176
+
177
+ function buildRefusedResult(
178
+ reason: string,
179
+ nextAction: string,
180
+ parts: ReadinessResultParts,
181
+ ): RuleHostReadinessResult {
182
+ return {
183
+ status: 'refused',
184
+ reason,
185
+ nextAction,
186
+ agentStatuses: parts.agentStatuses,
187
+ codeRuleCapability: parts.codeRuleCapability,
188
+ };
189
+ }
190
+
191
+ function buildTextPrincipleOnlyResult(
192
+ reason: string,
193
+ nextAction: string,
194
+ parts: ReadinessResultParts,
195
+ ): RuleHostReadinessResult {
196
+ return {
197
+ status: 'text_principle_only',
198
+ reason,
199
+ nextAction,
200
+ agentStatuses: parts.agentStatuses,
201
+ codeRuleCapability: parts.codeRuleCapability,
202
+ };
203
+ }
204
+
205
+ function resolveRuleHostReadinessUnchecked(
206
+ workspaceDir: string,
207
+ getEnvVar: (name: string) => string | undefined,
208
+ ): RuleHostReadinessResult {
209
+ // ── Step 1: Load config ──
210
+ const { configLoadResult } = resolveRuntimeFromPdConfig(workspaceDir, getEnvVar);
211
+
212
+ if (!configLoadResult.ok) {
213
+ const [firstError] = configLoadResult.errors;
214
+ const reason = `config_malformed: ${firstError?.reason ?? 'unknown config error'}`;
215
+ const nextAction = firstError?.nextAction ?? 'Fix .pd/config.yaml syntax and retry';
216
+ return buildRefusedResult(reason, nextAction, {
217
+ agentStatuses: emptyAgentStatuses(),
218
+ codeRuleCapability: { enabled: false, disabledReason: 'config_malformed' },
219
+ });
220
+ }
221
+
222
+ const { effective } = configLoadResult;
223
+
224
+ // ── Step 2: Check required agents (dreamer, philosopher, scribe) ──
225
+ // Start with all 5 keys set to 'not checked'; updated as each agent is checked.
226
+ const agentStatuses: AgentStatusesMap = emptyAgentStatuses();
227
+ const requiredFailures: string[] = [];
228
+
229
+ for (const agentName of REQUIRED_AGENTS) {
230
+ const readiness = checkAgentReadiness(effective, agentName, getEnvVar);
231
+ agentStatuses[agentName] = readiness;
232
+ if (readiness.status !== 'ready') {
233
+ requiredFailures.push(`${agentName}: ${readiness.reason ?? readiness.status}`);
234
+ }
235
+ }
236
+
237
+ if (requiredFailures.length > 0) {
238
+ const reason = `required_agents_not_ready: ${requiredFailures.join('; ')}`;
239
+ const nextAction = `Fix the following agent issues in .pd/config.yaml: ${requiredFailures.join('; ')}. RuleHost requires dreamer, philosopher, and scribe agents to be enabled with pi-ai runtime profiles and valid API keys.`;
240
+ return buildRefusedResult(reason, nextAction, {
241
+ agentStatuses,
242
+ codeRuleCapability: { enabled: false, disabledReason: 'required_agents_not_ready' },
243
+ });
244
+ }
245
+
246
+ // ── Step 3: Check code_rule_capability feature flag ──
247
+ const featureFlags = computeFeatureFlagsFromConfig(effective);
248
+ if (!isFeatureEnabled(featureFlags, 'code_rule_capability')) {
249
+ const reason = 'code_rule_capability feature flag is disabled';
250
+ const nextAction = "Enable code_rule_capability in .pd/config.yaml features.code_rule_capability.enabled to run the full adversarial pipeline. Text-principle-only mode is available.";
251
+ // Still check artificer/evaluator for reporting, but they don't affect the status
252
+ for (const agentName of CODE_RULE_AGENTS) {
253
+ agentStatuses[agentName] = checkAgentReadiness(effective, agentName, getEnvVar);
254
+ }
255
+ return buildTextPrincipleOnlyResult(reason, nextAction, {
256
+ agentStatuses,
257
+ codeRuleCapability: { enabled: false, disabledReason: reason },
258
+ });
259
+ }
260
+
261
+ // ── Step 4: Check code-rule agents (artificer, evaluator) ──
262
+ const codeRuleFailures: string[] = [];
263
+
264
+ for (const agentName of CODE_RULE_AGENTS) {
265
+ const readiness = checkAgentReadiness(effective, agentName, getEnvVar);
266
+ agentStatuses[agentName] = readiness;
267
+ if (readiness.status !== 'ready') {
268
+ codeRuleFailures.push(`${agentName}: ${readiness.reason ?? readiness.status}`);
269
+ }
270
+ }
271
+
272
+ if (codeRuleFailures.length > 0) {
273
+ const reason = `code_rule_agents_not_ready: ${codeRuleFailures.join('; ')}`;
274
+ const nextAction = `Fix the following agent issues to enable code-rule capability: ${codeRuleFailures.join('; ')}. Text-principle-only mode is available with the current configuration.`;
275
+ return buildTextPrincipleOnlyResult(reason, nextAction, {
276
+ agentStatuses,
277
+ codeRuleCapability: { enabled: false, disabledReason: reason },
278
+ });
279
+ }
280
+
281
+ // ── Step 5: All checks pass → ready ──
282
+ return {
283
+ status: 'ready',
284
+ reason: 'All agents ready with pi-ai profiles and valid API keys. Code-rule capability is ON.',
285
+ nextAction: 'Pass --confirm to run the full pipeline.',
286
+ agentStatuses,
287
+ codeRuleCapability: { enabled: true },
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Resolve RuleHost readiness from the workspace's .pd/config.yaml.
293
+ *
294
+ * This is the production entry point called by the `run-rulehost` handler
295
+ * BEFORE constructing any adapters. It returns a structured result so the
296
+ * handler can emit a clear status instead of an opaque adapter-resolution failure.
297
+ *
298
+ * This function NEVER throws. Any unexpected exception from config loading,
299
+ * feature-flag computation, agent checks, or getEnvVar is caught and converted
300
+ * to a `refused` result with reason + nextAction (Runtime Contract #9).
301
+ *
302
+ * @param workspaceDir - The workspace directory containing .pd/config.yaml
303
+ * @param getEnvVar - Env var accessor, defaults to process.env. Injected for testability.
304
+ * @returns Structured readiness result. Never throws.
305
+ */
306
+ export function resolveRuleHostReadiness(
307
+ workspaceDir: string,
308
+ getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
309
+ ): RuleHostReadinessResult {
310
+ try {
311
+ return resolveRuleHostReadinessUnchecked(workspaceDir, getEnvVar);
312
+ } catch (error: unknown) {
313
+ const message =
314
+ error instanceof Error && error.message.length > 0
315
+ ? error.message
316
+ : 'unknown readiness resolution error';
317
+ return buildRefusedResult(
318
+ `readiness_resolution_failed: ${message}`,
319
+ 'Fix the readiness resolution error and retry. Run `pd config doctor` for diagnostics.',
320
+ {
321
+ agentStatuses: emptyAgentStatuses(),
322
+ codeRuleCapability: { enabled: false, disabledReason: 'readiness_resolution_failed' },
323
+ },
324
+ );
325
+ }
326
+ }
@@ -13,7 +13,7 @@
13
13
  * - CLI-gate concerns (process.exit, telemetry, help text) stay at call sites.
14
14
  * This resolver throws structured `ConfigResolutionError` that handlers translate.
15
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.
16
+ * PD config via `loadPdConfig` + `computeFlagsFromLoadResult`, which are pd-cli services.
17
17
  *
18
18
  * ERR refs:
19
19
  * - ERR-001 (no any): all types explicit
@@ -34,7 +34,7 @@ import {
34
34
  } from '@principles/core/runtime-v2';
35
35
  import type { PDRuntimeAdapter, PdL2ArtifactReader, RuntimeConfig, RuntimeConfigResult } from '@principles/core/runtime-v2';
36
36
  import { loadLedger } from '@principles/core/principle-tree-ledger';
37
- import { loadEffectiveFeatureFlags } from './feature-flag-loader.js';
37
+ import { loadPdConfig, computeFlagsFromLoadResult } from './pd-config-loader.js';
38
38
  import { resolveRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
39
39
  import type { ResolvedRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
40
40
 
@@ -255,7 +255,7 @@ export function resolveRuntimeAdapterFromConfig(opts: ResolveAdapterOptions): PD
255
255
  // dreamer runner, route through the L2 multi-turn agent loop adapter.
256
256
  // Other runners (philosopher/scribe/...) stay on L1.
257
257
  if (opts.runnerKind === 'dreamer' && opts.l2ArtifactReader && opts.l2StateDir) {
258
- const effectiveFlags = loadEffectiveFeatureFlags(opts.workspaceDir);
258
+ const effectiveFlags = computeFlagsFromLoadResult(loadPdConfig(opts.workspaceDir));
259
259
  const l2FlagDef = Object.hasOwn(effectiveFlags.flags, 'l2_dreamer')
260
260
  ? effectiveFlags.flags.l2_dreamer
261
261
  : undefined;
@@ -71,6 +71,11 @@ function parseJsonObject(text: string): Record<string, unknown> {
71
71
  return parsed;
72
72
  }
73
73
 
74
+ /** Type guard: narrows `unknown` to `Record<string, unknown>` without `as`. */
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
77
+ }
78
+
74
79
  function captureStdio(fn: () => Promise<void>): Promise<StdIoState> {
75
80
  return new Promise((resolve, reject) => {
76
81
  const origExitCode = process.exitCode;
@@ -251,3 +256,275 @@ describe('handleRunRuleHost — dry-run mode output shape (with minimal pd-confi
251
256
  }
252
257
  });
253
258
  });
259
+
260
+ // ── PRI-461: readiness integration tests ──────────────────────────────────
261
+ //
262
+ // Verifies the handler emits the three readiness statuses (ready /
263
+ // text_principle_only / refused) with the correct exit codes and JSON shape.
264
+ // These tests exercise the full path: config → resolveRuleHostReadiness →
265
+ // handler output, ensuring the readiness gate is wired into production code
266
+ // (EP-02) and that refused statuses fail loud with reason + nextAction (EP-03).
267
+
268
+ function writeFullReadyConfig(workspaceDir: string): void {
269
+ const configDir = path.join(workspaceDir, '.pd');
270
+ fs.mkdirSync(configDir, { recursive: true });
271
+ const cfg = {
272
+ version: 1,
273
+ features: {
274
+ prompt: { category: 'core', enabled: true },
275
+ code_tool_hook: { category: 'core', enabled: true },
276
+ defer_archive: { category: 'core', enabled: true },
277
+ code_rule_capability: { category: 'core', enabled: true },
278
+ },
279
+ workspace: { default: workspaceDir },
280
+ runtimeProfiles: {
281
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
282
+ },
283
+ internalAgents: {
284
+ defaultRuntime: 'pi-ai.default',
285
+ agents: {
286
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
287
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
288
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
289
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
290
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
291
+ },
292
+ },
293
+ };
294
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
295
+ }
296
+
297
+ function writeTextPrincipleOnlyConfig(workspaceDir: string): void {
298
+ // code_rule_capability explicitly OFF → text_principle_only
299
+ const configDir = path.join(workspaceDir, '.pd');
300
+ fs.mkdirSync(configDir, { recursive: true });
301
+ const cfg = {
302
+ version: 1,
303
+ features: {
304
+ prompt: { category: 'core', enabled: true },
305
+ code_tool_hook: { category: 'core', enabled: true },
306
+ defer_archive: { category: 'core', enabled: true },
307
+ code_rule_capability: { category: 'core', enabled: false },
308
+ },
309
+ workspace: { default: workspaceDir },
310
+ runtimeProfiles: {
311
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
312
+ },
313
+ internalAgents: {
314
+ defaultRuntime: 'pi-ai.default',
315
+ agents: {
316
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
317
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
318
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
319
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
320
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
321
+ },
322
+ },
323
+ };
324
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
325
+ }
326
+
327
+ function writeEvaluatorDisabledConfig(workspaceDir: string): void {
328
+ const configDir = path.join(workspaceDir, '.pd');
329
+ fs.mkdirSync(configDir, { recursive: true });
330
+ const cfg = {
331
+ version: 1,
332
+ features: {
333
+ prompt: { category: 'core', enabled: true },
334
+ code_tool_hook: { category: 'core', enabled: true },
335
+ defer_archive: { category: 'core', enabled: true },
336
+ code_rule_capability: { category: 'core', enabled: true },
337
+ },
338
+ workspace: { default: workspaceDir },
339
+ runtimeProfiles: {
340
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
341
+ },
342
+ internalAgents: {
343
+ defaultRuntime: 'pi-ai.default',
344
+ agents: {
345
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
346
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
347
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
348
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
349
+ evaluator: { enabled: false, runtimeProfile: 'pi-ai.default' },
350
+ },
351
+ },
352
+ };
353
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
354
+ }
355
+
356
+ function writeRefusedConfig(workspaceDir: string): void {
357
+ // dreamer disabled → required agent missing → refused
358
+ const configDir = path.join(workspaceDir, '.pd');
359
+ fs.mkdirSync(configDir, { recursive: true });
360
+ const cfg = {
361
+ version: 1,
362
+ features: {
363
+ prompt: { category: 'core', enabled: true },
364
+ code_tool_hook: { category: 'core', enabled: true },
365
+ defer_archive: { category: 'core', enabled: true },
366
+ code_rule_capability: { category: 'core', enabled: true },
367
+ },
368
+ workspace: { default: workspaceDir },
369
+ runtimeProfiles: {
370
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
371
+ },
372
+ internalAgents: {
373
+ defaultRuntime: 'pi-ai.default',
374
+ agents: {
375
+ dreamer: { enabled: false, runtimeProfile: 'pi-ai.default' },
376
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
377
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
378
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
379
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
380
+ },
381
+ },
382
+ };
383
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
384
+ }
385
+
386
+ describe('handleRunRuleHost — PRI-461 readiness integration', () => {
387
+ let workspaceDir: string;
388
+ let savedEnv: NodeJS.ProcessEnv;
389
+
390
+ beforeEach(() => {
391
+ workspaceDir = mkTmpDir();
392
+ savedEnv = { ...process.env };
393
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';
394
+ });
395
+
396
+ afterEach(() => {
397
+ process.env = savedEnv;
398
+ try { fs.rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ }
399
+ });
400
+
401
+ // ── ready ───────────────────────────────────────────────────────────────
402
+
403
+ it('emits readiness=ready in --json dry-run when all agents and code-rule capability are ON', async () => {
404
+ writeFullReadyConfig(workspaceDir);
405
+ const { stdout, exitCode } = await captureStdio(() =>
406
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
407
+ );
408
+ const payload = parseJsonObject(stdout.trim());
409
+ expect(payload.status).toBe('dry_run');
410
+ expect(payload.readinessStatus).toBe('ready');
411
+ const readiness = payload.readiness;
412
+ expect(isRecord(readiness)).toBe(true);
413
+ if (!isRecord(readiness)) {
414
+ throw new Error('readiness is not a record');
415
+ }
416
+ expect(readiness.status).toBe('ready');
417
+ expect(exitCode).toBeUndefined();
418
+ });
419
+
420
+ it('emits readiness=ready in plain-text dry-run output', async () => {
421
+ writeFullReadyConfig(workspaceDir);
422
+ const { stdout } = await captureStdio(() =>
423
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
424
+ );
425
+ expect(stdout).toMatch(/readiness:\s*READY/i);
426
+ });
427
+
428
+ // ── text_principle_only ─────────────────────────────────────────────────
429
+
430
+ it('emits readiness=text_principle_only in --json dry-run when code_rule_capability is OFF', async () => {
431
+ writeTextPrincipleOnlyConfig(workspaceDir);
432
+ const { stdout, exitCode } = await captureStdio(() =>
433
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
434
+ );
435
+ const payload = parseJsonObject(stdout.trim());
436
+ expect(payload.status).toBe('dry_run');
437
+ expect(payload.readinessStatus).toBe('text_principle_only');
438
+ const readiness = payload.readiness;
439
+ expect(isRecord(readiness)).toBe(true);
440
+ if (!isRecord(readiness)) {
441
+ throw new Error('readiness is not a record');
442
+ }
443
+ expect(readiness.status).toBe('text_principle_only');
444
+ expect(typeof readiness.reason).toBe('string');
445
+ expect(typeof readiness.nextAction).toBe('string');
446
+ expect(exitCode).toBeUndefined();
447
+ });
448
+
449
+ it('does not reclassify evaluator-disabled text_principle_only as runtime resolution failure', async () => {
450
+ writeEvaluatorDisabledConfig(workspaceDir);
451
+ const { stdout, exitCode } = await captureStdio(() =>
452
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
453
+ );
454
+ const payload = parseJsonObject(stdout.trim());
455
+ expect(payload.status).toBe('dry_run');
456
+ expect(payload.readinessStatus).toBe('text_principle_only');
457
+ const readiness = payload.readiness;
458
+ expect(isRecord(readiness)).toBe(true);
459
+ if (!isRecord(readiness)) {
460
+ throw new Error('readiness is not a record');
461
+ }
462
+ expect(readiness.status).toBe('text_principle_only');
463
+ expect(String(readiness.reason)).toContain('evaluator');
464
+ expect(String(payload.capabilityStatus)).toContain('evaluator');
465
+ expect(exitCode).toBeUndefined();
466
+ });
467
+
468
+ it('emits readiness=text_principle_only in plain-text dry-run output', async () => {
469
+ writeTextPrincipleOnlyConfig(workspaceDir);
470
+ const { stdout } = await captureStdio(() =>
471
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
472
+ );
473
+ expect(stdout).toMatch(/readiness:\s*TEXT_PRINCIPLE_ONLY/i);
474
+ });
475
+
476
+ // ── refused ─────────────────────────────────────────────────────────────
477
+
478
+ it('exits with code=1 and emits status=refused in --json when dreamer is disabled', async () => {
479
+ writeRefusedConfig(workspaceDir);
480
+ const { stdout, exitCode } = await captureStdio(() =>
481
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
482
+ );
483
+ const payload = parseJsonObject(stdout.trim());
484
+ expect(payload.status).toBe('refused');
485
+ expect(typeof payload.reason).toBe('string');
486
+ expect(typeof payload.nextAction).toBe('string');
487
+ expect(exitCode).toBe(1);
488
+ });
489
+
490
+ it('exits with code=1 and emits REFUSED in plain-text when dreamer is disabled', async () => {
491
+ writeRefusedConfig(workspaceDir);
492
+ const { stderr, exitCode } = await captureStdio(() =>
493
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
494
+ );
495
+ expect(stderr).toMatch(/REFUSED/i);
496
+ expect(stderr).toMatch(/dreamer/i);
497
+ expect(exitCode).toBe(1);
498
+ });
499
+
500
+ it('refused status includes the full readiness object in --json output', async () => {
501
+ writeRefusedConfig(workspaceDir);
502
+ const { stdout } = await captureStdio(() =>
503
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
504
+ );
505
+ const payload = parseJsonObject(stdout.trim());
506
+ expect(payload.readiness).toBeDefined();
507
+ const readiness = payload.readiness;
508
+ expect(isRecord(readiness)).toBe(true);
509
+ if (!isRecord(readiness)) {
510
+ throw new Error('readiness is not a record');
511
+ }
512
+ expect(readiness.status).toBe('refused');
513
+ expect(readiness.agentStatuses).toBeDefined();
514
+ });
515
+
516
+ it('refused status does NOT attempt pipeline execution or adapter construction', async () => {
517
+ // If the handler tried to construct adapters with a disabled dreamer,
518
+ // resolveRunRuleHostRuntime would throw. The readiness gate must prevent
519
+ // that by exiting before resolveRunRuleHostRuntime is called.
520
+ writeRefusedConfig(workspaceDir);
521
+ const { stdout, exitCode } = await captureStdio(() =>
522
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
523
+ );
524
+ const payload = parseJsonObject(stdout.trim());
525
+ // Must be 'refused', NOT 'failed' with 'agent_runtime_resolution_failed'
526
+ expect(payload.status).toBe('refused');
527
+ expect(payload.reason).not.toMatch(/agent_runtime_resolution_failed/);
528
+ expect(exitCode).toBe(1);
529
+ });
530
+ });