principles-disciple 1.83.0 → 1.84.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.
@@ -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
+ });
@@ -7,36 +7,44 @@ const INDEX_TS = fs.readFileSync(
7
7
  'utf-8',
8
8
  );
9
9
 
10
+ const WORKSPACE_RESOLVER_TS = fs.readFileSync(
11
+ path.resolve(__dirname, '../src/utils/workspace-resolver.ts'),
12
+ 'utf-8',
13
+ );
14
+
10
15
  describe('Hook workspace resolution NextAction contract', () => {
11
- const FORBIDDEN_NEXT_ACTION_PATTERNS = [
12
- /PD_WORKSPACE_DIR/,
13
- /principles-disciple\.json/,
14
- ];
15
-
16
- it('does not claim PD_WORKSPACE_DIR env var as recovery in NextAction', () => {
17
- const matches = INDEX_TS.match(/NextAction:[^`]*PD_WORKSPACE_DIR/g);
18
- expect(matches).toBeNull();
16
+ it('no stale HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION constant remains', () => {
17
+ const constantMatch = INDEX_TS.match(/HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION/);
18
+ expect(constantMatch).toBeNull();
19
19
  });
20
20
 
21
- it('does not claim principles-disciple.json as recovery in NextAction', () => {
22
- const matches = INDEX_TS.match(/NextAction:[^`]*principles-disciple\.json/g);
23
- expect(matches).toBeNull();
21
+ it('resolveHookWorkspaceDir failure result includes PD canonical config in nextAction', () => {
22
+ const nextActionMatch = WORKSPACE_RESOLVER_TS.match(
23
+ /nextAction:\s*'([^']+)'/s,
24
+ );
25
+ expect(nextActionMatch).not.toBeNull();
26
+ const nextAction = nextActionMatch![1];
27
+ expect(nextAction).toContain('PD_WORKSPACE_DIR');
28
+ expect(nextAction).toContain('principles-disciple.json');
24
29
  });
25
30
 
26
- it('all hook failure NextActions reference canonical workspace migration', () => {
27
- const nextActionLines = INDEX_TS.match(/NextAction: \${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}/g);
28
- expect(nextActionLines).not.toBeNull();
29
- expect(nextActionLines!.length).toBeGreaterThanOrEqual(7);
31
+ it('all hook failure paths use resolveHookWorkspaceDir with structured nextAction', () => {
32
+ const hookUsages = INDEX_TS.match(/resolveHookWorkspaceDir\(/g);
33
+ expect(hookUsages).not.toBeNull();
34
+ expect(hookUsages!.length).toBeGreaterThanOrEqual(6);
35
+
36
+ const wsResultOkChecks = INDEX_TS.match(/!wsResult\.ok/g);
37
+ expect(wsResultOkChecks).not.toBeNull();
38
+ expect(wsResultOkChecks!.length).toBeGreaterThanOrEqual(6);
39
+
40
+ const nextActionRefs = INDEX_TS.match(/wsResult\.nextAction/g);
41
+ expect(nextActionRefs).not.toBeNull();
42
+ expect(nextActionRefs!.length).toBeGreaterThanOrEqual(6);
30
43
  });
31
44
 
32
- it('HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION constant exists and does not contain forbidden patterns', () => {
33
- const constantMatch = INDEX_TS.match(
34
- /const HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION\s*=\s*'([^']+)'/,
35
- );
36
- expect(constantMatch).not.toBeNull();
37
- const constantValue = constantMatch![1];
38
- for (const pattern of FORBIDDEN_NEXT_ACTION_PATTERNS) {
39
- expect(pattern.test(constantValue)).toBe(false);
40
- }
45
+ it('resolveHookWorkspaceDir failure result has reason and nextAction fields', () => {
46
+ expect(WORKSPACE_RESOLVER_TS).toContain("reason: 'workspace_dir_unresolvable'");
47
+ expect(WORKSPACE_RESOLVER_TS).toContain('nextAction:');
48
+ expect(WORKSPACE_RESOLVER_TS).toContain('PD_WORKSPACE_DIR');
41
49
  });
42
50
  });
@@ -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