principles-disciple 1.87.0 → 1.89.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.
@@ -2,8 +2,8 @@
2
2
  * PRI-288: Quarantine EvolutionWorkerService default startup behind MVP feature flag.
3
3
  *
4
4
  * Tests prove:
5
- * 1. Default config (no feature-flags.yaml) → EvolutionWorkerService does NOT start.
6
- * 2. Explicit enable in feature-flags.yaml → EvolutionWorkerService starts.
5
+ * 1. Default config (no config.yaml) → EvolutionWorkerService does NOT start.
6
+ * 2. Explicit enable in config.yaml → EvolutionWorkerService starts.
7
7
  * 3. Disabled state has structured observability from real helper, not hand-written JSON.
8
8
  * 4. api.registerService still works regardless of flag state.
9
9
  *
@@ -60,9 +60,63 @@ function createTempWorkspace(): string {
60
60
  return dir;
61
61
  }
62
62
 
63
- function writeFeatureFlags(workspaceDir: string, flags: Record<string, unknown>): void {
64
- const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
65
- const content = yaml.dump(flags, { schema: yaml.JSON_SCHEMA });
63
+ function deepMergeFeatures(
64
+ defaults: Record<string, unknown>,
65
+ overrides: Record<string, unknown>,
66
+ ): Record<string, unknown> {
67
+ const result: Record<string, unknown> = { ...defaults };
68
+ for (const [key, value] of Object.entries(overrides)) {
69
+ if (
70
+ value != null &&
71
+ typeof value === 'object' &&
72
+ !Array.isArray(value) &&
73
+ Object.hasOwn(result, key) &&
74
+ result[key] != null &&
75
+ typeof result[key] === 'object' &&
76
+ !Array.isArray(result[key])
77
+ ) {
78
+ result[key] = { ...(result[key] as Record<string, unknown>), ...(value as Record<string, unknown>) };
79
+ } else {
80
+ result[key] = value;
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+
86
+ function writeConfigYaml(workspaceDir: string, featureOverrides: Record<string, unknown>): void {
87
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
88
+ const defaultFeatures: Record<string, unknown> = {
89
+ prompt: { category: 'core', enabled: true },
90
+ code_tool_hook: { category: 'core', enabled: true },
91
+ defer_archive: { category: 'core', enabled: true },
92
+ correction_observer: { category: 'quiet', enabled: false },
93
+ empathy_observer: { category: 'quiet', enabled: false },
94
+ evolution_worker: { category: 'quiet', enabled: false },
95
+ nocturnal: { category: 'gone', enabled: false },
96
+ };
97
+ const config = {
98
+ version: 1,
99
+ features: deepMergeFeatures(defaultFeatures, featureOverrides),
100
+ runtimeProfiles: {
101
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
102
+ },
103
+ internalAgents: {
104
+ defaultRuntime: 'openclaw.default',
105
+ agents: {
106
+ diagnostician: { enabled: true },
107
+ dreamer: { enabled: true },
108
+ scribe: { enabled: true },
109
+ artificer: { enabled: true },
110
+ philosopher: { enabled: false },
111
+ evaluator: { enabled: false },
112
+ rolloutReviewer: { enabled: false },
113
+ trainer: { enabled: false },
114
+ correctionObserver: { enabled: false },
115
+ empathyObserver: { enabled: false },
116
+ },
117
+ },
118
+ };
119
+ const content = yaml.dump(config, { schema: yaml.JSON_SCHEMA });
66
120
  fs.writeFileSync(configPath, content, 'utf8');
67
121
  }
68
122
 
@@ -112,32 +166,32 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
112
166
  // ── 2. loadFeatureFlagFromWorkspace ──
113
167
 
114
168
  describe('loadFeatureFlagFromWorkspace', () => {
115
- it('returns enabled=false when no feature-flags.yaml exists', () => {
169
+ it('returns enabled=false when no config.yaml exists', () => {
116
170
  const logger = createMockLogger();
117
171
  const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
118
172
  expect(result.enabled).toBe(false);
119
173
  expect(result.source).toBe('defaults');
120
174
  });
121
175
 
122
- it('returns enabled=false when feature-flags.yaml has no evolution_worker entry', () => {
123
- writeFeatureFlags(workspaceDir, { prompt: { enabled: true } });
176
+ it('returns enabled=false when config.yaml has no evolution_worker entry', () => {
177
+ writeConfigYaml(workspaceDir, { prompt: { enabled: true } });
124
178
  const logger = createMockLogger();
125
179
  const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
126
180
  expect(result.enabled).toBe(false);
127
181
  });
128
182
 
129
- it('returns enabled=true when feature-flags.yaml explicitly enables evolution_worker', () => {
130
- writeFeatureFlags(workspaceDir, {
183
+ it('returns enabled=true when config.yaml explicitly enables evolution_worker', () => {
184
+ writeConfigYaml(workspaceDir, {
131
185
  evolution_worker: { enabled: true },
132
186
  });
133
187
  const logger = createMockLogger();
134
188
  const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
135
189
  expect(result.enabled).toBe(true);
136
- expect(result.source).toBe('workspace_file');
190
+ expect(result.source).toBe('user_config');
137
191
  });
138
192
 
139
193
  it('returns enabled=false when YAML is malformed and warning includes error detail', () => {
140
- const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
194
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
141
195
  fs.writeFileSync(configPath, ' bad: [yaml: content', 'utf8');
142
196
  const logger = createMockLogger();
143
197
  const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
@@ -152,7 +206,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
152
206
 
153
207
  it('returns defaults when file is unreadable', () => {
154
208
  // Create a directory where a file should be — causes read error
155
- const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
209
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
156
210
  fs.mkdirSync(configPath, { recursive: true });
157
211
  const logger = createMockLogger();
158
212
  const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
@@ -162,7 +216,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
162
216
 
163
217
  it('rejects dangerous keys (__proto__) and does not enable via prototype pollution', () => {
164
218
  // Write raw YAML with __proto__ to test dangerous key rejection on raw parsed output
165
- writeFeatureFlags(workspaceDir, {
219
+ writeConfigYaml(workspaceDir, {
166
220
  __proto__: { enabled: true },
167
221
  evolution_worker: { enabled: false },
168
222
  });
@@ -175,7 +229,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
175
229
  // ── 3. shouldStartEvolutionWorker — real helper, real output ──
176
230
 
177
231
  describe('shouldStartEvolutionWorker gate helper', () => {
178
- it('returns shouldStart=false by default (no feature-flags.yaml)', () => {
232
+ it('returns shouldStart=false by default (no config.yaml)', () => {
179
233
  const logger = createMockLogger();
180
234
  const gate = shouldStartEvolutionWorker(workspaceDir, logger);
181
235
  expect(gate.shouldStart).toBe(false);
@@ -184,13 +238,13 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
184
238
  });
185
239
 
186
240
  it('returns shouldStart=true when explicitly enabled', () => {
187
- writeFeatureFlags(workspaceDir, {
241
+ writeConfigYaml(workspaceDir, {
188
242
  evolution_worker: { enabled: true },
189
243
  });
190
244
  const logger = createMockLogger();
191
245
  const gate = shouldStartEvolutionWorker(workspaceDir, logger);
192
246
  expect(gate.shouldStart).toBe(true);
193
- expect(gate.flagSource).toBe('workspace_file');
247
+ expect(gate.flagSource).toBe('user_config');
194
248
  expect(gate.disabledInfo).toBeNull();
195
249
  });
196
250
 
@@ -248,18 +302,18 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
248
302
 
249
303
  describe('explicit enable: worker starts', () => {
250
304
  it('shouldStartEvolutionWorker returns true when enabled in config', () => {
251
- writeFeatureFlags(workspaceDir, {
305
+ writeConfigYaml(workspaceDir, {
252
306
  evolution_worker: { enabled: true },
253
307
  });
254
308
 
255
309
  const logger = createMockLogger();
256
310
  const gate = shouldStartEvolutionWorker(workspaceDir, logger);
257
311
  expect(gate.shouldStart).toBe(true);
258
- expect(gate.flagSource).toBe('workspace_file');
312
+ expect(gate.flagSource).toBe('user_config');
259
313
  });
260
314
 
261
315
  it('EvolutionWorkerService.start actually runs when gate is true', () => {
262
- writeFeatureFlags(workspaceDir, {
316
+ writeConfigYaml(workspaceDir, {
263
317
  evolution_worker: { enabled: true },
264
318
  });
265
319
 
@@ -292,17 +346,18 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
292
346
  });
293
347
 
294
348
  it('computeEffectiveFlags preserves core flags even with evolution_worker override', () => {
295
- writeFeatureFlags(workspaceDir, {
349
+ writeConfigYaml(workspaceDir, {
296
350
  evolution_worker: { enabled: true },
297
351
  });
298
352
 
299
- const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
353
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
300
354
  const raw = fs.readFileSync(configPath, 'utf8');
301
355
  const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
302
356
 
303
357
  // Use isRecord type guard instead of `as`
304
358
  expect(isRecord(parsed)).toBe(true);
305
- const flags = computeEffectiveFlags(parsed as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
359
+ const features = (parsed as Record<string, unknown>).features;
360
+ const flags = computeEffectiveFlags(features as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
306
361
  expect(flags.flags['prompt']?.enabled).toBe(true);
307
362
  expect(flags.flags['code_tool_hook']?.enabled).toBe(true);
308
363
  expect(flags.flags['defer_archive']?.enabled).toBe(true);
@@ -310,17 +365,18 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
310
365
  });
311
366
 
312
367
  it('core flags cannot be disabled by user override', () => {
313
- writeFeatureFlags(workspaceDir, {
368
+ writeConfigYaml(workspaceDir, {
314
369
  prompt: { enabled: false },
315
370
  code_tool_hook: { enabled: false },
316
371
  });
317
372
 
318
- const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
373
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
319
374
  const raw = fs.readFileSync(configPath, 'utf8');
320
375
  const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
321
376
 
322
377
  expect(isRecord(parsed)).toBe(true);
323
- const flags = computeEffectiveFlags(parsed as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
378
+ const features = (parsed as Record<string, unknown>).features;
379
+ const flags = computeEffectiveFlags(features as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
324
380
  expect(flags.flags['prompt']?.enabled).toBe(true); // core cannot be disabled
325
381
  expect(flags.flags['code_tool_hook']?.enabled).toBe(true); // core cannot be disabled
326
382
  expect(flags.warnings.length).toBeGreaterThan(0); // warnings about core override attempt
@@ -331,7 +387,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
331
387
 
332
388
  describe('no confirm-first gate regression', () => {
333
389
  it('no PLAN.md or confirm-first files are created in workspace', () => {
334
- writeFeatureFlags(workspaceDir, {
390
+ writeConfigYaml(workspaceDir, {
335
391
  evolution_worker: { enabled: false },
336
392
  });
337
393
 
@@ -28,9 +28,63 @@ function createTempWorkspace(): string {
28
28
  return dir;
29
29
  }
30
30
 
31
- function writeFeatureFlags(workspaceDir: string, flags: Record<string, unknown>): void {
32
- const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
33
- const content = yaml.dump(flags, { schema: yaml.JSON_SCHEMA });
31
+ function deepMergeFeatures(
32
+ defaults: Record<string, unknown>,
33
+ overrides: Record<string, unknown>,
34
+ ): Record<string, unknown> {
35
+ const result: Record<string, unknown> = { ...defaults };
36
+ for (const [key, value] of Object.entries(overrides)) {
37
+ if (
38
+ value != null &&
39
+ typeof value === 'object' &&
40
+ !Array.isArray(value) &&
41
+ Object.hasOwn(result, key) &&
42
+ result[key] != null &&
43
+ typeof result[key] === 'object' &&
44
+ !Array.isArray(result[key])
45
+ ) {
46
+ result[key] = { ...(result[key] as Record<string, unknown>), ...(value as Record<string, unknown>) };
47
+ } else {
48
+ result[key] = value;
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function writeConfigYaml(workspaceDir: string, featureOverrides: Record<string, unknown>): void {
55
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
56
+ const defaultFeatures: Record<string, unknown> = {
57
+ prompt: { category: 'core', enabled: true },
58
+ code_tool_hook: { category: 'core', enabled: true },
59
+ defer_archive: { category: 'core', enabled: true },
60
+ correction_observer: { category: 'quiet', enabled: false },
61
+ empathy_observer: { category: 'quiet', enabled: false },
62
+ evolution_worker: { category: 'quiet', enabled: false },
63
+ nocturnal: { category: 'gone', enabled: false },
64
+ };
65
+ const config = {
66
+ version: 1,
67
+ features: deepMergeFeatures(defaultFeatures, featureOverrides),
68
+ runtimeProfiles: {
69
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
70
+ },
71
+ internalAgents: {
72
+ defaultRuntime: 'openclaw.default',
73
+ agents: {
74
+ diagnostician: { enabled: true },
75
+ dreamer: { enabled: true },
76
+ scribe: { enabled: true },
77
+ artificer: { enabled: true },
78
+ philosopher: { enabled: false },
79
+ evaluator: { enabled: false },
80
+ rolloutReviewer: { enabled: false },
81
+ trainer: { enabled: false },
82
+ correctionObserver: { enabled: false },
83
+ empathyObserver: { enabled: false },
84
+ },
85
+ },
86
+ };
87
+ const content = yaml.dump(config, { schema: yaml.JSON_SCHEMA });
34
88
  fs.writeFileSync(configPath, content, 'utf8');
35
89
  }
36
90
 
@@ -151,6 +205,9 @@ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () =>
151
205
  });
152
206
 
153
207
  it('CorrectionObserver starts when EvolutionWorker is disabled (default)', () => {
208
+ writeConfigYaml(workspaceDir, {
209
+ correction_observer: { enabled: true },
210
+ });
154
211
  const logger = createMockLogger();
155
212
 
156
213
  // Verify EvolutionWorker is disabled
@@ -164,8 +221,9 @@ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () =>
164
221
  });
165
222
 
166
223
  it('CorrectionObserver starts when EvolutionWorker is explicitly enabled', () => {
167
- writeFeatureFlags(workspaceDir, {
224
+ writeConfigYaml(workspaceDir, {
168
225
  evolution_worker: { enabled: true },
226
+ correction_observer: { enabled: true },
169
227
  });
170
228
  const logger = createMockLogger();
171
229
 
@@ -177,7 +235,7 @@ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () =>
177
235
  });
178
236
 
179
237
  it('CorrectionObserver can be independently disabled', () => {
180
- writeFeatureFlags(workspaceDir, {
238
+ writeConfigYaml(workspaceDir, {
181
239
  correction_observer: { enabled: false },
182
240
  });
183
241
  const logger = createMockLogger();
@@ -494,9 +494,11 @@ describe('Runtime V2 prompt activation — additional guard tests', () => {
494
494
 
495
495
  it('malformed DB/config input fails loud with warning', async () => {
496
496
  const pdDir = path.join(tempWorkspaceDir, '.pd');
497
+ // PRI-305/PRI-307: Write a malformed .pd/config.yaml with dangerous keys
498
+ // The core validator rejects __proto__ and constructor as dangerous keys
497
499
  fs.writeFileSync(
498
- path.join(pdDir, 'feature-flags.yaml'),
499
- '__proto__:\n enabled: true\nprompt:\n enabled: true\nconstructor:\n enabled: false\n',
500
+ path.join(pdDir, 'config.yaml'),
501
+ 'version: 1\nfeatures:\n __proto__:\n category: core\n enabled: true\n prompt:\n category: core\n enabled: true\n constructor:\n category: core\n enabled: false\nruntimeProfiles:\n openclaw.default:\n type: openclaw\n source: default\ninternalAgents:\n defaultRuntime: openclaw.default\n agents:\n diagnostician:\n enabled: true\n dreamer:\n enabled: true\n scribe:\n enabled: true\n artificer:\n enabled: true\n philosopher:\n enabled: false\n evaluator:\n enabled: false\n rolloutReviewer:\n enabled: false\n trainer:\n enabled: false\n correctionObserver:\n enabled: false\n empathyObserver:\n enabled: false\n',
500
502
  'utf8',
501
503
  );
502
504
 
@@ -507,10 +509,14 @@ describe('Runtime V2 prompt activation — additional guard tests', () => {
507
509
  const result = await reader.readActivatedPrinciples();
508
510
 
509
511
  const warnCalls = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
512
+ // PRI-305/PRI-307: Core validator rejects dangerous keys as errors.
513
+ // The plugin config loader logs config errors as warnings.
510
514
  const hasDangerousKeyWarning = warnCalls.some(
511
- (c: string) => c.includes('dangerous key') || c.includes('__proto__') || c.includes('constructor'),
515
+ (c: string) => c.includes('dangerous key') || c.includes('__proto__') || c.includes('constructor') || c.includes('Config error'),
512
516
  );
513
517
  expect(hasDangerousKeyWarning).toBe(true);
518
+ // With malformed config, defaults are used (prompt enabled by default),
519
+ // but no DB data exists, so principles should be empty
514
520
  expect(result.principles).toEqual([]);
515
521
  });
516
522
 
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ import * as yaml from 'js-yaml';
5
6
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
6
7
 
7
8
  const mockLearner = {
@@ -35,6 +36,89 @@ vi.mock('../../src/service/keyword-optimization-service.js', () => ({
35
36
  KeywordOptimizationService: { get: vi.fn(() => mockOptimizationService) },
36
37
  }));
37
38
 
39
+ // PRI-307: Mock the pd-config-loader instead of @principles/core/runtime-v2
40
+ // The service now reads .pd/config.yaml via resolveObserverConfig
41
+ vi.mock('../../src/core/pd-config-loader.js', () => {
42
+ return {
43
+ loadPdConfigForPlugin: vi.fn(() => ({
44
+ ok: true,
45
+ effective: {
46
+ config: {
47
+ version: 1,
48
+ features: {
49
+ prompt: { category: 'core', enabled: true },
50
+ code_tool_hook: { category: 'core', enabled: true },
51
+ defer_archive: { category: 'core', enabled: true },
52
+ correction_observer: { category: 'quiet', enabled: true },
53
+ empathy_observer: { category: 'quiet', enabled: false },
54
+ },
55
+ runtimeProfiles: {
56
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
57
+ 'pd.anthropic-sonnet': { type: 'pi-ai', provider: 'anthropic', model: 'claude-3-5-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY', timeoutMs: 30000 },
58
+ },
59
+ internalAgents: {
60
+ defaultRuntime: 'openclaw.default',
61
+ agents: {
62
+ diagnostician: { enabled: true },
63
+ dreamer: { enabled: true },
64
+ scribe: { enabled: true },
65
+ artificer: { enabled: true },
66
+ philosopher: { enabled: false },
67
+ evaluator: { enabled: false },
68
+ rolloutReviewer: { enabled: false },
69
+ trainer: { enabled: false },
70
+ correctionObserver: { enabled: true, runtimeProfile: 'pd.anthropic-sonnet' },
71
+ empathyObserver: { enabled: false },
72
+ },
73
+ },
74
+ },
75
+ warnings: [],
76
+ },
77
+ source: 'defaults',
78
+ configPath: '.pd/config.yaml',
79
+ warnings: [],
80
+ errors: [],
81
+ })),
82
+ loadFeatureFlagFromConfig: vi.fn(() => ({ enabled: true, source: 'defaults' })),
83
+ resolveObserverConfig: vi.fn((_workspaceDir: string, flagId: string, _agentName: string) => {
84
+ // Default: return disabled for correction_observer (no config file in test tmp dirs)
85
+ if (flagId === 'correction_observer') {
86
+ return {
87
+ enabled: true,
88
+ readiness: 'not_ready',
89
+ source: 'defaults',
90
+ reason: 'pi-ai profile configured with apiKeyEnv',
91
+ nextAction: 'Run pd runtime probe',
92
+ runtimeProfileId: 'pd.anthropic-sonnet',
93
+ runtimeProfileType: 'pi-ai',
94
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
95
+ apiKeyPresent: !!process.env.ANTHROPIC_API_KEY,
96
+ provider: 'anthropic',
97
+ model: 'claude-3-5-sonnet',
98
+ timeoutMs: 30000,
99
+ baseUrl: null,
100
+ };
101
+ }
102
+ return {
103
+ enabled: false,
104
+ readiness: 'disabled',
105
+ source: 'defaults',
106
+ reason: `${flagId} is disabled`,
107
+ nextAction: `Set features.${flagId}.enabled=true in .pd/config.yaml`,
108
+ runtimeProfileId: null,
109
+ runtimeProfileType: null,
110
+ apiKeyEnv: null,
111
+ apiKeyPresent: false,
112
+ provider: null,
113
+ model: null,
114
+ timeoutMs: null,
115
+ baseUrl: null,
116
+ };
117
+ }),
118
+ getPdConfigPath: vi.fn((workspaceDir: string) => path.join(workspaceDir, '.pd', 'config.yaml')),
119
+ };
120
+ });
121
+
38
122
  const mockDispatch = vi.fn().mockResolvedValue({
39
123
  updated: true,
40
124
  summary: 'Keyword store optimized',
@@ -45,23 +129,12 @@ const mockRegister = vi.fn();
45
129
 
46
130
  vi.mock('@principles/core/runtime-v2', () => {
47
131
  return {
48
- WorkflowFunnelLoader: class {
49
- getFunnel = vi.fn(() => ({
50
- policy: {
51
- runtimeKind: 'pi-ai',
52
- provider: 'anthropic',
53
- model: 'anthropic/claude-3-5-sonnet',
54
- apiKeyEnv: 'ANTHROPIC_API_KEY',
55
- timeoutMs: 30000,
56
- }
57
- }));
58
- },
59
132
  PiAiRuntimeAdapter: class {},
60
133
  CorrectionObserver: class {},
61
134
  AgentScheduler: class {
62
135
  register = mockRegister;
63
136
  dispatch = mockDispatch;
64
- }
137
+ },
65
138
  };
66
139
  });
67
140
 
@@ -330,12 +403,12 @@ describe('runCorrectionObserverCycle — Independent Execution', () => {
330
403
  });
331
404
  });
332
405
 
333
- describe('resolveCorrectionObserver — Configuration Resolution', () => {
406
+ describe('resolveCorrectionObserver — Configuration Resolution (PRI-307)', () => {
334
407
  beforeEach(() => {
335
408
  vi.clearAllMocks();
336
409
  });
337
410
 
338
- it('returns observer when API key env is set with mocked policy', async () => {
411
+ it('returns observer when API key env is set with pi-ai profile', async () => {
339
412
  const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-resolve-'));
340
413
  const stateDir = path.join(workspaceDir, '.state');
341
414
  fs.mkdirSync(stateDir, { recursive: true });
@@ -348,7 +421,7 @@ describe('resolveCorrectionObserver — Configuration Resolution', () => {
348
421
  const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
349
422
  const result = resolveCorrectionObserver(wctx, logger as any);
350
423
 
351
- // With mocked WorkflowFunnelLoader returning valid policy, should return observer
424
+ // With mocked resolveObserverConfig returning enabled + not_ready, should return observer
352
425
  expect(result).not.toBeNull();
353
426
  } finally {
354
427
  delete process.env.ANTHROPIC_API_KEY;
@@ -356,23 +429,76 @@ describe('resolveCorrectionObserver — Configuration Resolution', () => {
356
429
  }
357
430
  });
358
431
 
359
- it('returns observer when workflows.yaml provides valid policy', async () => {
360
- const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-policy-'));
432
+ it('returns null when observer is disabled in config', async () => {
433
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-disabled-'));
361
434
  const stateDir = path.join(workspaceDir, '.state');
362
435
  fs.mkdirSync(stateDir, { recursive: true });
363
436
 
364
- process.env.ANTHROPIC_API_KEY = 'test-key';
437
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
438
+
439
+ // Override the mock to return disabled
440
+ const { resolveObserverConfig } = await import('../../src/core/pd-config-loader.js');
441
+ vi.mocked(resolveObserverConfig).mockReturnValueOnce({
442
+ enabled: false,
443
+ readiness: 'disabled',
444
+ source: 'defaults',
445
+ reason: 'correction_observer is disabled in .pd/config.yaml',
446
+ nextAction: 'Set features.correction_observer.enabled=true in .pd/config.yaml to enable',
447
+ runtimeProfileId: null,
448
+ runtimeProfileType: null,
449
+ apiKeyEnv: null,
450
+ apiKeyPresent: false,
451
+ provider: null,
452
+ model: null,
453
+ timeoutMs: null,
454
+ baseUrl: null,
455
+ });
456
+
457
+ try {
458
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
459
+ const result = resolveCorrectionObserver(wctx, logger as any);
460
+
461
+ expect(result).toBeNull();
462
+ } finally {
463
+ safeRmDir(workspaceDir);
464
+ }
465
+ });
466
+
467
+ it('returns null when observer needs setup (no API key)', async () => {
468
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-needs-setup-'));
469
+ const stateDir = path.join(workspaceDir, '.state');
470
+ fs.mkdirSync(stateDir, { recursive: true });
365
471
 
366
472
  const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
367
473
 
474
+ // Override the mock to return needs_setup
475
+ const { resolveObserverConfig } = await import('../../src/core/pd-config-loader.js');
476
+ vi.mocked(resolveObserverConfig).mockReturnValueOnce({
477
+ enabled: true,
478
+ readiness: 'needs_setup',
479
+ source: 'defaults',
480
+ reason: "Environment variable 'ANTHROPIC_API_KEY' is not set or empty",
481
+ nextAction: 'Set the environment variable ANTHROPIC_API_KEY with a valid API key',
482
+ runtimeProfileId: 'pd.anthropic-sonnet',
483
+ runtimeProfileType: 'pi-ai',
484
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
485
+ apiKeyPresent: false,
486
+ provider: 'anthropic',
487
+ model: 'claude-3-5-sonnet',
488
+ timeoutMs: 30000,
489
+ baseUrl: null,
490
+ });
491
+
368
492
  try {
369
493
  const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
370
494
  const result = resolveCorrectionObserver(wctx, logger as any);
371
495
 
372
- // With mocked WorkflowFunnelLoader returning valid policy, should return observer
373
- expect(result).not.toBeNull();
496
+ expect(result).toBeNull();
497
+ // Should log the needs_setup reason, not noisy "no API key" cycling
498
+ expect(logger.info).toHaveBeenCalledWith(
499
+ expect.stringContaining('ANTHROPIC_API_KEY')
500
+ );
374
501
  } finally {
375
- delete process.env.ANTHROPIC_API_KEY;
376
502
  safeRmDir(workspaceDir);
377
503
  }
378
504
  });
@@ -14,7 +14,7 @@ describe('Correction Observer Ownership — Feature Flag & Surface Registry Cons
14
14
  const result = computeEffectiveFlags(
15
15
  { correction_observer: { enabled: false } },
16
16
  DEFAULT_FEATURE_FLAGS,
17
- '.pd/feature-flags.yaml',
17
+ '.pd/config.yaml',
18
18
  );
19
19
  expect(result.flags['correction_observer'].enabled).toBe(false);
20
20
  expect(result.warnings).not.toContain(expect.stringContaining('core flag cannot be disabled'));