@principles/pd-cli 1.147.16 → 1.147.17

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 (26) hide show
  1. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  2. package/dist/commands/runtime-internalization-run-once.js +23 -7
  3. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  4. package/dist/services/__tests__/runtime-adapter-resolver.test.js +4 -1
  5. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
  6. package/dist/services/resolve-runtime-from-pd-config.d.ts +28 -2
  7. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -1
  8. package/dist/services/resolve-runtime-from-pd-config.js +19 -6
  9. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
  10. package/dist/services/rulehost-readiness.js +1 -1
  11. package/dist/services/rulehost-readiness.js.map +1 -1
  12. package/dist/services/runtime-adapter-resolver.d.ts +14 -1
  13. package/dist/services/runtime-adapter-resolver.d.ts.map +1 -1
  14. package/dist/services/runtime-adapter-resolver.js +4 -1
  15. package/dist/services/runtime-adapter-resolver.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/commands/runtime-internalization-run-once.ts +23 -7
  18. package/src/services/__tests__/runtime-adapter-resolver.test.ts +4 -1
  19. package/src/services/resolve-runtime-from-pd-config.ts +44 -6
  20. package/src/services/rulehost-readiness.ts +1 -1
  21. package/src/services/runtime-adapter-resolver.ts +18 -2
  22. package/tests/commands/pri-393-runtime-config-unification.test.ts +13 -11
  23. package/tests/commands/runtime-internalization-run-once-language.test.ts +12 -0
  24. package/tests/commands/runtime-internalization-run-once-real-config.test.ts +237 -0
  25. package/tests/commands/runtime-internalization-run-once.test.ts +21 -5
  26. package/tests/services/resolve-runtime-from-pd-config.test.ts +3 -3
@@ -18,7 +18,7 @@ import {
18
18
  TestDoubleRuntimeAdapter,
19
19
  } from '@principles/core/runtime-v2';
20
20
  import type { WakeOnceResult, DreamerRunnerResult, PhilosopherRunnerResult, ScribeRunnerResult, ArtificerRunnerResult, EvaluatorRunnerResult, RolloutReviewerRunnerResult, PDRuntimeAdapter, PeerRunnerKind, OutputLanguage } from '@principles/core/runtime-v2';
21
- import { resolveRuntimeConfigFromPdConfig, isRuntimeConfigError } from '@principles/core/runtime-v2';
21
+ import { resolveRuntimeConfigForAgent, AGENT_NAME_FOR_TASK_KIND, isRuntimeConfigError } from '@principles/core/runtime-v2';
22
22
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
23
23
  import { readOutputLanguageFromWorkspace } from '../config-reader.js';
24
24
  import { loadPdConfig } from '../services/pd-config-loader.js';
@@ -501,13 +501,24 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
501
501
  const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
502
502
  const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
503
503
 
504
- // PRI-670: profile timeout fallback for the runner deadline. The shared
505
- // resolver reads the diagnostician binding (the binding ALL peer stages
506
- // share today) on any resolution error, fall back to the 300s default
507
- // rather than refusing the run: the flag-off/legacy behavior was 300s.
504
+ // PRI-670: profile timeout fallback for the runner deadline. PRI-719: the
505
+ // profile resolves for the SELECTED RUNNER's agent binding the shared
506
+ // resolver used to read the diagnostician binding for every stage, silently
507
+ // ignoring per-agent runtimeProfile declarations (EP002-R2 F4). On any
508
+ // resolution error, fall back to the 300s default rather than refusing the
509
+ // run: the flag-off/legacy behavior was 300s.
510
+ // PRI-719 review: explicit run-once is a PEER execution path — its scope
511
+ // is the internalization_full_chain flag, not agents[kind].enabled (the
512
+ // shipped default disables evaluator/rolloutReviewer, which must not block
513
+ // an explicit stage run). Same ignoreAgentEnabled semantics as the
514
+ // auto-consumer; `enabled` keeps gating the diagnostician bridge.
515
+ const runnerAgentName = AGENT_NAME_FOR_TASK_KIND[runnerKind];
508
516
  let profileTimeoutMs: number | undefined;
509
- if (effectiveConfig) {
510
- const resolved = resolveRuntimeConfigFromPdConfig(effectiveConfig, (name) => process.env[name]);
517
+ if (effectiveConfig && runnerAgentName !== undefined) {
518
+ const resolved = resolveRuntimeConfigForAgent(effectiveConfig, runnerAgentName, {
519
+ getEnvVar: (name) => process.env[name],
520
+ ignoreAgentEnabled: true,
521
+ });
511
522
  if (!isRuntimeConfigError(resolved)) {
512
523
  profileTimeoutMs = resolved.timeoutMs;
513
524
  }
@@ -544,6 +555,11 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
544
555
  runtimeKind,
545
556
  workspaceDir,
546
557
  runnerKind,
558
+ // PRI-719: resolve the SELECTED runner's own agent binding so its
559
+ // declared runtimeProfile governs the adapter — with the SAME
560
+ // ignoreAgentEnabled peer semantics as the timeout resolution above
561
+ // (declared profile == timeout profile == adapter profile).
562
+ ...(runnerAgentName !== undefined ? { agentName: runnerAgentName, ignoreAgentEnabled: true } : {}),
547
563
  timeoutMs: cliTimeoutMs,
548
564
  allowTestDouble: true,
549
565
  testDoublePayloadBuilder: () => buildTestDoubleAdapter(runnerKind, wakeResult.taskId),
@@ -422,7 +422,10 @@ describe('resolveRuntimeAdapterFromConfig (PRI-431)', () => {
422
422
  });
423
423
 
424
424
  expect(result).toHaveProperty('__type', 'PiAiRuntimeAdapter');
425
- expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith('/ws');
425
+ // PRI-719 (+review): the resolver threads the optional per-call options
426
+ // (agentName + ignoreAgentEnabled, both undefined when the caller does
427
+ // not select a peer stage).
428
+ expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith('/ws', { agentName: undefined, ignoreAgentEnabled: undefined });
426
429
  });
427
430
 
428
431
  it('delegates to openclaw-cli when config resolves to openclaw-cli', () => {
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import {
17
- resolveRuntimeConfigFromPdConfig,
17
+ resolveRuntimeConfigForAgent,
18
18
  isRuntimeConfigError,
19
19
  resolveAgentRuntimeBinding,
20
20
  } from '@principles/core/runtime-v2';
@@ -23,6 +23,7 @@ import type {
23
23
  RuntimeConfig,
24
24
  RuntimeConfigError,
25
25
  } from '@principles/core/runtime-v2';
26
+ import type { InternalAgentName } from '@principles/core/runtime-v2';
26
27
  import { loadPdConfig } from './pd-config-loader.js';
27
28
  import type { PdConfigLoadResult } from './pd-config-loader.js';
28
29
 
@@ -64,6 +65,31 @@ function buildProfileLabel(profileId: string, profile: { type: string; provider?
64
65
  return `pi-ai: ${profile.provider ?? 'unknown'}/${profile.model ?? 'unknown'}`;
65
66
  }
66
67
 
68
+ /**
69
+ * PRI-719: per-call resolution options for resolveRuntimeFromPdConfig.
70
+ */
71
+ export interface ResolveRuntimeFromPdConfigOptions {
72
+ /** Env var accessor, defaults to process.env. */
73
+ readonly getEnvVar?: (name: string) => string | undefined;
74
+ /**
75
+ * Whose `internalAgents.agents[agent]` .runtimeProfile binding resolves
76
+ * (default 'diagnostician' — the pain-signal bridge path). run-once passes
77
+ * the selected runner's agent so each stage executes on ITS declared
78
+ * profile (EP002-R2 F4).
79
+ */
80
+ readonly agentName?: InternalAgentName;
81
+ /**
82
+ * PRI-719 review: resolve the binding even when the agent is disabled.
83
+ * Peer execution scope (auto-consumer AND explicit run-once) is governed
84
+ * by the internalization_full_chain FLAG, not by
85
+ * internalAgents.agents[kind].enabled — the shipped default config
86
+ * disables philosopher/evaluator/rolloutReviewer yet the full chain runs
87
+ * them. `enabled` keeps gating the diagnostician bridge (callers that
88
+ * omit this option honor it, PRI-638 semantics unchanged).
89
+ */
90
+ readonly ignoreAgentEnabled?: boolean;
91
+ }
92
+
67
93
  /**
68
94
  * Resolve runtime configuration exclusively from .pd/config.yaml.
69
95
  *
@@ -72,13 +98,15 @@ function buildProfileLabel(profileId: string, profile: { type: string; provider?
72
98
  * called by probe/run-once/diagnose/pain-retry.
73
99
  *
74
100
  * @param workspaceDir - The resolved workspace directory.
75
- * @param getEnvVar - Env var accessor, defaults to process.env.
101
+ * @param options - Per-call resolution options (defaults = diagnostician
102
+ * binding, honor enabled, process.env).
76
103
  * @returns Resolved runtime config with legacy warnings.
77
104
  */
78
105
  export function resolveRuntimeFromPdConfig(
79
106
  workspaceDir: string,
80
- getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
107
+ options: ResolveRuntimeFromPdConfigOptions = {},
81
108
  ): ResolvedRuntimeFromPdConfig {
109
+ const { getEnvVar = (name) => process.env[name], agentName = 'diagnostician', ignoreAgentEnabled = false } = options;
82
110
  const configLoadResult = loadPdConfig(workspaceDir);
83
111
 
84
112
  // Malformed config → fail loud. Do NOT fall back to defaults for execution.
@@ -110,15 +138,25 @@ export function resolveRuntimeFromPdConfig(
110
138
  };
111
139
  }
112
140
 
113
- const result = resolveRuntimeConfigFromPdConfig(configLoadResult.effective, getEnvVar);
141
+ const result = resolveRuntimeConfigForAgent(configLoadResult.effective, agentName, { getEnvVar, ignoreAgentEnabled });
114
142
 
115
143
  // PRI-402: Extract profile ID and label for probe output alignment with doctor
116
144
  let runtimeProfileId: string | null = null;
117
145
  let runtimeProfileLabel: string | null = null;
118
- const bindingResult = resolveAgentRuntimeBinding(configLoadResult.effective, 'diagnostician');
146
+ const bindingResult = resolveAgentRuntimeBinding(configLoadResult.effective, agentName);
119
147
  if (bindingResult.ok) {
120
148
  runtimeProfileId = bindingResult.profileId;
121
149
  runtimeProfileLabel = buildProfileLabel(bindingResult.profileId, bindingResult.profile);
150
+ } else if (!isRuntimeConfigError(result) && result.runtimeProfileId !== undefined) {
151
+ // PRI-719 review: on the peer path (ignoreAgentEnabled) the runtime may
152
+ // resolve fine for a shipped-disabled agent while the raw binding still
153
+ // reports disabled — derive the label from the resolved profile identity.
154
+ const { runtimeProfileId: resolvedProfileId } = result;
155
+ const profile = configLoadResult.effective.config.runtimeProfiles[resolvedProfileId];
156
+ if (resolvedProfileId !== undefined && profile) {
157
+ runtimeProfileId = resolvedProfileId;
158
+ runtimeProfileLabel = buildProfileLabel(resolvedProfileId, profile);
159
+ }
122
160
  }
123
161
 
124
162
  const legacyWarnings = configLoadResult.legacyFilesDetected.length > 0
@@ -161,7 +199,7 @@ export function resolveRuntimeWithOverrides(
161
199
  },
162
200
  getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
163
201
  ): ResolvedRuntimeFromPdConfig & { mergedConfig: RuntimeConfig | null } {
164
- const base = resolveRuntimeFromPdConfig(workspaceDir, getEnvVar);
202
+ const base = resolveRuntimeFromPdConfig(workspaceDir, { getEnvVar });
165
203
 
166
204
  if (isRuntimeConfigError(base.result)) {
167
205
  return { ...base, mergedConfig: null };
@@ -207,7 +207,7 @@ function resolveRuleHostReadinessUnchecked(
207
207
  getEnvVar: (name: string) => string | undefined,
208
208
  ): RuleHostReadinessResult {
209
209
  // ── Step 1: Load config ──
210
- const { configLoadResult } = resolveRuntimeFromPdConfig(workspaceDir, getEnvVar);
210
+ const { configLoadResult } = resolveRuntimeFromPdConfig(workspaceDir, { getEnvVar });
211
211
 
212
212
  if (!configLoadResult.ok) {
213
213
  const [firstError] = configLoadResult.errors;
@@ -32,7 +32,7 @@ import {
32
32
  isRuntimeConfigError,
33
33
  validateRuntimeConfig,
34
34
  } from '@principles/core/runtime-v2';
35
- import type { PDRuntimeAdapter, PdL2ArtifactReader, RuntimeConfig, RuntimeConfigResult } from '@principles/core/runtime-v2';
35
+ import type { PDRuntimeAdapter, PdL2ArtifactReader, RuntimeConfig, RuntimeConfigResult, InternalAgentName } from '@principles/core/runtime-v2';
36
36
  import { loadLedger } from '@principles/core/principle-tree-ledger';
37
37
  import { loadPdConfig, computeFlagsFromLoadResult } from './pd-config-loader.js';
38
38
  import { resolveRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
@@ -80,6 +80,19 @@ export interface ResolveAdapterOptions {
80
80
  workspaceDir: string;
81
81
  /** Runner kind (for L2 dreamer routing). Optional. */
82
82
  runnerKind?: string;
83
+ /**
84
+ * PRI-719: internal agent whose runtimeProfile binding resolves
85
+ * (default 'diagnostician'). run-once passes the selected runner's agent
86
+ * so each stage executes on its own declared profile.
87
+ */
88
+ agentName?: InternalAgentName;
89
+ /**
90
+ * PRI-719 review: resolve the binding even when the agent is disabled —
91
+ * peer execution scope is the internalization_full_chain flag (auto-
92
+ * consumer AND explicit run-once), not agents[kind].enabled. Omitted by
93
+ * non-peer callers (probe/diagnose) which keep honoring `enabled`.
94
+ */
95
+ ignoreAgentEnabled?: boolean;
83
96
  /** CLI timeout override. Takes precedence over config timeoutMs. */
84
97
  timeoutMs?: number;
85
98
  /**
@@ -163,7 +176,10 @@ export function resolveRuntimeAdapterFromConfig(opts: ResolveAdapterOptions): PD
163
176
  }
164
177
 
165
178
  // ── Resolve config from .pd/config.yaml (for pi-ai, openclaw-cli, config) ──
166
- const resolved = resolveRuntimeFromPdConfig(opts.workspaceDir);
179
+ const resolved = resolveRuntimeFromPdConfig(opts.workspaceDir, {
180
+ agentName: opts.agentName,
181
+ ignoreAgentEnabled: opts.ignoreAgentEnabled,
182
+ });
167
183
  const configResult: RuntimeConfigResult = resolved.result;
168
184
 
169
185
  // PRI-431 Step 1d: invoke onConfigResolved callback (for telemetry, legacyWarnings, etc.)
@@ -119,8 +119,10 @@ describe('PRI-393: runtime config unification', () => {
119
119
  }
120
120
 
121
121
  for (const block of importBlocks) {
122
- // Allow resolveRuntimeConfigFromPdConfig but NOT bare resolveRuntimeConfig
123
- if (block.includes('resolveRuntimeConfig') && !block.includes('resolveRuntimeConfigFromPdConfig')) {
122
+ // Allow the canonical resolver family — resolveRuntimeConfigFromPdConfig
123
+ // (shared diagnostician binding) and resolveRuntimeConfigForAgent (the
124
+ // PRI-719 per-agent variant) — but NOT bare legacy resolveRuntimeConfig
125
+ if (block.includes('resolveRuntimeConfig') && !block.includes('resolveRuntimeConfigFromPdConfig') && !block.includes('resolveRuntimeConfigForAgent')) {
124
126
  // This is the legacy import — fail
125
127
  expect.fail(
126
128
  `${file} still imports legacy resolveRuntimeConfig from @principles/core/runtime-v2. ` +
@@ -212,7 +214,7 @@ describe('PRI-393: runtime config unification', () => {
212
214
 
213
215
  // Dynamically import to avoid module resolution issues
214
216
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
215
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
217
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
216
218
 
217
219
  expect(resolved.configSource).toBe('.pd/config.yaml');
218
220
  expect(resolved.result).toBeDefined();
@@ -238,7 +240,7 @@ funnels:
238
240
  `);
239
241
 
240
242
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
241
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
243
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
242
244
 
243
245
  // Should have legacy warning
244
246
  expect(resolved.legacyWarnings.length).toBeGreaterThan(0);
@@ -259,7 +261,7 @@ funnels:
259
261
 
260
262
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
261
263
  const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
262
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
264
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
263
265
 
264
266
  // Malformed config must produce a RuntimeConfigError — never fall back to defaults
265
267
  expect(resolved.configLoadResult.ok).toBe(false);
@@ -277,7 +279,7 @@ funnels:
277
279
  writeConfigYaml(tmpDir, makeValidConfigYaml());
278
280
 
279
281
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
280
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
282
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
281
283
 
282
284
  const jsonStr = JSON.stringify(resolved.result);
283
285
  const parsed = JSON.parse(jsonStr);
@@ -355,7 +357,7 @@ funnels:
355
357
  it('handles empty workspace directory gracefully', async () => {
356
358
  // No .pd directory at all
357
359
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
358
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
360
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
359
361
 
360
362
  // Missing config should return defaults (ok=true with source='defaults')
361
363
  expect(resolved.configLoadResult.ok).toBe(true);
@@ -371,7 +373,7 @@ funnels:
371
373
 
372
374
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
373
375
  const { isRuntimeConfigError } = await import('@principles/core/runtime-v2');
374
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
376
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
375
377
 
376
378
  // Missing runtimeProfiles should either load with defaults or produce
377
379
  // a RuntimeConfigError — never silently succeed with wrong config.
@@ -400,7 +402,7 @@ funnels:
400
402
 
401
403
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
402
404
  const { isRuntimeConfigError } = await import('@principles/core/runtime-v2');
403
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
405
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
404
406
 
405
407
  // Invalid type should be caught — verify external contract, not internal state
406
408
  expect(isRuntimeConfigError(resolved.result)).toBe(true);
@@ -414,7 +416,7 @@ funnels:
414
416
  writeConfigYaml(tmpDir, makeValidConfigYaml());
415
417
 
416
418
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
417
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => undefined);
419
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => undefined });
418
420
 
419
421
  // Should handle undefined env var gracefully
420
422
  expect(resolved.result).toBeDefined();
@@ -432,7 +434,7 @@ funnels:
432
434
  fs.writeFileSync(path.join(ffDir, 'feature-flags.yaml'), 'flags: []', 'utf8');
433
435
 
434
436
  const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
435
- const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
437
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, { getEnvVar: () => 'test-key' });
436
438
 
437
439
  expect(resolved.legacyWarnings.length).toBeGreaterThan(0);
438
440
  expect(resolved.configLoadResult.legacyFilesDetected.length).toBeGreaterThanOrEqual(2);
@@ -187,6 +187,18 @@ vi.mock('@principles/core/runtime-v2', () => ({
187
187
  return { validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }) };
188
188
  }),
189
189
  resolveRuntimeConfigFromPdConfig: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
190
+ // PRI-719: run-once resolves the SELECTED runner's agent binding through
191
+ // the per-agent variant; mirror the real taskKind→agent mapping (whole
192
+ // core module is mocked here).
193
+ resolveRuntimeConfigForAgent: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
194
+ AGENT_NAME_FOR_TASK_KIND: {
195
+ dreamer: 'dreamer',
196
+ philosopher: 'philosopher',
197
+ scribe: 'scribe',
198
+ artificer: 'artificer',
199
+ evaluator: 'evaluator',
200
+ rollout_reviewer: 'rolloutReviewer',
201
+ },
190
202
  resolveRuntimeConfig: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', timeoutMs: 300_000, agentId: 'main', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY' }),
191
203
  validateRuntimeConfig: vi.fn(),
192
204
  isRuntimeConfigError: vi.fn().mockImplementation((result: unknown) => result != null && typeof result === 'object' && Object.hasOwn(result, 'reason') && !Object.hasOwn(result, 'runtimeKind')),
@@ -0,0 +1,237 @@
1
+ /**
2
+ * PRI-719 review — real-config composition tests (NO core module mock).
3
+ *
4
+ * The run-once unit suites mock `@principles/core/runtime-v2`, which cannot
5
+ * prove real default-config behavior. These tests drive the REAL resolver
6
+ * chain (resolveRuntimeFromPdConfig → resolveRuntimeConfigForAgent →
7
+ * resolveRuntimeAdapterFromConfig) against real workspaces built from
8
+ * `getDefaultPdConfig()`:
9
+ *
10
+ * Case 1 evaluator shipped disabled + explicit runtimeProfile → an
11
+ * explicit peer run-once resolves ITS profile (no
12
+ * "Agent 'evaluator' is disabled"), because peer execution scope
13
+ * is the internalization_full_chain flag — the same
14
+ * ignoreAgentEnabled semantics the auto-consumer uses.
15
+ * Case 2 rolloutReviewer shipped disabled — same resolution.
16
+ * Case 3 per-agent isolation: diagnostician→profile-A, evaluator→B;
17
+ * the run-once seam resolves profile-B.
18
+ * Case 4 the diagnostician bridge gate still HONORS enabled (no global
19
+ * bypass leaked from the peer option).
20
+ * B7 cross-entry equivalence: run-once path and auto-consumer path
21
+ * resolve IDENTICAL runtimeProfileId/provider/model for the same
22
+ * config (runtime binding must not drift across entry points).
23
+ */
24
+ import { describe, expect, it } from 'vitest';
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import {
29
+ computeEffectivePdConfig,
30
+ getDefaultPdConfig,
31
+ resolveRuntimeConfigForAgent,
32
+ AGENT_NAME_FOR_TASK_KIND,
33
+ type RuntimeConfigError,
34
+ } from '@principles/core/runtime-v2';
35
+ import { resolveRuntimeFromPdConfig } from '../../src/services/resolve-runtime-from-pd-config.js';
36
+ import { resolveRuntimeAdapterFromConfig } from '../../src/services/runtime-adapter-resolver.js';
37
+
38
+ const dirs: string[] = [];
39
+
40
+ function makeWorkspace(): string {
41
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-runonce-profile-'));
42
+ dirs.push(root);
43
+ fs.mkdirSync(path.join(root, '.pd'), { recursive: true });
44
+ return root;
45
+ }
46
+
47
+ function writeProfileConfig(root: string, agents: Record<string, string>): void {
48
+ const config = getDefaultPdConfig();
49
+ (config.runtimeProfiles as Record<string, unknown>) = {
50
+ ...(config.runtimeProfiles as Record<string, unknown>),
51
+ 'profile-diagnostician': {
52
+ type: 'pi-ai', provider: 'prov-diag', model: 'diag-model', apiKeyEnv: 'DIAG_KEY',
53
+ baseUrl: 'http://127.0.0.1:9001/v1',
54
+ },
55
+ 'profile-evaluator': {
56
+ type: 'pi-ai', provider: 'prov-evaluator', model: 'evaluator-model', apiKeyEnv: 'EVAL_KEY',
57
+ baseUrl: 'http://127.0.0.1:9002/v1',
58
+ },
59
+ 'profile-rollout': {
60
+ type: 'pi-ai', provider: 'prov-rollout', model: 'rollout-model', apiKeyEnv: 'ROLLOUT_KEY',
61
+ baseUrl: 'http://127.0.0.1:9003/v1',
62
+ },
63
+ };
64
+ const agentBindings = config.internalAgents.agents as Record<string, { enabled: boolean; runtimeProfile?: string }>;
65
+ // The shipped default config pins every agent's runtimeProfile — replace
66
+ // the DECLARED profiles per the scenario; enabled flags stay at their
67
+ // shipped values (evaluator/rolloutReviewer/philosopher = false).
68
+ for (const [agent, profileId] of Object.entries(agents)) {
69
+ agentBindings[agent] = { ...agentBindings[agent], runtimeProfile: profileId };
70
+ }
71
+ // JSON is valid YAML — same trick the sibling composition tests use.
72
+ fs.writeFileSync(path.join(root, '.pd', 'config.yaml'), JSON.stringify(config));
73
+ }
74
+
75
+ function readAdapterConfig(adapter: unknown): { provider?: string; model?: string } {
76
+ return (adapter as unknown as { config: { provider?: string; model?: string } }).config;
77
+ }
78
+
79
+ function isErr(result: unknown): RuntimeConfigError {
80
+ return result as RuntimeConfigError;
81
+ }
82
+
83
+ describe('PRI-719 review — explicit run-once resolves shipped-disabled peer agents on their own profile (real config)', () => {
84
+ it('Case 1: evaluator enabled=false (default) + declared profile → run-once seam resolves profile-evaluator', () => {
85
+ const ws = makeWorkspace();
86
+ writeProfileConfig(ws, { evaluator: 'profile-evaluator' });
87
+ process.env.EVAL_KEY = 'test-key';
88
+
89
+ try {
90
+ const resolved = resolveRuntimeFromPdConfig(ws, {
91
+ agentName: 'evaluator',
92
+ ignoreAgentEnabled: true,
93
+ });
94
+ expect(resolved.result).not.toHaveProperty('ok');
95
+ if (resolved.result.ok === false) return;
96
+ expect(resolved.result.runtimeKind).toBe('pi-ai');
97
+ expect(resolved.result.provider).toBe('prov-evaluator');
98
+ expect(resolved.result.model).toBe('evaluator-model');
99
+ expect(resolved.result.runtimeProfileId).toBe('profile-evaluator');
100
+
101
+ // The run-once ADAPTER seam resolves the same profile into the adapter.
102
+ const adapter = resolveRuntimeAdapterFromConfig({
103
+ runtimeKind: 'config',
104
+ workspaceDir: ws,
105
+ runnerKind: 'evaluator',
106
+ agentName: 'evaluator',
107
+ ignoreAgentEnabled: true,
108
+ });
109
+ // The real (unmocked) adapter has no `__type` marker — assert via kind().
110
+ expect(adapter.kind()).toBe('pi-ai');
111
+ expect(readAdapterConfig(adapter)).toMatchObject({
112
+ provider: 'prov-evaluator', model: 'evaluator-model',
113
+ });
114
+ } finally {
115
+ delete process.env.EVAL_KEY;
116
+ }
117
+ });
118
+
119
+ it('Case 2: rolloutReviewer enabled=false (default) → run-once seam resolves profile-rollout', () => {
120
+ const ws = makeWorkspace();
121
+ writeProfileConfig(ws, { rolloutReviewer: 'profile-rollout' });
122
+ process.env.ROLLOUT_KEY = 'test-key';
123
+
124
+ try {
125
+ // Through the canonical taskKind→agent map, as run-once does.
126
+ const agentName = AGENT_NAME_FOR_TASK_KIND['rollout_reviewer'];
127
+ expect(agentName).toBe('rolloutReviewer');
128
+ const resolved = resolveRuntimeFromPdConfig(ws, { agentName, ignoreAgentEnabled: true });
129
+ if (resolved.result.ok === false) throw new Error(resolved.result.message);
130
+ expect(resolved.result.provider).toBe('prov-rollout');
131
+ expect(resolved.result.runtimeProfileId).toBe('profile-rollout');
132
+ } finally {
133
+ delete process.env.ROLLOUT_KEY;
134
+ }
135
+ });
136
+
137
+ it('Case 3: per-agent isolation — evaluator resolves ITS profile, not the diagnostician one', () => {
138
+ const ws = makeWorkspace();
139
+ writeProfileConfig(ws, { diagnostician: 'profile-diagnostician', evaluator: 'profile-evaluator' });
140
+ process.env.DIAG_KEY = 'test-key';
141
+ process.env.EVAL_KEY = 'test-key';
142
+
143
+ try {
144
+ const adapter = resolveRuntimeAdapterFromConfig({
145
+ runtimeKind: 'config',
146
+ workspaceDir: ws,
147
+ runnerKind: 'evaluator',
148
+ agentName: 'evaluator',
149
+ ignoreAgentEnabled: true,
150
+ });
151
+ expect(readAdapterConfig(adapter)).toMatchObject({
152
+ provider: 'prov-evaluator', model: 'evaluator-model',
153
+ });
154
+ } finally {
155
+ delete process.env.DIAG_KEY;
156
+ delete process.env.EVAL_KEY;
157
+ }
158
+ });
159
+
160
+ it('negative control: without ignoreAgentEnabled the shipped-disabled evaluator still refuses', () => {
161
+ const ws = makeWorkspace();
162
+ writeProfileConfig(ws, { evaluator: 'profile-evaluator' });
163
+ process.env.EVAL_KEY = 'test-key';
164
+
165
+ try {
166
+ // Pins that the option (not something else) lifts the gate: the same
167
+ // call without it honors enabled and refuses.
168
+ expect(() => resolveRuntimeAdapterFromConfig({
169
+ runtimeKind: 'config',
170
+ workspaceDir: ws,
171
+ runnerKind: 'evaluator',
172
+ agentName: 'evaluator',
173
+ })).toThrow(/disabled/);
174
+ } finally {
175
+ delete process.env.EVAL_KEY;
176
+ }
177
+ });
178
+
179
+ it('Case 4: diagnostician bridge gate still HONORS enabled (no global bypass)', () => {
180
+ const ws = makeWorkspace();
181
+ writeProfileConfig(ws, { diagnostician: 'profile-diagnostician' });
182
+ process.env.DIAG_KEY = 'test-key';
183
+
184
+ try {
185
+ const config = JSON.parse(fs.readFileSync(path.join(ws, '.pd', 'config.yaml'), 'utf8'));
186
+ config.internalAgents.agents.diagnostician.enabled = false;
187
+ fs.writeFileSync(path.join(ws, '.pd', 'config.yaml'), JSON.stringify(config));
188
+
189
+ // Canonical diagnostician resolution (the pain-signal bridge path)
190
+ // keeps failing closed with readiness=disabled.
191
+ const viaWrapper = resolveRuntimeFromPdConfig(ws);
192
+ expect(viaWrapper.result).toHaveProperty('ok', false);
193
+ expect(isErr(viaWrapper.result).reason).toBe('disabled');
194
+
195
+ const viaCore = resolveRuntimeConfigForAgent(
196
+ computeEffectivePdConfig(config),
197
+ 'diagnostician',
198
+ { getEnvVar: (name) => process.env[name] },
199
+ );
200
+ expect(viaCore).toHaveProperty('ok', false);
201
+ expect(isErr(viaCore).reason).toBe('disabled');
202
+ } finally {
203
+ delete process.env.DIAG_KEY;
204
+ }
205
+ });
206
+
207
+ it('B7 cross-entry equivalence: run-once path and auto-consumer path resolve the SAME binding', () => {
208
+ const ws = makeWorkspace();
209
+ writeProfileConfig(ws, { diagnostician: 'profile-diagnostician', evaluator: 'profile-evaluator' });
210
+ process.env.EVAL_KEY = 'test-key';
211
+
212
+ try {
213
+ // Auto-consumer path: per-kind resolution with peer scope semantics.
214
+ const consumerResult = resolveRuntimeConfigForAgent(
215
+ computeEffectivePdConfig(JSON.parse(fs.readFileSync(path.join(ws, '.pd', 'config.yaml'), 'utf8'))),
216
+ 'evaluator',
217
+ { getEnvVar: (name) => process.env[name], ignoreAgentEnabled: true },
218
+ );
219
+
220
+ // Explicit run-once path: resolveRuntimeFromPdConfig with the same
221
+ // agent + scope semantics (exactly what run-once's two resolution
222
+ // points use).
223
+ const runOnceResult = resolveRuntimeFromPdConfig(ws, {
224
+ agentName: 'evaluator',
225
+ ignoreAgentEnabled: true,
226
+ }).result;
227
+
228
+ expect(runOnceResult).toEqual(consumerResult);
229
+ if (runOnceResult.ok === false || consumerResult.ok === false) return;
230
+ expect(runOnceResult.runtimeProfileId).toBe('profile-evaluator');
231
+ expect(runOnceResult.provider).toBe('prov-evaluator');
232
+ expect(runOnceResult.model).toBe('evaluator-model');
233
+ } finally {
234
+ delete process.env.EVAL_KEY;
235
+ }
236
+ });
237
+ });
@@ -163,6 +163,19 @@ vi.mock('@principles/core/runtime-v2', () => ({
163
163
  isRuntimeConfigError: vi.fn().mockImplementation((result: unknown) => result != null && typeof result === 'object' && Object.hasOwn(result, 'reason') && !Object.hasOwn(result, 'runtimeKind')),
164
164
  validateRuntimeConfig: vi.fn(),
165
165
  resolveRuntimeConfigFromPdConfig: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
166
+ // PRI-719: run-once resolves the SELECTED runner's agent binding through
167
+ // the per-agent variant — this is the seam the PRI-670 timeout tests mock.
168
+ resolveRuntimeConfigForAgent: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
169
+ // PRI-719: the real taskKind→agent mapping (stable constant, mirrored here
170
+ // because the whole core module is mocked in this file).
171
+ AGENT_NAME_FOR_TASK_KIND: {
172
+ dreamer: 'dreamer',
173
+ philosopher: 'philosopher',
174
+ scribe: 'scribe',
175
+ artificer: 'artificer',
176
+ evaluator: 'evaluator',
177
+ rollout_reviewer: 'rolloutReviewer',
178
+ },
166
179
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
167
180
  }));
168
181
 
@@ -508,9 +521,12 @@ describe('handleRuntimeInternalizationRunOnce', () => {
508
521
  const customWs = '/tmp/test-workspace';
509
522
  await handleRuntimeInternalizationRunOnce({ workspace: customWs, runtime: 'config', json: true });
510
523
 
511
- // PRI-393: verify resolveRuntimeFromPdConfig was called with workspace dir
524
+ // PRI-393 + PRI-719 (+review): resolveRuntimeFromPdConfig is called with
525
+ // the workspace dir AND the selected runner's agent binding under the
526
+ // PEER ignoreAgentEnabled semantics (same as the auto-consumer).
512
527
  expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith(
513
528
  expect.stringContaining('test-workspace'),
529
+ { agentName: 'dreamer', ignoreAgentEnabled: true },
514
530
  );
515
531
  });
516
532
 
@@ -824,7 +840,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
824
840
  // the PRI-670 fix the runner deadline stayed hardcoded at 300s and the
825
841
  // profile value only reached the adapter as a per-request timeout.
826
842
  const coreModule = await import('@principles/core/runtime-v2');
827
- vi.mocked(coreModule.resolveRuntimeConfigFromPdConfig).mockReturnValue({
843
+ vi.mocked(coreModule.resolveRuntimeConfigForAgent).mockReturnValue({
828
844
  runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model',
829
845
  apiKeyEnv: 'TEST_API_KEY', timeoutMs: 600_000, agentId: 'main',
830
846
  } as never);
@@ -857,7 +873,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
857
873
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
858
874
  expect(output.effectiveTimeoutMs).toBe(600_000);
859
875
  } finally {
860
- vi.mocked(coreModule.resolveRuntimeConfigFromPdConfig).mockReturnValue({
876
+ vi.mocked(coreModule.resolveRuntimeConfigForAgent).mockReturnValue({
861
877
  runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model',
862
878
  apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main',
863
879
  } as never);
@@ -866,7 +882,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
866
882
 
867
883
  it('PRI-670: --timeout-ms still wins over the profile timeoutMs', async () => {
868
884
  const coreModule = await import('@principles/core/runtime-v2');
869
- vi.mocked(coreModule.resolveRuntimeConfigFromPdConfig).mockReturnValue({
885
+ vi.mocked(coreModule.resolveRuntimeConfigForAgent).mockReturnValue({
870
886
  runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model',
871
887
  apiKeyEnv: 'TEST_API_KEY', timeoutMs: 600_000, agentId: 'main',
872
888
  } as never);
@@ -892,7 +908,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
892
908
  const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
893
909
  expect(output.effectiveTimeoutMs).toBe(120_000);
894
910
  } finally {
895
- vi.mocked(coreModule.resolveRuntimeConfigFromPdConfig).mockReturnValue({
911
+ vi.mocked(coreModule.resolveRuntimeConfigForAgent).mockReturnValue({
896
912
  runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model',
897
913
  apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main',
898
914
  } as never);