principles-disciple 1.78.0 → 1.80.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,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.78.0",
5
+ "version": "1.80.0",
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.78.0",
3
+ "version": "1.80.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -1,23 +1,11 @@
1
1
  import * as path from 'path';
2
2
  import * as fs from 'fs';
3
3
  import * as yaml from 'js-yaml';
4
- import { SqliteConnection, SqliteActivationStateStore, computeEffectiveFlags, DEFAULT_FEATURE_FLAGS } from '@principles/core/runtime-v2';
5
- import type { ActivationStatusRecord, EffectiveFeatureFlags } from '@principles/core/runtime-v2';
4
+ import { SqliteConnection, SqliteActivationStateStore, computeEffectiveFlags, DEFAULT_FEATURE_FLAGS, filterPromptActivations, resolvePrincipleFromArtifact } from '@principles/core/runtime-v2';
5
+ import type { EffectiveFeatureFlags, ActivatedPrinciple, PromptActivationReaderResult } from '@principles/core/runtime-v2';
6
6
 
7
- export const RUNTIME_V2_PRINCIPLE_BUDGET = 2000;
8
-
9
- export interface ActivatedPrinciple {
10
- principleId: string;
11
- text: string;
12
- artifactId: string;
13
- activationId: string;
14
- }
15
-
16
- export interface PromptActivationReaderResult {
17
- principles: ActivatedPrinciple[];
18
- warnings: string[];
19
- source: 'runtime_v2';
20
- }
7
+ export { RUNTIME_V2_PRINCIPLE_BUDGET } from '@principles/core/runtime-v2';
8
+ export type { ActivatedPrinciple, PromptActivationReaderResult };
21
9
 
22
10
  export interface PromptActivationReaderDeps {
23
11
  logger?: { warn?: (msg: string) => void; info?: (msg: string) => void; error?: (msg: string) => void };
@@ -54,14 +42,29 @@ export class PromptActivationReader {
54
42
  sqliteConn = new SqliteConnection(this.workspaceDir);
55
43
  const store = new SqliteActivationStateStore(sqliteConn);
56
44
 
57
- const activations = await store.listPromptActivations();
45
+ const allActivations = await store.listPromptActivations();
46
+ const promptActivations = filterPromptActivations(allActivations);
47
+
48
+ for (const activation of promptActivations) {
49
+ let artifactRow: unknown | null;
50
+ try {
51
+ artifactRow = this.queryArtifactRow(sqliteConn, activation.artifactId);
52
+ } catch (e) {
53
+ const msg = e instanceof Error ? e.message : String(e);
54
+ const warning = `artifact_query_failed: artifactId=${activation.artifactId} reason=${msg}; nextAction=check_pi_artifacts_table`;
55
+ warnings.push(warning);
56
+ this.deps.logger?.warn?.(`[PD:RuntimeV2] ${warning}`);
57
+ continue;
58
+ }
58
59
 
59
- for (const activation of activations) {
60
- if (activation.channel !== 'prompt' || activation.action !== 'prompt_activate') {
60
+ if (artifactRow === null) {
61
+ const warning = `artifact_not_found: artifactId=${activation.artifactId}; nextAction=check_pi_artifacts_table`;
62
+ warnings.push(warning);
63
+ this.deps.logger?.warn?.(`[PD:RuntimeV2] ${warning}`);
61
64
  continue;
62
65
  }
63
66
 
64
- const result = this.resolvePrincipleFromActivation(sqliteConn, activation);
67
+ const result = resolvePrincipleFromArtifact(artifactRow, activation);
65
68
  if (result.ok) {
66
69
  principles.push(result.principle);
67
70
  } else {
@@ -85,6 +88,21 @@ export class PromptActivationReader {
85
88
  return { principles, warnings, source: 'runtime_v2' };
86
89
  }
87
90
 
91
+ private queryArtifactRow(sqliteConn: SqliteConnection, artifactId: string): unknown | null {
92
+ try {
93
+ const db = sqliteConn.getDb();
94
+ const row = db.prepare(`
95
+ SELECT artifact_id, artifact_kind, content_json, validation_status
96
+ FROM pi_artifacts
97
+ WHERE artifact_id = ?
98
+ `).get(artifactId);
99
+ return row ?? null;
100
+ } catch (e) {
101
+ const msg = e instanceof Error ? e.message : String(e);
102
+ throw new Error(`artifact_query_failed: artifactId=${artifactId} reason=${msg}; nextAction=check_pi_artifacts_table`);
103
+ }
104
+ }
105
+
88
106
  private loadFeatureFlags(): EffectiveFeatureFlags {
89
107
  const configPath = path.join(this.workspaceDir, '.pd', 'feature-flags.yaml');
90
108
 
@@ -141,91 +159,4 @@ export class PromptActivationReader {
141
159
  }
142
160
  return result;
143
161
  }
144
-
145
- private resolvePrincipleFromActivation(
146
- sqliteConn: SqliteConnection,
147
- activation: ActivationStatusRecord,
148
- ): { ok: true; principle: ActivatedPrinciple } | { ok: false; warning: string } {
149
- const db = sqliteConn.getDb();
150
-
151
- let contentJson: string;
152
-
153
- try {
154
- const row = db.prepare(`
155
- SELECT artifact_id, artifact_kind, content_json, validation_status
156
- FROM pi_artifacts
157
- WHERE artifact_id = ?
158
- `).get(activation.artifactId);
159
-
160
- if (!isRecord(row)) {
161
- return { ok: false, warning: `artifact_query_unexpected: artifactId=${activation.artifactId}; nextAction=check_pi_artifacts_table` };
162
- }
163
-
164
- const artifact_id = Object.hasOwn(row, 'artifact_id') && typeof row.artifact_id === 'string' && row.artifact_id.length > 0 ? row.artifact_id : null;
165
- const artifact_kind = Object.hasOwn(row, 'artifact_kind') && typeof row.artifact_kind === 'string' ? row.artifact_kind : null;
166
- const raw_content_json = Object.hasOwn(row, 'content_json') && typeof row.content_json === 'string' ? row.content_json : null;
167
- const validation_status = Object.hasOwn(row, 'validation_status') && typeof row.validation_status === 'string' ? row.validation_status : null;
168
-
169
- if (!artifact_id) {
170
- return { ok: false, warning: `artifact_not_found: artifactId=${activation.artifactId} activationId=${activation.activationId}; nextAction=verify_artifact_exists_or_remove_stale_activation` };
171
- }
172
-
173
- if (artifact_kind !== 'principle') {
174
- return { ok: false, warning: `artifact_not_principle: artifactId=${artifact_id} kind=${artifact_kind ?? 'missing'}; nextAction=skip_non_principle_activations` };
175
- }
176
-
177
- if (validation_status !== 'validated') {
178
- return { ok: false, warning: `artifact_not_validated: artifactId=${artifact_id} status=${validation_status ?? 'missing'}; nextAction=skip_unvalidated_artifacts` };
179
- }
180
-
181
- if (raw_content_json === null) {
182
- return { ok: false, warning: `artifact_missing_content_json: artifactId=${artifact_id}; nextAction=ensure_artifact_has_content_json` };
183
- }
184
-
185
- contentJson = raw_content_json;
186
- } catch (e) {
187
- const msg = e instanceof Error ? e.message : String(e);
188
- return { ok: false, warning: `artifact_query_failed: artifactId=${activation.artifactId} reason=${msg}; nextAction=check_pi_artifacts_table` };
189
- }
190
-
191
- let parsed: unknown;
192
- try {
193
- parsed = JSON.parse(contentJson);
194
- } catch (e) {
195
- const msg = e instanceof Error ? e.message : String(e);
196
- return { ok: false, warning: `artifact_content_json_parse_error: artifactId=${activation.artifactId} reason=${msg}; nextAction=fix_artifact_content_json` };
197
- }
198
-
199
- if (!isRecord(parsed)) {
200
- return { ok: false, warning: `artifact_content_malformed: artifactId=${activation.artifactId} reason=parsed_to_non_object; nextAction=fix_artifact_content_json` };
201
- }
202
-
203
- const principleId = Object.hasOwn(parsed, 'principleId') && typeof parsed.principleId === 'string' ? parsed.principleId : undefined;
204
- const text = Object.hasOwn(parsed, 'text') && typeof parsed.text === 'string' ? parsed.text : undefined;
205
-
206
- const draftObj = Object.hasOwn(parsed, 'principleDraft') && isRecord(parsed.principleDraft) ? parsed.principleDraft : null;
207
- const draftTitle = draftObj && Object.hasOwn(draftObj, 'title') && typeof draftObj.title === 'string' ? draftObj.title : undefined;
208
- const draftStatement = draftObj && Object.hasOwn(draftObj, 'statement') && typeof draftObj.statement === 'string' ? draftObj.statement : undefined;
209
-
210
- const resolvedPrincipleId = principleId && principleId.length > 0 ? principleId : draftTitle;
211
- const resolvedText = text && text.length > 0 ? text : draftStatement;
212
-
213
- if (!resolvedPrincipleId || resolvedPrincipleId.length === 0) {
214
- return { ok: false, warning: `artifact_missing_principle_id: artifactId=${activation.artifactId}; nextAction=ensure_artifact_has_principleId_or_principleDraft_title` };
215
- }
216
-
217
- if (!resolvedText || resolvedText.length === 0) {
218
- return { ok: false, warning: `artifact_missing_text: artifactId=${activation.artifactId} principleId=${resolvedPrincipleId}; nextAction=ensure_artifact_has_text_or_principleDraft_statement` };
219
- }
220
-
221
- return {
222
- ok: true,
223
- principle: {
224
- principleId: resolvedPrincipleId,
225
- text: resolvedText,
226
- artifactId: activation.artifactId,
227
- activationId: activation.activationId,
228
- },
229
- };
230
- }
231
162
  }
@@ -12,9 +12,9 @@ import { defaultContextConfig } from '../types.js';
12
12
  import { extractSummary, getHistoryVersions, parseWorkingMemorySection, workingMemoryToInjection, autoCompressFocus, safeReadCurrentFocus } from '../core/focus-history.js';
13
13
  import { PathResolver } from '../core/path-resolver.js';
14
14
  import { selectPrinciplesForInjection, DEFAULT_PRINCIPLE_BUDGET } from '../core/principle-injection.js';
15
- import { getCachedMaskedPrincipleSet, WorkflowFunnelLoader, PiAiRuntimeAdapter, EmpathyObserver, AgentScheduler } from '@principles/core/runtime-v2';
15
+ import { getCachedMaskedPrincipleSet, WorkflowFunnelLoader, PiAiRuntimeAdapter, EmpathyObserver, AgentScheduler, RUNTIME_V2_PRINCIPLE_BUDGET, trimToBudget, renderPrinciplesToDirectives } from '@principles/core/runtime-v2';
16
16
  import { truncateInjectionToBudget } from '@principles/core/prompt-builder';
17
- import { PromptActivationReader, RUNTIME_V2_PRINCIPLE_BUDGET } from '../core/runtime-v2-prompt-activation-reader.js';
17
+ import { PromptActivationReader } from '../core/runtime-v2-prompt-activation-reader.js';
18
18
  import {
19
19
  matchEmpathyKeywords,
20
20
  loadKeywordStore,
@@ -898,20 +898,12 @@ ${heartbeatChecklist}
898
898
  dedupedV2 = v2Result.principles.filter((p) => !legacyActiveIds.has(p.principleId));
899
899
 
900
900
  if (dedupedV2.length > 0) {
901
- let remaining = RUNTIME_V2_PRINCIPLE_BUDGET;
902
- const lines: string[] = [];
903
- lines.push('Runtime V2 activated principles (owner-approved):');
904
- remaining -= 'Runtime V2 activated principles (owner-approved):'.length;
905
-
906
- for (const p of dedupedV2) {
907
- const entry = `- [${escapeXml(p.principleId)}] ${escapeXml(p.text)}`;
908
- if (remaining < entry.length + 1) {
909
- logger?.info?.(`[PD:RuntimeV2] Principle budget reached (${RUNTIME_V2_PRINCIPLE_BUDGET}c) — truncating after ${lines.length - 1} principles`);
910
- break;
911
- }
912
- lines.push(entry);
913
- remaining -= entry.length + 1;
914
- runtimeV2PrincipleIds.add(p.principleId);
901
+ const { lines, injectedIds, truncated } = trimToBudget(dedupedV2, RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml);
902
+ if (truncated) {
903
+ logger?.info?.(`[PD:RuntimeV2] Principle budget reached (${RUNTIME_V2_PRINCIPLE_BUDGET}c) — truncating after ${injectedIds.size} principles`);
904
+ }
905
+ for (const id of injectedIds) {
906
+ runtimeV2PrincipleIds.add(id);
915
907
  }
916
908
  runtimeV2PrinciplesContent = lines.join('\n');
917
909
  }
@@ -984,24 +976,8 @@ ${empathySilenceConstraint}
984
976
  // PLACED IN prependSystemContext (before gateway system prompt) for highest LLM attention.
985
977
  // These are owner-reviewed, validated behavior constraints — not background context.
986
978
  if (runtimeV2PrincipleIds.size > 0) {
987
- const directiveLines: string[] = [];
988
- directiveLines.push('');
989
- directiveLines.push('## 【OWNER-APPROVED BEHAVIOR DIRECTIVES】');
990
- directiveLines.push('');
991
- directiveLines.push('Owner-approved behavior directives are active operating constraints learned from prior owner corrections.');
992
- directiveLines.push('These directives are mandatory for this session unless they conflict with safety, security, or higher-priority system policy.');
993
- directiveLines.push('For ambiguous coding or file-changing tasks, follow these directives before using mutating tools.');
994
- directiveLines.push('');
995
- for (const p of dedupedV2) {
996
- if (!runtimeV2PrincipleIds.has(p.principleId)) continue;
997
- directiveLines.push(`<directive id="${escapeXml(p.principleId)}" source="runtime_v2_activation">`);
998
- directiveLines.push(`MANDATORY: ${escapeXml(p.text)}`);
999
- directiveLines.push('Apply this as an active behavior constraint. Do not treat this as background context.');
1000
- directiveLines.push('</directive>');
1001
- directiveLines.push('');
1002
- }
1003
- directiveLines.push('Note: These directives do not override safety, security, or core system policy.');
1004
- prependSystemContext += directiveLines.join('\n');
979
+ const directiveText = renderPrinciplesToDirectives(dedupedV2, runtimeV2PrincipleIds, escapeXml);
980
+ prependSystemContext += directiveText;
1005
981
  }
1006
982
 
1007
983
  // Routing guidance removed per PRI-291 (MVP diet).
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ checkSurfaceGuard,
4
+ isSurfaceEnabled,
5
+ guardHook,
6
+ guardService,
7
+ getSurfaceIdForHook,
8
+ getSurfaceIdForService,
9
+ } from '../../src/core/surface-guard.js';
10
+ import { PLUGIN_SURFACE_REGISTRY } from '@principles/core/runtime-v2';
11
+ import type { OpenClawPluginService } from '../../src/openclaw-sdk.js';
12
+
13
+ describe('surface-guard', () => {
14
+ describe('getSurfaceIdForHook', () => {
15
+ it('generates correct surface id without label', () => {
16
+ expect(getSurfaceIdForHook('before_tool_call')).toBe('hook:before_tool_call');
17
+ });
18
+
19
+ it('generates correct surface id with label', () => {
20
+ expect(getSurfaceIdForHook('after_tool_call', 'trajectory')).toBe('hook:after_tool_call.trajectory');
21
+ });
22
+ });
23
+
24
+ describe('getSurfaceIdForService', () => {
25
+ it('generates correct surface id for service', () => {
26
+ expect(getSurfaceIdForService('evolution-worker')).toBe('service:evolution-worker');
27
+ });
28
+ });
29
+
30
+ describe('checkSurfaceGuard', () => {
31
+ it('returns passed=true when registry is valid', () => {
32
+ const result = checkSurfaceGuard();
33
+ expect(result.passed).toBe(true);
34
+ expect(result.violations).toEqual([]);
35
+ });
36
+
37
+ it('includes enabled core surfaces', () => {
38
+ const result = checkSurfaceGuard();
39
+ expect(result.enabledCoreSurfaces.length).toBeGreaterThan(0);
40
+ for (const surfaceId of result.enabledCoreSurfaces) {
41
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === surfaceId);
42
+ expect(entry).toBeDefined();
43
+ expect(entry?.category).toBe('core');
44
+ }
45
+ });
46
+
47
+ it('includes disabled non-core surfaces', () => {
48
+ const result = checkSurfaceGuard();
49
+ expect(result.disabledNonCoreSurfaces.length).toBeGreaterThan(0);
50
+ for (const surfaceId of result.disabledNonCoreSurfaces) {
51
+ const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === surfaceId);
52
+ expect(entry).toBeDefined();
53
+ expect(entry?.category).not.toBe('core');
54
+ expect(entry?.enabledByDefault).toBe(false);
55
+ }
56
+ });
57
+
58
+ it('returns violations when non-core surface is enabledByDefault', () => {
59
+ const result = checkSurfaceGuard();
60
+ const nonCoreEnabled = PLUGIN_SURFACE_REGISTRY.filter(
61
+ s => s.category !== 'core' && s.enabledByDefault,
62
+ );
63
+ if (nonCoreEnabled.length > 0) {
64
+ expect(result.violations.length).toBeGreaterThan(0);
65
+ }
66
+ });
67
+ });
68
+
69
+ describe('isSurfaceEnabled', () => {
70
+ it('returns enabled=true for core surface without override', () => {
71
+ const result = isSurfaceEnabled('hook:before_prompt_build');
72
+ expect(result.enabled).toBe(true);
73
+ expect(result.reason).toBeUndefined();
74
+ });
75
+
76
+ it('returns enabled=false for quiet surface without override', () => {
77
+ const result = isSurfaceEnabled('hook:after_tool_call.trajectory');
78
+ expect(result.enabled).toBe(false);
79
+ expect(result.reason).toBeDefined();
80
+ });
81
+
82
+ it('returns reason when surface not found', () => {
83
+ const result = isSurfaceEnabled('hook:nonexistent_hook');
84
+ expect(result.enabled).toBe(false);
85
+ expect(result.reason).toContain('not found in registry');
86
+ });
87
+
88
+ it('allows override for quiet surface', () => {
89
+ const result = isSurfaceEnabled('hook:after_tool_call.trajectory', {
90
+ 'hook:after_tool_call.trajectory': true,
91
+ });
92
+ expect(result.enabled).toBe(true);
93
+ });
94
+
95
+ it('ignores non-boolean override', () => {
96
+ const result = isSurfaceEnabled('hook:before_prompt_build', {
97
+ 'hook:before_prompt_build': 'yes' as unknown as boolean,
98
+ });
99
+ expect(result.enabled).toBe(true);
100
+ });
101
+
102
+ it('cannot disable core surface', () => {
103
+ const result = isSurfaceEnabled('hook:before_prompt_build', {
104
+ 'hook:before_prompt_build': false,
105
+ });
106
+ expect(result.enabled).toBe(true);
107
+ expect(result.reason).toContain('core');
108
+ });
109
+
110
+ it('returns disabledReason for disabled surface', () => {
111
+ const result = isSurfaceEnabled('service:evolution-worker');
112
+ expect(result.enabled).toBe(false);
113
+ expect(result.reason).toContain('evolution_worker');
114
+ });
115
+ });
116
+
117
+ describe('guardHook', () => {
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+ });
121
+
122
+ it('returns original handler for enabled surface', () => {
123
+ const mockHandler = vi.fn().mockReturnValue('result');
124
+ const guarded = guardHook('hook:before_prompt_build', undefined, mockHandler);
125
+ const result = guarded({}, {});
126
+ expect(mockHandler).toHaveBeenCalled();
127
+ expect(result).toBe('result');
128
+ });
129
+
130
+ it('returns no-op for disabled surface without logger', () => {
131
+ const mockHandler = vi.fn();
132
+ const guarded = guardHook('hook:after_tool_call.trajectory', undefined, mockHandler);
133
+ const result = guarded({}, {});
134
+ expect(mockHandler).not.toHaveBeenCalled();
135
+ expect(result).toBeUndefined();
136
+ });
137
+
138
+ it('logs when surface is disabled with logger', () => {
139
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
140
+ const mockHandler = vi.fn();
141
+ const guarded = guardHook('hook:after_tool_call.trajectory', mockLogger, mockHandler);
142
+ guarded({}, {});
143
+ expect(mockLogger.info).toHaveBeenCalled();
144
+ expect(mockHandler).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('does not log for enabled surface', () => {
148
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
149
+ const mockHandler = vi.fn();
150
+ const guarded = guardHook('hook:before_prompt_build', mockLogger, mockHandler);
151
+ guarded({}, {});
152
+ expect(mockLogger.info).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('guards unknown surface with not-found reason', () => {
156
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
157
+ const guarded = guardHook('hook:unknown_hook', mockLogger, vi.fn());
158
+ guarded({}, {});
159
+ expect(mockLogger.info).toHaveBeenCalledWith(
160
+ expect.stringContaining('not found in registry'),
161
+ );
162
+ });
163
+ });
164
+
165
+ describe('guardService', () => {
166
+ beforeEach(() => {
167
+ vi.clearAllMocks();
168
+ });
169
+
170
+ it('returns original service for enabled surface', () => {
171
+ const mockService: OpenClawPluginService = { id: 'test-service' };
172
+ const result = guardService('hook:before_prompt_build', mockService);
173
+ expect(result).toBe(mockService);
174
+ });
175
+
176
+ it('returns null for disabled surface without logger', () => {
177
+ const mockService: OpenClawPluginService = { id: 'test-service' };
178
+ const result = guardService('hook:after_tool_call.trajectory', mockService);
179
+ expect(result).toBeNull();
180
+ });
181
+
182
+ it('logs when surface is disabled with logger', () => {
183
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
184
+ const mockService: OpenClawPluginService = { id: 'test-service' };
185
+ const result = guardService('hook:after_tool_call.trajectory', mockService, mockLogger);
186
+ expect(result).toBeNull();
187
+ expect(mockLogger.info).toHaveBeenCalledWith(
188
+ expect.stringContaining('SKIP service'),
189
+ );
190
+ });
191
+
192
+ it('returns null for unknown surface', () => {
193
+ const mockService: OpenClawPluginService = { id: 'test-service' };
194
+ const result = guardService('service:nonexistent', mockService);
195
+ expect(result).toBeNull();
196
+ });
197
+ });
198
+ });
@@ -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
+ });