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.
@@ -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.79.0",
5
+ "version": "1.81.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.79.0",
3
+ "version": "1.81.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ import { handleExportCommand } from './commands/export.js';
49
49
  import { handleSamplesCommand } from './commands/samples.js';
50
50
  import { handleWorkflowDebugCommand } from './commands/workflow-debug.js';
51
51
  import { EvolutionWorkerService } from './service/evolution-worker.js';
52
+ import { CorrectionObserverService } from './service/correction-observer-service.js';
52
53
  import { TrajectoryService } from './service/trajectory-service.js';
53
54
  import { PDTaskService } from './core/pd-task-service.js';
54
55
  import { CentralSyncService } from './service/central-sync-service.js';
@@ -168,6 +169,30 @@ export function shouldStartEvolutionWorker(
168
169
  return { shouldStart: false, flagSource: flag.source, disabledInfo };
169
170
  }
170
171
 
172
+ export interface CorrectionObserverGateResult {
173
+ shouldStart: boolean;
174
+ flagSource: string;
175
+ disabledInfo: string | null;
176
+ }
177
+
178
+ export function shouldStartCorrectionObserver(
179
+ workspaceDir: string,
180
+ logger: { info?: (msg: string) => void; warn?: (msg: string) => void },
181
+ ): CorrectionObserverGateResult {
182
+ const flag = loadFeatureFlagFromWorkspace(workspaceDir, 'correction_observer', logger);
183
+ if (flag.enabled) {
184
+ return { shouldStart: true, flagSource: flag.source, disabledInfo: null };
185
+ }
186
+ const disabledInfo = JSON.stringify({
187
+ reason: 'correction_observer_disabled',
188
+ nextAction: 'set correction_observer.enabled=true in .pd/feature-flags.yaml to enable',
189
+ featureFlag: 'correction_observer',
190
+ boundedContext: 'correction_observer_service',
191
+ flagSource: flag.source,
192
+ });
193
+ return { shouldStart: false, flagSource: flag.source, disabledInfo };
194
+ }
195
+
171
196
  const plugin = {
172
197
  name: "Principles Disciple",
173
198
  description: "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
@@ -246,6 +271,22 @@ const plugin = {
246
271
  api.logger.info(`[PD] EvolutionWorker NOT started for workspace: ${workspaceDir}. ${gate.disabledInfo}`);
247
272
  SystemLogger.log(workspaceDir, 'EVOLUTION_WORKER_DISABLED', gate.disabledInfo ?? '');
248
273
  }
274
+
275
+ // ── Start CorrectionObserver for THIS workspace ──
276
+ // MVP-Core per ADR-0014 amendment, independently owned (PRI-293).
277
+ const corrGate = shouldStartCorrectionObserver(workspaceDir, api.logger);
278
+ if (corrGate.shouldStart) {
279
+ CorrectionObserverService.start({
280
+ config: api.config,
281
+ workspaceDir,
282
+ stateDir: path.join(workspaceDir, '.state'),
283
+ logger: api.logger,
284
+ });
285
+ api.logger.info(`[PD] CorrectionObserver started for workspace: ${workspaceDir} (flag source: ${corrGate.flagSource})`);
286
+ } else {
287
+ api.logger.info(`[PD] CorrectionObserver NOT started for workspace: ${workspaceDir}. ${corrGate.disabledInfo}`);
288
+ SystemLogger.log(workspaceDir, 'CORRECTION_OBSERVER_DISABLED', corrGate.disabledInfo ?? '');
289
+ }
249
290
  }
250
291
 
251
292
  const result = await handleBeforePromptBuild(event, { ...ctx, api: api as Parameters<typeof handleBeforePromptBuild>[1]['api'], workspaceDir });
@@ -527,6 +568,8 @@ const plugin = {
527
568
  EvolutionWorkerService.api = api;
528
569
  const guardedEvolutionWorker = guardService('service:evolution-worker', EvolutionWorkerService, api.logger);
529
570
  if (guardedEvolutionWorker) api.registerService(guardedEvolutionWorker);
571
+ const guardedCorrectionObserver = guardService('service:correction-observer', CorrectionObserverService, api.logger);
572
+ if (guardedCorrectionObserver) api.registerService(guardedCorrectionObserver);
530
573
  const guardedTrajectory = guardService('service:trajectory', TrajectoryService, api.logger);
531
574
  if (guardedTrajectory) api.registerService(guardedTrajectory);
532
575
  const guardedPdTask = guardService('service:pd-task', PDTaskService, api.logger);
@@ -0,0 +1,200 @@
1
+ import type { OpenClawPluginServiceContext, PluginLogger } from '../openclaw-sdk.js';
2
+ import { WorkspaceContext } from '../core/workspace-context.js';
3
+ import { TrajectoryRegistry } from '../core/trajectory.js';
4
+ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
5
+ import {
6
+ WorkflowFunnelLoader,
7
+ PiAiRuntimeAdapter,
8
+ CorrectionObserver,
9
+ AgentScheduler,
10
+ } from '@principles/core/runtime-v2';
11
+ import { KeywordOptimizationService } from './keyword-optimization-service.js';
12
+ import { SystemLogger } from '../core/system-logger.js';
13
+
14
+ export interface CorrectionObserverServiceShape {
15
+ id: string;
16
+ start: (ctx: OpenClawPluginServiceContext) => void;
17
+ stop?: (ctx: OpenClawPluginServiceContext) => void;
18
+ }
19
+
20
+ let correctionObserverTimeoutId: ReturnType<typeof setTimeout> | null = null;
21
+ let correctionObserverStopped = false;
22
+ const startedWorkspaces = new Set<string>();
23
+
24
+ const CORRECTION_OBSERVER_INTERVAL_MS = 15 * 60 * 1000;
25
+ const CORRECTION_OBSERVER_INITIAL_DELAY_MS = 10_000;
26
+ const CORRECTION_OBSERVER_MAX_RECENT_SESSIONS = 20;
27
+ const CORRECTION_OBSERVER_MAX_PAYLOAD_SESSIONS = 5;
28
+
29
+ export function resolveCorrectionObserver(wctx: WorkspaceContext, logger?: Pick<PluginLogger, 'info' | 'warn' | 'error' | 'debug'>): CorrectionObserver | null {
30
+ try {
31
+ const loader = new WorkflowFunnelLoader(wctx.stateDir);
32
+ const funnel = loader.getFunnel('pd-correction-observer');
33
+ const policy = funnel?.policy;
34
+ if (!policy || policy.runtimeKind !== 'pi-ai') {
35
+ logger?.debug?.('[PD:CorrectionObserver] workflows.yaml pd-correction-observer policy not found. Falling back to environment variables.');
36
+ const provider = process.env.PD_CORRECTION_PROVIDER || 'anthropic';
37
+ const model = process.env.PD_CORRECTION_MODEL || 'anthropic/claude-3-5-sonnet';
38
+ const apiKeyEnv = process.env.PD_CORRECTION_API_KEY_ENV || 'ANTHROPIC_API_KEY';
39
+ const baseUrl = process.env.PD_CORRECTION_BASE_URL;
40
+
41
+ if (!process.env[apiKeyEnv]) {
42
+ logger?.debug?.(`[PD:CorrectionObserver] API key env ${apiKeyEnv} is not set. Periodic optimization disabled.`);
43
+ return null;
44
+ }
45
+
46
+ const adapter = new PiAiRuntimeAdapter({
47
+ provider,
48
+ model,
49
+ apiKeyEnv,
50
+ baseUrl,
51
+ workspace: wctx.workspaceDir,
52
+ });
53
+ return new CorrectionObserver({ runtimeAdapter: adapter });
54
+ }
55
+
56
+ const adapter = new PiAiRuntimeAdapter({
57
+ provider: String(policy.provider),
58
+ model: String(policy.model),
59
+ apiKeyEnv: String(policy.apiKeyEnv),
60
+ maxRetries: policy.maxRetries,
61
+ timeoutMs: policy.timeoutMs ?? 30_000,
62
+ baseUrl: policy.baseUrl,
63
+ workspace: wctx.workspaceDir,
64
+ });
65
+ return new CorrectionObserver({ runtimeAdapter: adapter }, { timeoutMs: policy.timeoutMs });
66
+ } catch (err) {
67
+ logger?.warn?.(`[PD:CorrectionObserver] Failed to resolve CorrectionObserver: ${String(err)}`);
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export async function runCorrectionObserverCycle(wctx: WorkspaceContext, logger: PluginLogger): Promise<void> {
73
+ try {
74
+ const observer = resolveCorrectionObserver(wctx, logger);
75
+ if (!observer) {
76
+ logger?.info?.('[PD:CorrectionObserver] Observer not resolved (no API key or config). Skipping cycle.');
77
+ return;
78
+ }
79
+
80
+ logger?.info?.('[PD:CorrectionObserver] Observer resolved. Initiating periodic optimization...');
81
+
82
+ const db = TrajectoryRegistry.get(wctx.workspaceDir);
83
+ const recentSessions = db.listRecentSessions({ limit: CORRECTION_OBSERVER_MAX_RECENT_SESSIONS });
84
+ const recentSessionIds = recentSessions.map(s => s.sessionId);
85
+
86
+ if (recentSessionIds.length === 0) {
87
+ logger?.info?.('[PD:CorrectionObserver] No recent sessions found. Skipping correction optimization.');
88
+ return;
89
+ }
90
+
91
+ const recentMessages: string[] = [];
92
+ for (const sId of recentSessionIds.slice(0, CORRECTION_OBSERVER_MAX_PAYLOAD_SESSIONS)) {
93
+ try {
94
+ const turns = db.listUserTurnsForSession(sId);
95
+ for (const t of turns) {
96
+ if (t.rawExcerpt) {
97
+ recentMessages.push(t.rawExcerpt);
98
+ }
99
+ }
100
+ } catch (turnErr) {
101
+ logger?.warn?.(`[PD:CorrectionObserver] Failed to load user turns for session ${sId}: ${String(turnErr)}`);
102
+ }
103
+ }
104
+
105
+ const learner = CorrectionCueLearner.get(wctx.stateDir);
106
+ const keywords = learner.getStore().keywords;
107
+ const keywordStoreSummary = {
108
+ totalKeywords: keywords.length,
109
+ terms: keywords.map(k => ({
110
+ term: k.term,
111
+ weight: k.weight,
112
+ hitCount: k.hitCount ?? 0,
113
+ truePositiveCount: k.truePositiveCount ?? 0,
114
+ falsePositiveCount: k.falsePositiveCount ?? 0,
115
+ })),
116
+ };
117
+
118
+ const optimizationService = KeywordOptimizationService.get(wctx.stateDir, wctx.workspaceDir, logger);
119
+ const trajectoryHistory = await optimizationService.buildTrajectoryHistory(recentSessionIds);
120
+
121
+ const payload = {
122
+ parentSessionId: 'correction-observer-service',
123
+ workspaceDir: wctx.workspaceDir,
124
+ keywordStoreSummary,
125
+ recentMessages,
126
+ trajectoryHistory,
127
+ };
128
+
129
+ const scheduler = new AgentScheduler();
130
+ scheduler.register({
131
+ agentId: 'correction-observer',
132
+ mode: 'realtime',
133
+ runner: observer,
134
+ });
135
+
136
+ logger?.info?.(`[PD:CorrectionObserver] Dispatching with ${trajectoryHistory.length} trajectory events, ${recentMessages.length} recent messages.`);
137
+ const result = await scheduler.dispatch('correction-observer', payload);
138
+ logger?.info?.(`[PD:CorrectionObserver] Completed: updated=${result.updated}, summary="${result.summary}"`);
139
+
140
+ if (result.updated) {
141
+ optimizationService.applyResult(result);
142
+ }
143
+ } catch (err) {
144
+ const errMsg = `Correction observer cycle failed: ${String(err)}`;
145
+ logger?.warn?.(`[PD:CorrectionObserver] ${errMsg}`);
146
+ SystemLogger.log(wctx.workspaceDir, 'CORRECTION_OBSERVER_CYCLE_FAILED', errMsg);
147
+ }
148
+ }
149
+
150
+ export const CorrectionObserverService: CorrectionObserverServiceShape = {
151
+ id: 'principles-correction-observer',
152
+
153
+ start(ctx: OpenClawPluginServiceContext): void {
154
+ const workspaceDir = ctx?.workspaceDir;
155
+ const logger = ctx?.logger || console;
156
+
157
+ if (!workspaceDir) {
158
+ if (logger) logger.warn('[PD:CorrectionObserver] workspaceDir not found in service config. Correction observer disabled.');
159
+ return;
160
+ }
161
+
162
+ if (startedWorkspaces.has(workspaceDir)) {
163
+ if (logger) logger.info(`[PD:CorrectionObserver] Already started for workspace: ${workspaceDir}. Skipping duplicate start.`);
164
+ return;
165
+ }
166
+ startedWorkspaces.add(workspaceDir);
167
+
168
+ correctionObserverStopped = false;
169
+
170
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
171
+ if (logger) logger.info(`[PD:CorrectionObserver] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
172
+
173
+ const interval = CORRECTION_OBSERVER_INTERVAL_MS;
174
+
175
+ async function runCycle(): Promise<void> {
176
+ if (correctionObserverStopped) return;
177
+ await runCorrectionObserverCycle(wctx, logger);
178
+ if (correctionObserverStopped) return;
179
+ correctionObserverTimeoutId = setTimeout(runCycle, interval);
180
+ correctionObserverTimeoutId.unref();
181
+ }
182
+
183
+ correctionObserverTimeoutId = setTimeout(() => {
184
+ void runCycle().catch((err) => {
185
+ if (logger) logger.error(`[PD:CorrectionObserver] Startup cycle failed: ${String(err)}`);
186
+ if (correctionObserverStopped) return;
187
+ correctionObserverTimeoutId = setTimeout(runCycle, interval);
188
+ correctionObserverTimeoutId.unref();
189
+ });
190
+ }, CORRECTION_OBSERVER_INITIAL_DELAY_MS);
191
+ correctionObserverTimeoutId.unref();
192
+ },
193
+
194
+ stop(_ctx: OpenClawPluginServiceContext): void {
195
+ correctionObserverStopped = true;
196
+ startedWorkspaces.clear();
197
+ if (correctionObserverTimeoutId) clearTimeout(correctionObserverTimeoutId);
198
+ correctionObserverTimeoutId = null;
199
+ },
200
+ };
@@ -22,14 +22,7 @@ export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock
22
22
  export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
23
23
  import { saveEvolutionQueue, requireQueueLock } from './queue-io.js';
24
24
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
25
- import {
26
- WorkflowFunnelLoader,
27
- PiAiRuntimeAdapter,
28
- CorrectionObserver,
29
- AgentScheduler,
30
- } from '@principles/core/runtime-v2';
31
- import { KeywordOptimizationService } from './keyword-optimization-service.js';
32
- import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
25
+
33
26
 
34
27
  import { PrincipleCompiler } from '../core/principle-compiler/index.js';
35
28
  import { loadLedger, updatePrinciple } from '../core/principle-tree-ledger.js';
@@ -632,49 +625,6 @@ async function processEvolutionQueueWithResult(
632
625
  return { queue: queueResult, errors };
633
626
  }
634
627
 
635
- function resolveCorrectionObserver(wctx: WorkspaceContext, logger?: Pick<PluginLogger, 'info' | 'warn' | 'error' | 'debug'>): CorrectionObserver | null {
636
- try {
637
- const loader = new WorkflowFunnelLoader(wctx.stateDir);
638
- const funnel = loader.getFunnel('pd-correction-observer');
639
- const policy = funnel?.policy;
640
- if (!policy || policy.runtimeKind !== 'pi-ai') {
641
- logger?.debug?.('[PD:Correction] workflows.yaml pd-correction-observer policy not found. Falling back to environment variables.');
642
- const provider = process.env.PD_CORRECTION_PROVIDER || 'anthropic';
643
- const model = process.env.PD_CORRECTION_MODEL || 'anthropic/claude-3-5-sonnet';
644
- const apiKeyEnv = process.env.PD_CORRECTION_API_KEY_ENV || 'ANTHROPIC_API_KEY';
645
- const baseUrl = process.env.PD_CORRECTION_BASE_URL;
646
-
647
- if (!process.env[apiKeyEnv]) {
648
- logger?.debug?.(`[PD:Correction] Correction observer API key env ${apiKeyEnv} is not set. Periodic optimization disabled.`);
649
- return null;
650
- }
651
-
652
- const adapter = new PiAiRuntimeAdapter({
653
- provider,
654
- model,
655
- apiKeyEnv,
656
- baseUrl,
657
- workspace: wctx.workspaceDir,
658
- });
659
- return new CorrectionObserver({ runtimeAdapter: adapter });
660
- }
661
-
662
- const adapter = new PiAiRuntimeAdapter({
663
- provider: String(policy.provider),
664
- model: String(policy.model),
665
- apiKeyEnv: String(policy.apiKeyEnv),
666
- maxRetries: policy.maxRetries,
667
- timeoutMs: policy.timeoutMs ?? 30_000,
668
- baseUrl: policy.baseUrl,
669
- workspace: wctx.workspaceDir,
670
- });
671
- return new CorrectionObserver({ runtimeAdapter: adapter }, { timeoutMs: policy.timeoutMs });
672
- } catch (err) {
673
- logger?.warn?.(`[PD:Correction] Failed to resolve CorrectionObserver: ${String(err)}`);
674
- return null;
675
- }
676
- }
677
-
678
628
  export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
679
629
  id: 'principles-evolution-worker',
680
630
  api: null,
@@ -759,78 +709,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
759
709
  await processDetectionQueue(wctx, api, eventLog);
760
710
  }
761
711
  // processPromotion removed (D-06) — promotion via PAIN_CANDIDATES no longer needed
762
-
763
- // ── Correction Observer: periodic keyword optimization (D-40-08 / H-1) ──
764
- try {
765
- const observer = resolveCorrectionObserver(wctx, logger);
766
- if (observer) {
767
- logger?.info?.('[PD:EvolutionWorker] Correction Observer resolved. Initiating periodic optimization...');
768
- const db = TrajectoryRegistry.get(wctx.workspaceDir);
769
- const recentSessions = db.listRecentSessions({ limit: 20 });
770
- const recentSessionIds = recentSessions.map(s => s.sessionId);
771
-
772
- if (recentSessionIds.length > 0) {
773
- const recentMessages: string[] = [];
774
- for (const sId of recentSessionIds.slice(0, 5)) {
775
- try {
776
- const turns = db.listUserTurnsForSession(sId);
777
- for (const t of turns) {
778
- if (t.rawExcerpt) {
779
- recentMessages.push(t.rawExcerpt);
780
- }
781
- }
782
- } catch (turnErr) {
783
- logger?.warn?.(`[PD:EvolutionWorker] Failed to load user turns for session ${sId}: ${String(turnErr)}`);
784
- }
785
- }
786
-
787
- const learner = CorrectionCueLearner.get(wctx.stateDir);
788
- const keywords = learner.getStore().keywords;
789
- const keywordStoreSummary = {
790
- totalKeywords: keywords.length,
791
- terms: keywords.map(k => ({
792
- term: k.term,
793
- weight: k.weight,
794
- hitCount: k.hitCount ?? 0,
795
- truePositiveCount: k.truePositiveCount ?? 0,
796
- falsePositiveCount: k.falsePositiveCount ?? 0,
797
- })),
798
- };
799
-
800
- const optimizationService = KeywordOptimizationService.get(wctx.stateDir, wctx.workspaceDir, logger);
801
- const trajectoryHistory = await optimizationService.buildTrajectoryHistory(recentSessionIds);
802
-
803
- const payload = {
804
- parentSessionId: 'evolution-worker',
805
- workspaceDir: wctx.workspaceDir,
806
- keywordStoreSummary,
807
- recentMessages,
808
- trajectoryHistory,
809
- };
810
-
811
- const scheduler = new AgentScheduler();
812
- scheduler.register({
813
- agentId: 'correction-observer',
814
- mode: 'realtime',
815
- runner: observer,
816
- });
817
-
818
- logger?.info?.(`[PD:EvolutionWorker] Dispatching correction-observer with ${trajectoryHistory.length} trajectory events, ${recentMessages.length} recent messages.`);
819
- const result = await scheduler.dispatch('correction-observer', payload);
820
- logger?.info?.(`[PD:EvolutionWorker] Correction-observer completed: updated=${result.updated}, summary="${result.summary}"`);
821
-
822
- if (result.updated) {
823
- optimizationService.applyResult(result);
824
- }
825
- } else {
826
- logger?.info?.('[PD:EvolutionWorker] No recent sessions found. Skipping correction optimization.');
827
- }
828
- }
829
- } catch (corrErr) {
830
- const corrErrMsg = `Correction observer execution failed: ${String(corrErr)}`;
831
- cycleResult.errors.push(corrErrMsg);
832
- logger?.warn?.(`[PD:EvolutionWorker] ${corrErrMsg}`);
833
- }
712
+ // Correction Observer extracted to independent service (PRI-293) — no longer runs on EvolutionWorker heartbeat
834
713
 
835
714
  try {
836
715
  const subagentRuntime = api?.runtime?.subagent;
@@ -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
+ });