principles-disciple 1.83.0 → 1.83.1

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,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.83.0",
5
+ "version": "1.83.1",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.83.0",
3
+ "version": "1.83.1",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -192,7 +192,7 @@ export function loadContextInjectionConfig(workspaceDir: string): ContextInjecti
192
192
  const raw = cachedReadFile(profilePath);
193
193
  if (raw) {
194
194
  const profile = JSON.parse(raw);
195
- if (profile.contextInjection) {
195
+ if (profile && typeof profile === 'object' && profile.contextInjection && typeof profile.contextInjection === 'object') {
196
196
  const contextInjection = profile.contextInjection as Partial<ContextInjectionConfig>;
197
197
  return {
198
198
  ...defaultContextConfig,
@@ -360,6 +360,9 @@ export async function handleBeforePromptBuild(
360
360
  }
361
361
 
362
362
  // ──── 1. prependSystemContext: Minimal Agent Identity ────
363
+ // EvolutionWorker-era INTERNAL SYSTEM LAYOUT removed per PRI-294.
364
+ // The EVOLUTION_WORKER PathResolver key and system layout reference are
365
+ // not MVP-Core; agents discover what they need via tool calls.
363
366
  prependSystemContext = `## 【AGENT IDENTITY】
364
367
 
365
368
  You are a **self-evolving AI agent** powered by Principles Disciple.
@@ -377,10 +380,6 @@ You are a **self-evolving AI agent** powered by Principles Disciple.
377
380
  - Use the current session for the normal user reply.
378
381
  - Use sessions_send for cross-session messaging.
379
382
  - Use agents_list / sessions_list for peer-agent or peer-session orchestration.
380
-
381
- ## 🔧 INTERNAL SYSTEM LAYOUT
382
- - Your core plugin logic is rooted at: ${PathResolver.getExtensionRoot() || 'EXTENSION_ROOT (unresolved)'}
383
- - If you need self-inspection, prioritize the worker entry pointed by PathResolver key: EVOLUTION_WORKER
384
383
  `;
385
384
 
386
385
  // ──── 2. Empathy Observer Spawn (async sidecar)
package/src/index.ts CHANGED
@@ -563,11 +563,12 @@ const plugin = {
563
563
  return handleAfterCompaction(event, { ...ctx, workspaceDir });
564
564
  }));
565
565
 
566
- // ── Service: Background Evolution Worker ──
566
+ // ── Service Registration (surface-guarded) ──
567
+ // PRI-294: EvolutionWorker service registration removed — it starts via
568
+ // before_prompt_build hook gate, not via api.registerService. The surface
569
+ // guard already prevents registration when disabled (enabledByDefault=false).
570
+ // Dead pre-assignment of EvolutionWorkerService.api removed.
567
571
  try {
568
- EvolutionWorkerService.api = api;
569
- const guardedEvolutionWorker = guardService('service:evolution-worker', EvolutionWorkerService, api.logger);
570
- if (guardedEvolutionWorker) api.registerService(guardedEvolutionWorker);
571
572
  const guardedCorrectionObserver = guardService('service:correction-observer', CorrectionObserverService, api.logger);
572
573
  if (guardedCorrectionObserver) api.registerService(guardedCorrectionObserver);
573
574
  const guardedTrajectory = guardService('service:trajectory', TrajectoryService, api.logger);
@@ -13,6 +13,15 @@ const mockFs = {
13
13
 
14
14
  vi.mock('fs', () => mockFs);
15
15
 
16
+ // Mock heavy core module to avoid slow re-imports and to control staleness detection
17
+ const mockMigrateWorkspaceGuidance = vi.fn<(content: string, relativePath: string) => { changed: boolean; migrated: string }>();
18
+ const mockContainsStalePlanMdGuidance = vi.fn<(content: string, relativePath: string) => boolean>();
19
+
20
+ vi.mock('@principles/core/runtime-v2', () => ({
21
+ migrateWorkspaceGuidance: (...args: unknown[]) => mockMigrateWorkspaceGuidance(...(args as [string, string])),
22
+ containsStalePlanMdGuidance: (...args: unknown[]) => mockContainsStalePlanMdGuidance(...(args as [string, string])),
23
+ }));
24
+
16
25
  const WORKSPACE_GUIDANCE_MIGRATOR_PATH = '../../src/core/workspace-guidance-migrator.js';
17
26
 
18
27
  describe('workspace-guidance-migrator', () => {
@@ -41,6 +50,13 @@ describe('workspace-guidance-migrator', () => {
41
50
  mockFs.writeFileSync.mockReturnValue(undefined);
42
51
  mockFs.readdirSync.mockReturnValue([]);
43
52
 
53
+ // Default: content is NOT stale
54
+ mockContainsStalePlanMdGuidance.mockReturnValue(false);
55
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
56
+ changed: false,
57
+ migrated: content,
58
+ }));
59
+
44
60
  const module = await import(WORKSPACE_GUIDANCE_MIGRATOR_PATH);
45
61
  migrateStaleWorkspaceGuidance = module.migrateStaleWorkspaceGuidance;
46
62
  });
@@ -63,6 +79,7 @@ describe('workspace-guidance-migrator', () => {
63
79
  it('skips files with no stale guidance', () => {
64
80
  mockFs.existsSync.mockReturnValue(true);
65
81
  mockFs.readFileSync.mockReturnValue('# Clean AGENTS.md\nNo stale references here.');
82
+ // Default: containsStalePlanMdGuidance returns false
66
83
 
67
84
  const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
68
85
 
@@ -76,6 +93,14 @@ describe('workspace-guidance-migrator', () => {
76
93
  mockFs.readFileSync.mockReturnValue(
77
94
  '# Agent Instructions\nPhysical interception ensures safety.',
78
95
  );
96
+ // First call (AGENTS.md) is stale, second (MEMORY.md) is not
97
+ mockContainsStalePlanMdGuidance
98
+ .mockReturnValueOnce(true)
99
+ .mockReturnValueOnce(false);
100
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
101
+ changed: true,
102
+ migrated: content.replace('Physical interception', 'MIGRATED'),
103
+ }));
79
104
 
80
105
  const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
81
106
 
@@ -88,6 +113,11 @@ describe('workspace-guidance-migrator', () => {
88
113
  mockFs.readFileSync.mockReturnValue(
89
114
  '# Agent Instructions\nPhysical interception ensures safety.',
90
115
  );
116
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
117
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
118
+ changed: true,
119
+ migrated: content.replace('Physical interception', 'MIGRATED'),
120
+ }));
91
121
 
92
122
  migrateStaleWorkspaceGuidance(mockApi, '/workspace');
93
123
 
@@ -102,6 +132,11 @@ describe('workspace-guidance-migrator', () => {
102
132
  mockFs.readFileSync.mockReturnValue(
103
133
  '# Agent Instructions\nPhysical interception ensures safety.',
104
134
  );
135
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
136
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
137
+ changed: true,
138
+ migrated: content.replace('Physical interception', 'MIGRATED'),
139
+ }));
105
140
 
106
141
  migrateStaleWorkspaceGuidance(mockApi, '/workspace');
107
142
 
@@ -126,6 +161,11 @@ describe('workspace-guidance-migrator', () => {
126
161
  const originalContent = '# Agent Instructions\nPhysical interception ensures safety.';
127
162
  mockFs.existsSync.mockReturnValue(true);
128
163
  mockFs.readFileSync.mockReturnValue(originalContent);
164
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
165
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
166
+ changed: true,
167
+ migrated: content.replace('Physical interception', 'MIGRATED'),
168
+ }));
129
169
 
130
170
  let callCount = 0;
131
171
  mockFs.writeFileSync.mockImplementation((path: string, content: string) => {
@@ -154,8 +194,9 @@ describe('workspace-guidance-migrator', () => {
154
194
  });
155
195
 
156
196
  it('discovers skill files in .principles/skills directory', () => {
197
+ const skillsPattern = path.join('.principles', 'skills');
157
198
  mockFs.existsSync.mockImplementation((p: string) => {
158
- if (String(p).includes('.principles/skills')) return true;
199
+ if (String(p).includes(skillsPattern)) return true;
159
200
  return false;
160
201
  });
161
202
  mockFs.readdirSync.mockReturnValue([
@@ -165,6 +206,11 @@ describe('workspace-guidance-migrator', () => {
165
206
  mockFs.readFileSync.mockReturnValue(
166
207
  'Ensure `PLAN.md` contains `## Target Files` heading.',
167
208
  );
209
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
210
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
211
+ changed: true,
212
+ migrated: content.replace('## Target Files', '## Targets'),
213
+ }));
168
214
 
169
215
  const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
170
216
 
@@ -182,8 +228,9 @@ describe('workspace-guidance-migrator', () => {
182
228
  });
183
229
 
184
230
  it('handles skills directory read error gracefully', () => {
231
+ const skillsPattern = path.join('.principles', 'skills');
185
232
  mockFs.existsSync.mockImplementation((p: string) => {
186
- if (String(p).includes('.principles/skills')) return true;
233
+ if (String(p).includes(skillsPattern)) return true;
187
234
  return false;
188
235
  });
189
236
  mockFs.readdirSync.mockImplementation(() => {
@@ -0,0 +1,323 @@
1
+ /**
2
+ * PRI-294: Split EvolutionWorker-era services into MVP hook adapters.
3
+ *
4
+ * Tests prove:
5
+ * 1. All guardHook/guardService surface IDs in index.ts exist in the registry.
6
+ * 2. CorrectionObserver starts independently of EvolutionWorker.
7
+ * 3. Core hooks are enabled; non-core hooks are disabled by default.
8
+ * 4. EvolutionWorker-era prompt injection has been removed.
9
+ * 5. EvolutionWorker service is NOT registered via api.registerService.
10
+ *
11
+ * ERR-024: dead validators/services must not appear as protection if not wired.
12
+ * ERR-025: tests must exercise production hook/service paths.
13
+ * ERR-027: registry/docs must match actual runtime surface.
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import * as os from 'os';
19
+ import * as yaml from 'js-yaml';
20
+ import { PLUGIN_SURFACE_REGISTRY, computeEffectiveFlags, DEFAULT_FEATURE_FLAGS } from '@principles/core/runtime-v2';
21
+
22
+ // ── Helpers ──
23
+
24
+ function createTempWorkspace(): string {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-slimming-'));
26
+ fs.mkdirSync(path.join(dir, '.pd'), { recursive: true });
27
+ fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
28
+ return dir;
29
+ }
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 });
34
+ fs.writeFileSync(configPath, content, 'utf8');
35
+ }
36
+
37
+ function createMockLogger() {
38
+ return {
39
+ info: vi.fn(),
40
+ warn: vi.fn(),
41
+ error: vi.fn(),
42
+ debug: vi.fn(),
43
+ };
44
+ }
45
+
46
+ // Mock dependencies for service start tests
47
+ vi.mock('../src/core/dictionary-service.js', () => ({
48
+ DictionaryService: { get: vi.fn(() => ({ flush: vi.fn() })) },
49
+ }));
50
+
51
+ vi.mock('../src/core/session-tracker.js', () => ({
52
+ initPersistence: vi.fn(),
53
+ flushAllSessions: vi.fn(),
54
+ listSessions: vi.fn(() => []),
55
+ }));
56
+
57
+ vi.mock('../src/core/workspace-context.js', () => {
58
+ const mockCtx = {
59
+ stateDir: '',
60
+ workspaceDir: '',
61
+ config: { get: vi.fn() },
62
+ eventLog: { recordHookExecution: vi.fn() },
63
+ dictionary: { flush: vi.fn() },
64
+ resolve: vi.fn((key: string) => `/mock/${key}`),
65
+ trajectory: null,
66
+ };
67
+ return {
68
+ WorkspaceContext: {
69
+ fromHookContext: vi.fn(() => mockCtx),
70
+ clearCache: vi.fn(),
71
+ },
72
+ };
73
+ });
74
+
75
+ // Import after mocks
76
+ import { loadFeatureFlagFromWorkspace, shouldStartEvolutionWorker, shouldStartCorrectionObserver } from '../src/index.js';
77
+ import { CorrectionObserverService } from '../src/service/correction-observer-service.js';
78
+
79
+ // ── 1. Surface Registry Coverage Audit ──
80
+
81
+ describe('PRI-294: Surface registry coverage audit', () => {
82
+ // Surface IDs actually used in index.ts guardHook/guardService calls
83
+ const USED_SURFACE_IDS = [
84
+ 'hook:before_prompt_build',
85
+ 'hook:before_tool_call',
86
+ 'hook:after_tool_call',
87
+ 'hook:llm_output',
88
+ 'hook:after_tool_call.trajectory',
89
+ 'hook:llm_output.trajectory',
90
+ 'hook:subagent_spawning',
91
+ 'hook:subagent_ended',
92
+ 'hook:before_reset',
93
+ 'hook:before_compaction',
94
+ 'hook:after_compaction',
95
+ // Services registered via guardService
96
+ 'service:correction-observer',
97
+ 'service:trajectory',
98
+ 'service:pd-task',
99
+ 'service:central-sync',
100
+ ];
101
+
102
+ for (const surfaceId of USED_SURFACE_IDS) {
103
+ it(`used surface '${surfaceId}' exists in registry`, () => {
104
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === surfaceId);
105
+ expect(entry).toBeDefined();
106
+ expect(entry!.id).toBe(surfaceId);
107
+ });
108
+ }
109
+
110
+ it('all used surface IDs are accounted for (no orphan surfaces)', () => {
111
+ const registeredIds = new Set(PLUGIN_SURFACE_REGISTRY.map(s => s.id));
112
+ const usedButNotRegistered = USED_SURFACE_IDS.filter(id => !registeredIds.has(id));
113
+ expect(usedButNotRegistered).toEqual([]);
114
+ });
115
+
116
+ it('registry has no unexpected surfaces beyond what index.ts uses', () => {
117
+ const usedSet = new Set(USED_SURFACE_IDS);
118
+ // These are used but not directly via guardHook/guardService in the main hook path
119
+ const additionallyRegistered = [
120
+ 'service:evolution-worker', // Previously registered, now removed per PRI-294
121
+ 'startup:workspace-init',
122
+ 'startup:evolution-worker',
123
+ 'startup:correction-observer',
124
+ ];
125
+ const allowedIds = new Set([...usedSet, ...additionallyRegistered]);
126
+ const unaccounted = PLUGIN_SURFACE_REGISTRY
127
+ .map(s => s.id)
128
+ .filter(id => !allowedIds.has(id));
129
+ // Unaccounted surfaces should be empty — every registry entry must trace to a caller
130
+ expect(unaccounted).toEqual([]);
131
+ });
132
+ });
133
+
134
+ // ── 2. CorrectionObserver Independence ──
135
+
136
+ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () => {
137
+ let workspaceDir: string;
138
+
139
+ beforeEach(() => {
140
+ workspaceDir = createTempWorkspace();
141
+ CorrectionObserverService.stop?.({ logger: createMockLogger() });
142
+ });
143
+
144
+ afterEach(() => {
145
+ CorrectionObserverService.stop?.({ logger: createMockLogger() });
146
+ try {
147
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
148
+ } catch {
149
+ // best-effort
150
+ }
151
+ });
152
+
153
+ it('CorrectionObserver starts when EvolutionWorker is disabled (default)', () => {
154
+ const logger = createMockLogger();
155
+
156
+ // Verify EvolutionWorker is disabled
157
+ const ewGate = shouldStartEvolutionWorker(workspaceDir, logger);
158
+ expect(ewGate.shouldStart).toBe(false);
159
+
160
+ // Verify CorrectionObserver is enabled
161
+ const coGate = shouldStartCorrectionObserver(workspaceDir, logger);
162
+ expect(coGate.shouldStart).toBe(true);
163
+ expect(coGate.disabledInfo).toBeNull();
164
+ });
165
+
166
+ it('CorrectionObserver starts when EvolutionWorker is explicitly enabled', () => {
167
+ writeFeatureFlags(workspaceDir, {
168
+ evolution_worker: { enabled: true },
169
+ });
170
+ const logger = createMockLogger();
171
+
172
+ const ewGate = shouldStartEvolutionWorker(workspaceDir, logger);
173
+ expect(ewGate.shouldStart).toBe(true);
174
+
175
+ const coGate = shouldStartCorrectionObserver(workspaceDir, logger);
176
+ expect(coGate.shouldStart).toBe(true);
177
+ });
178
+
179
+ it('CorrectionObserver can be independently disabled', () => {
180
+ writeFeatureFlags(workspaceDir, {
181
+ correction_observer: { enabled: false },
182
+ });
183
+ const logger = createMockLogger();
184
+
185
+ const coGate = shouldStartCorrectionObserver(workspaceDir, logger);
186
+ expect(coGate.shouldStart).toBe(false);
187
+ expect(coGate.disabledInfo).not.toBeNull();
188
+ });
189
+ });
190
+
191
+ // ── 3. Core vs Non-Core Surface Defaults ──
192
+
193
+ describe('PRI-294: MVP core hooks enabled, non-core disabled', () => {
194
+ const CORE_HOOKS = [
195
+ 'hook:before_prompt_build',
196
+ 'hook:before_tool_call',
197
+ 'hook:after_tool_call',
198
+ 'hook:llm_output',
199
+ ];
200
+
201
+ const QUIET_HOOKS = [
202
+ 'hook:after_tool_call.trajectory',
203
+ 'hook:llm_output.trajectory',
204
+ 'hook:subagent_spawning',
205
+ 'hook:subagent_ended',
206
+ 'hook:before_reset',
207
+ 'hook:before_compaction',
208
+ 'hook:after_compaction',
209
+ ];
210
+
211
+ for (const surfaceId of CORE_HOOKS) {
212
+ it(`core hook '${surfaceId}' is enabled by default`, () => {
213
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === surfaceId);
214
+ expect(entry).toBeDefined();
215
+ expect(entry!.category).toBe('core');
216
+ expect(entry!.enabledByDefault).toBe(true);
217
+ });
218
+ }
219
+
220
+ for (const surfaceId of QUIET_HOOKS) {
221
+ it(`quiet hook '${surfaceId}' is disabled by default`, () => {
222
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === surfaceId);
223
+ expect(entry).toBeDefined();
224
+ expect(entry!.category).toBe('quiet');
225
+ expect(entry!.enabledByDefault).toBe(false);
226
+ });
227
+ }
228
+
229
+ it('CorrectionObserver service surface is core and enabled', () => {
230
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'service:correction-observer');
231
+ expect(entry).toBeDefined();
232
+ expect(entry!.category).toBe('core');
233
+ expect(entry!.enabledByDefault).toBe(true);
234
+ });
235
+
236
+ it('EvolutionWorker service surface is quiet and disabled', () => {
237
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'service:evolution-worker');
238
+ expect(entry).toBeDefined();
239
+ expect(entry!.category).toBe('quiet');
240
+ expect(entry!.enabledByDefault).toBe(false);
241
+ expect(entry!.disabledReason).toBeDefined();
242
+ });
243
+
244
+ it('all disabled surfaces have disabledReason (ERR-002 observability)', () => {
245
+ const disabledWithoutReason = PLUGIN_SURFACE_REGISTRY.filter(
246
+ s => !s.enabledByDefault && s.category !== 'core' && !s.disabledReason,
247
+ );
248
+ expect(disabledWithoutReason).toEqual([]);
249
+ });
250
+ });
251
+
252
+ // ── 4. EvolutionWorker-era prompt injection removed ──
253
+
254
+ describe('PRI-294: EvolutionWorker-era prompt injection removed', () => {
255
+ it('prompt.ts does not inject EVOLUTION_WORKER key into agent prompt', async () => {
256
+ const promptPath = path.resolve(__dirname, '../src/hooks/prompt.ts');
257
+ const src = fs.readFileSync(promptPath, 'utf-8');
258
+ // Check that the EVOLUTION_WORKER PathResolver key is not injected into
259
+ // the prependSystemContext template string (the runtime prompt).
260
+ // Comments referencing EVOLUTION_WORKER for documentation are fine.
261
+ // Match: inside template literal interpolation that resolves EVOLUTION_WORKER
262
+ expect(src).not.toMatch(/\$\{.*EVOLUTION_WORKER.*\}/);
263
+ // Also check no template literal contains "PathResolver key: EVOLUTION_WORKER"
264
+ expect(src).not.toContain('PathResolver key: EVOLUTION_WORKER');
265
+ });
266
+
267
+ it('prompt.ts does not inject INTERNAL SYSTEM LAYOUT section into prompt', async () => {
268
+ const promptPath = path.resolve(__dirname, '../src/hooks/prompt.ts');
269
+ const src = fs.readFileSync(promptPath, 'utf-8');
270
+ // The INTERNAL SYSTEM LAYOUT section was EvolutionWorker-era injection.
271
+ // Verify the actual rendered section header is gone — only comments should remain.
272
+ // Remove all single-line comments, then check no INTERNAL SYSTEM LAYOUT in code.
273
+ const withoutComments = src.replace(/\/\/.*$/gm, '');
274
+ expect(withoutComments).not.toContain('INTERNAL SYSTEM LAYOUT');
275
+ });
276
+ });
277
+
278
+ // ── 5. EvolutionWorker NOT registered via api.registerService ──
279
+
280
+ describe('PRI-294: EvolutionWorker not registered as service', () => {
281
+ it('index.ts does not register EvolutionWorker via api.registerService', async () => {
282
+ const indexPath = path.resolve(__dirname, '../src/index.ts');
283
+ const src = fs.readFileSync(indexPath, 'utf-8');
284
+ // Should NOT have guardService('service:evolution-worker', ...)
285
+ expect(src).not.toMatch(/guardService\(\s*['"]service:evolution-worker['"]/);
286
+ });
287
+
288
+ it('index.ts does not pre-assign EvolutionWorkerService.api outside gate', async () => {
289
+ const indexPath = path.resolve(__dirname, '../src/index.ts');
290
+ const src = fs.readFileSync(indexPath, 'utf-8');
291
+ // The only EvolutionWorkerService.api assignment should be inside the gate
292
+ const apiAssignments = src.match(/EvolutionWorkerService\.api\s*=\s*api/g) ?? [];
293
+ // Should be exactly one: inside the shouldStartEvolutionWorker gate
294
+ expect(apiAssignments.length).toBe(1);
295
+ });
296
+ });
297
+
298
+ // ── 6. Feature flag registry completeness ──
299
+
300
+ describe('PRI-294: Feature flag registry matches surface registry', () => {
301
+ it('evolution_worker flag exists in DEFAULT_FEATURE_FLAGS as quiet+disabled', () => {
302
+ const flag = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'evolution_worker');
303
+ expect(flag).toBeDefined();
304
+ expect(flag!.category).toBe('quiet');
305
+ expect(flag!.enabled).toBe(false);
306
+ });
307
+
308
+ it('correction_observer flag exists in DEFAULT_FEATURE_FLAGS', () => {
309
+ const flag = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'correction_observer');
310
+ expect(flag).toBeDefined();
311
+ expect(flag!.enabled).toBe(true);
312
+ });
313
+
314
+ it('nocturnal and idle_trigger are gone flags', () => {
315
+ const nocturnal = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'nocturnal');
316
+ expect(nocturnal).toBeDefined();
317
+ expect(nocturnal!.category).toBe('gone');
318
+
319
+ const idle = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'idle_trigger');
320
+ expect(idle).toBeDefined();
321
+ expect(idle!.category).toBe('gone');
322
+ });
323
+ });
@@ -200,12 +200,21 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
200
200
  expect(unclassified).toEqual([]);
201
201
  });
202
202
 
203
- it('total service registrations match expected count', () => {
203
+ it('registered service IDs match expected MVP set', () => {
204
+ // Quiet/disabled services (e.g. evolution-worker per PRI-294) exist in the
205
+ // registry but are NOT registered via guardService in index.ts.
204
206
  const source = read('packages/openclaw-plugin/src/index.ts');
205
207
  const registrations = extractServiceRegistrations(source);
206
208
 
207
- const registryServiceCount = PLUGIN_SURFACE_REGISTRY.filter(s => s.kind === 'service').length;
208
- expect(registrations.length).toBe(registryServiceCount);
209
+ const registeredIds = registrations.map(r => r.surfaceId).sort();
210
+ const expectedIds = [
211
+ 'service:central-sync',
212
+ 'service:correction-observer',
213
+ 'service:pd-task',
214
+ 'service:trajectory',
215
+ ].sort();
216
+
217
+ expect(registeredIds).toEqual(expectedIds);
209
218
  });
210
219
  });
211
220