principles-disciple 1.79.0 → 1.81.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,201 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import type { Dirent } from 'fs';
5
+ import type { OpenClawPluginApi } from '../../src/openclaw-sdk.js';
6
+
7
+ const mockFs = {
8
+ existsSync: vi.fn(),
9
+ readFileSync: vi.fn(),
10
+ writeFileSync: vi.fn(),
11
+ readdirSync: vi.fn(),
12
+ };
13
+
14
+ vi.mock('fs', () => mockFs);
15
+
16
+ const WORKSPACE_GUIDANCE_MIGRATOR_PATH = '../../src/core/workspace-guidance-migrator.js';
17
+
18
+ describe('workspace-guidance-migrator', () => {
19
+ let migrateStaleWorkspaceGuidance: (api: OpenClawPluginApi, workspaceDir: string) => {
20
+ migratedFiles: string[];
21
+ skippedFiles: string[];
22
+ errors: { file: string; error: string }[];
23
+ };
24
+
25
+ const mockLogger = {
26
+ info: vi.fn(),
27
+ warn: vi.fn(),
28
+ error: vi.fn(),
29
+ };
30
+
31
+ const mockApi = {
32
+ logger: mockLogger,
33
+ } as unknown as OpenClawPluginApi;
34
+
35
+ beforeEach(async () => {
36
+ vi.clearAllMocks();
37
+ vi.resetModules();
38
+
39
+ mockFs.existsSync.mockReturnValue(true);
40
+ mockFs.readFileSync.mockReturnValue('');
41
+ mockFs.writeFileSync.mockReturnValue(undefined);
42
+ mockFs.readdirSync.mockReturnValue([]);
43
+
44
+ const module = await import(WORKSPACE_GUIDANCE_MIGRATOR_PATH);
45
+ migrateStaleWorkspaceGuidance = module.migrateStaleWorkspaceGuidance;
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe('migrateStaleWorkspaceGuidance', () => {
53
+ it('skips files that do not exist', () => {
54
+ mockFs.existsSync.mockReturnValue(false);
55
+
56
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
57
+
58
+ expect(result.migratedFiles).toEqual([]);
59
+ expect(result.skippedFiles).toEqual([]);
60
+ expect(result.errors).toEqual([]);
61
+ });
62
+
63
+ it('skips files with no stale guidance', () => {
64
+ mockFs.existsSync.mockReturnValue(true);
65
+ mockFs.readFileSync.mockReturnValue('# Clean AGENTS.md\nNo stale references here.');
66
+
67
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
68
+
69
+ expect(result.migratedFiles).toEqual([]);
70
+ expect(result.skippedFiles.length).toBeGreaterThan(0);
71
+ expect(result.errors).toEqual([]);
72
+ });
73
+
74
+ it('migrates AGENTS.md with stale guidance', () => {
75
+ mockFs.existsSync.mockReturnValue(true);
76
+ mockFs.readFileSync.mockReturnValue(
77
+ '# Agent Instructions\nPhysical interception ensures safety.',
78
+ );
79
+
80
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
81
+
82
+ expect(result.migratedFiles.some(f => f.includes('AGENTS.md'))).toBe(true);
83
+ expect(result.skippedFiles.some(f => f.includes('MEMORY.md'))).toBe(true);
84
+ });
85
+
86
+ it('creates backup before migration', () => {
87
+ mockFs.existsSync.mockReturnValue(true);
88
+ mockFs.readFileSync.mockReturnValue(
89
+ '# Agent Instructions\nPhysical interception ensures safety.',
90
+ );
91
+
92
+ migrateStaleWorkspaceGuidance(mockApi, '/workspace');
93
+
94
+ const backupCalls = mockFs.writeFileSync.mock.calls.filter(
95
+ (call: unknown[]) => String(call[0]).includes('.pre-pri286.bak'),
96
+ );
97
+ expect(backupCalls.length).toBeGreaterThan(0);
98
+ });
99
+
100
+ it('logs migration progress', () => {
101
+ mockFs.existsSync.mockReturnValue(true);
102
+ mockFs.readFileSync.mockReturnValue(
103
+ '# Agent Instructions\nPhysical interception ensures safety.',
104
+ );
105
+
106
+ migrateStaleWorkspaceGuidance(mockApi, '/workspace');
107
+
108
+ expect(mockLogger.info).toHaveBeenCalledWith(
109
+ expect.stringContaining('[PD:GuidanceMigration]'),
110
+ );
111
+ });
112
+
113
+ it('handles read errors gracefully', () => {
114
+ mockFs.existsSync.mockReturnValue(true);
115
+ mockFs.readFileSync.mockImplementation(() => {
116
+ throw new Error('Read error');
117
+ });
118
+
119
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
120
+
121
+ expect(result.errors.length).toBeGreaterThan(0);
122
+ expect(result.errors[0].error).toContain('Failed to read file content');
123
+ });
124
+
125
+ it('handles write errors and restores original', () => {
126
+ const originalContent = '# Agent Instructions\nPhysical interception ensures safety.';
127
+ mockFs.existsSync.mockReturnValue(true);
128
+ mockFs.readFileSync.mockReturnValue(originalContent);
129
+
130
+ let callCount = 0;
131
+ mockFs.writeFileSync.mockImplementation((path: string, content: string) => {
132
+ callCount++;
133
+ if (path.includes('.pre-pri286.bak')) return;
134
+ if (callCount === 2) {
135
+ expect(content).toBe(originalContent);
136
+ return;
137
+ }
138
+ throw new Error('Write error');
139
+ });
140
+
141
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
142
+
143
+ expect(result.errors.length).toBeGreaterThan(0);
144
+ expect(callCount).toBeGreaterThanOrEqual(2);
145
+ });
146
+
147
+ it('skips non-guidance files', () => {
148
+ mockFs.existsSync.mockReturnValue(true);
149
+ mockFs.readFileSync.mockReturnValue('# Random Content\nNo guidance here.');
150
+
151
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
152
+
153
+ expect(result.migratedFiles).toEqual([]);
154
+ });
155
+
156
+ it('discovers skill files in .principles/skills directory', () => {
157
+ mockFs.existsSync.mockImplementation((p: string) => {
158
+ if (String(p).includes('.principles/skills')) return true;
159
+ return false;
160
+ });
161
+ mockFs.readdirSync.mockReturnValue([
162
+ { isDirectory: () => true, name: 'admin' },
163
+ { isDirectory: () => true, name: 'reflection' },
164
+ ] as Dirent[]);
165
+ mockFs.readFileSync.mockReturnValue(
166
+ 'Ensure `PLAN.md` contains `## Target Files` heading.',
167
+ );
168
+
169
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
170
+
171
+ expect(result.migratedFiles.length).toBeGreaterThan(0);
172
+ });
173
+
174
+ it('handles empty workspace directory', () => {
175
+ mockFs.existsSync.mockReturnValue(false);
176
+
177
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
178
+
179
+ expect(result.migratedFiles).toEqual([]);
180
+ expect(result.skippedFiles).toEqual([]);
181
+ expect(result.errors).toEqual([]);
182
+ });
183
+
184
+ it('handles skills directory read error gracefully', () => {
185
+ mockFs.existsSync.mockImplementation((p: string) => {
186
+ if (String(p).includes('.principles/skills')) return true;
187
+ return false;
188
+ });
189
+ mockFs.readdirSync.mockImplementation(() => {
190
+ throw new Error('Directory read error');
191
+ });
192
+ mockFs.readFileSync.mockReturnValue(
193
+ '# Agent Instructions\nPhysical interception ensures safety.',
194
+ );
195
+
196
+ const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
197
+
198
+ expect(result.errors.length).toBeGreaterThan(0);
199
+ });
200
+ });
201
+ });
@@ -0,0 +1,331 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { WorkspaceContext } from '../../src/core/workspace-context.js';
6
+
7
+ const mockLearner = {
8
+ getStore: vi.fn(() => ({ keywords: [{ term: 'wrong', weight: 0.5, hitCount: 3, truePositiveCount: 1, falsePositiveCount: 2 }] })),
9
+ };
10
+
11
+ const mockDb = {
12
+ listRecentSessions: vi.fn(() => [{ sessionId: 'session-1' }]),
13
+ listUserTurnsForSession: vi.fn(() => [{ rawExcerpt: 'User said wrong input', correctionDetected: true, correctionCue: 'wrong' }]),
14
+ };
15
+
16
+ const mockOptimizationService = {
17
+ buildTrajectoryHistory: vi.fn(async () => [
18
+ { sessionId: 'session-1', timestamp: 'now', term: 'wrong', userMessage: '' }
19
+ ]),
20
+ applyResult: vi.fn(),
21
+ };
22
+
23
+ vi.mock('../../src/core/correction-cue-learner.js', () => ({
24
+ CorrectionCueLearner: { get: vi.fn(() => mockLearner) },
25
+ }));
26
+
27
+ vi.mock('../../src/core/trajectory.js', () => ({
28
+ TrajectoryRegistry: {
29
+ get: vi.fn(() => mockDb),
30
+ clear: vi.fn(),
31
+ },
32
+ }));
33
+
34
+ vi.mock('../../src/service/keyword-optimization-service.js', () => ({
35
+ KeywordOptimizationService: { get: vi.fn(() => mockOptimizationService) },
36
+ }));
37
+
38
+ const mockDispatch = vi.fn().mockResolvedValue({
39
+ updated: true,
40
+ summary: 'Keyword store optimized',
41
+ updates: { wrong: { action: 'update', weight: 0.4, reasoning: 'slightly high FP' } }
42
+ });
43
+
44
+ const mockRegister = vi.fn();
45
+
46
+ vi.mock('@principles/core/runtime-v2', () => {
47
+ 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
+ PiAiRuntimeAdapter: class {},
60
+ CorrectionObserver: class {},
61
+ AgentScheduler: class {
62
+ register = mockRegister;
63
+ dispatch = mockDispatch;
64
+ }
65
+ };
66
+ });
67
+
68
+ import { CorrectionObserverService, runCorrectionObserverCycle } from '../../src/service/correction-observer-service.js';
69
+ import { safeRmDir } from '../test-utils.js';
70
+
71
+ describe('CorrectionObserverService — Independent Service (PRI-293)', () => {
72
+ beforeEach(() => {
73
+ vi.useFakeTimers();
74
+ vi.clearAllMocks();
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.useRealTimers();
79
+ CorrectionObserverService.stop?.({} as any);
80
+ });
81
+
82
+ it('has correct service id', () => {
83
+ expect(CorrectionObserverService.id).toBe('principles-correction-observer');
84
+ });
85
+
86
+ it('starts and schedules periodic cycles', async () => {
87
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-obs-'));
88
+ const stateDir = path.join(workspaceDir, '.state');
89
+ fs.mkdirSync(stateDir, { recursive: true });
90
+
91
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
92
+
93
+ try {
94
+ CorrectionObserverService.start({
95
+ workspaceDir,
96
+ stateDir,
97
+ logger,
98
+ config: { get: () => undefined },
99
+ } as any);
100
+
101
+ expect(logger.info).toHaveBeenCalledWith(
102
+ expect.stringContaining('[PD:CorrectionObserver] Starting')
103
+ );
104
+
105
+ await vi.advanceTimersByTimeAsync(10_000);
106
+ for (let i = 0; i < 20; i++) {
107
+ await Promise.resolve();
108
+ }
109
+
110
+ expect(mockRegister).toHaveBeenCalled();
111
+ expect(mockDispatch).toHaveBeenCalledWith('correction-observer', expect.objectContaining({
112
+ parentSessionId: 'correction-observer-service',
113
+ workspaceDir,
114
+ recentMessages: ['User said wrong input'],
115
+ }));
116
+
117
+ expect(mockOptimizationService.applyResult).toHaveBeenCalledWith(expect.objectContaining({
118
+ updated: true,
119
+ summary: 'Keyword store optimized',
120
+ }));
121
+ } finally {
122
+ CorrectionObserverService.stop?.({} as any);
123
+ safeRmDir(workspaceDir);
124
+ }
125
+ });
126
+
127
+ it('stops cleanly and cancels pending timer', () => {
128
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-obs-stop-'));
129
+ const stateDir = path.join(workspaceDir, '.state');
130
+ fs.mkdirSync(stateDir, { recursive: true });
131
+
132
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
133
+
134
+ try {
135
+ CorrectionObserverService.start({
136
+ workspaceDir,
137
+ stateDir,
138
+ logger,
139
+ config: { get: () => undefined },
140
+ } as any);
141
+
142
+ CorrectionObserverService.stop?.({} as any);
143
+
144
+ vi.advanceTimersByTime(30_000);
145
+
146
+ expect(mockDispatch).not.toHaveBeenCalled();
147
+ } finally {
148
+ safeRmDir(workspaceDir);
149
+ }
150
+ });
151
+
152
+ it('does not reschedule after stop during active cycle (P2 fix)', async () => {
153
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-obs-race-'));
154
+ const stateDir = path.join(workspaceDir, '.state');
155
+ fs.mkdirSync(stateDir, { recursive: true });
156
+
157
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
158
+
159
+ let cycleResolve: () => void;
160
+ const cyclePromise = new Promise<void>(r => { cycleResolve = r; });
161
+ mockDispatch.mockImplementationOnce(async () => {
162
+ cycleResolve!();
163
+ return { updated: false, summary: 'in-flight' };
164
+ });
165
+
166
+ try {
167
+ CorrectionObserverService.start({
168
+ workspaceDir,
169
+ stateDir,
170
+ logger,
171
+ config: { get: () => undefined },
172
+ } as any);
173
+
174
+ await vi.advanceTimersByTimeAsync(10_000);
175
+ await cyclePromise;
176
+
177
+ CorrectionObserverService.stop?.({} as any);
178
+
179
+ vi.advanceTimersByTime(15 * 60 * 1000 * 2);
180
+
181
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
182
+ } finally {
183
+ CorrectionObserverService.stop?.({} as any);
184
+ safeRmDir(workspaceDir);
185
+ }
186
+ });
187
+
188
+ it('logs structured reason when workspaceDir is missing (ERR-002)', () => {
189
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
190
+
191
+ CorrectionObserverService.start({
192
+ workspaceDir: undefined as any,
193
+ logger,
194
+ config: { get: () => undefined },
195
+ } as any);
196
+
197
+ expect(logger.warn).toHaveBeenCalledWith(
198
+ expect.stringContaining('workspaceDir not found')
199
+ );
200
+ });
201
+
202
+ it('double start same workspace only dispatches one loop (P1 fix)', async () => {
203
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-dbl-'));
204
+ const stateDir = path.join(workspaceDir, '.state');
205
+ fs.mkdirSync(stateDir, { recursive: true });
206
+
207
+ const logger1 = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
208
+ const logger2 = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
209
+
210
+ try {
211
+ CorrectionObserverService.start({
212
+ workspaceDir,
213
+ stateDir,
214
+ logger: logger1,
215
+ config: { get: () => undefined },
216
+ } as any);
217
+
218
+ CorrectionObserverService.start({
219
+ workspaceDir,
220
+ stateDir,
221
+ logger: logger2,
222
+ config: { get: () => undefined },
223
+ } as any);
224
+
225
+ expect(logger2.info).toHaveBeenCalledWith(
226
+ expect.stringContaining('Already started')
227
+ );
228
+
229
+ await vi.advanceTimersByTimeAsync(10_000);
230
+ for (let i = 0; i < 20; i++) {
231
+ await Promise.resolve();
232
+ }
233
+
234
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
235
+ } finally {
236
+ CorrectionObserverService.stop?.({} as any);
237
+ safeRmDir(workspaceDir);
238
+ }
239
+ });
240
+
241
+ it('stop after double start cancels all timers and allows clean restart', async () => {
242
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-stopdbl-'));
243
+ const stateDir = path.join(workspaceDir, '.state');
244
+ fs.mkdirSync(stateDir, { recursive: true });
245
+
246
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
247
+
248
+ try {
249
+ CorrectionObserverService.start({
250
+ workspaceDir,
251
+ stateDir,
252
+ logger,
253
+ config: { get: () => undefined },
254
+ } as any);
255
+
256
+ CorrectionObserverService.start({
257
+ workspaceDir,
258
+ stateDir,
259
+ logger,
260
+ config: { get: () => undefined },
261
+ } as any);
262
+
263
+ CorrectionObserverService.stop?.({} as any);
264
+
265
+ vi.advanceTimersByTime(30_000);
266
+
267
+ expect(mockDispatch).not.toHaveBeenCalled();
268
+
269
+ CorrectionObserverService.start({
270
+ workspaceDir,
271
+ stateDir,
272
+ logger,
273
+ config: { get: () => undefined },
274
+ } as any);
275
+
276
+ expect(logger.info).toHaveBeenCalledWith(
277
+ expect.stringContaining('[PD:CorrectionObserver] Starting')
278
+ );
279
+ } finally {
280
+ CorrectionObserverService.stop?.({} as any);
281
+ safeRmDir(workspaceDir);
282
+ }
283
+ });
284
+ });
285
+
286
+ describe('runCorrectionObserverCycle — Independent Execution', () => {
287
+ it('skips cycle when no recent sessions exist', async () => {
288
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-cycle-'));
289
+ const stateDir = path.join(workspaceDir, '.state');
290
+ fs.mkdirSync(stateDir, { recursive: true });
291
+
292
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
293
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
294
+
295
+ mockDb.listRecentSessions.mockReturnValueOnce([]);
296
+
297
+ try {
298
+ await runCorrectionObserverCycle(wctx, logger as any);
299
+
300
+ expect(logger.info).toHaveBeenCalledWith(
301
+ expect.stringContaining('No recent sessions found')
302
+ );
303
+ expect(mockDispatch).not.toHaveBeenCalled();
304
+ } finally {
305
+ safeRmDir(workspaceDir);
306
+ }
307
+ });
308
+
309
+ it('logs structured error on cycle failure (ERR-002)', async () => {
310
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-err-'));
311
+ const stateDir = path.join(workspaceDir, '.state');
312
+ fs.mkdirSync(stateDir, { recursive: true });
313
+
314
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
315
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
316
+
317
+ mockDb.listRecentSessions.mockImplementationOnce(() => {
318
+ throw new Error('DB connection failed');
319
+ });
320
+
321
+ try {
322
+ await runCorrectionObserverCycle(wctx, logger as any);
323
+
324
+ expect(logger.warn).toHaveBeenCalledWith(
325
+ expect.stringContaining('Correction observer cycle failed')
326
+ );
327
+ } finally {
328
+ safeRmDir(workspaceDir);
329
+ }
330
+ });
331
+ });