principles-disciple 1.76.0 → 1.77.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.
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PLUGIN_SURFACE_REGISTRY,
|
|
3
|
+
validateSurfaceRegistry,
|
|
4
|
+
getSurfacesByCategory,
|
|
5
|
+
type PluginSurfaceEntry,
|
|
6
|
+
type MvpCategory,
|
|
7
|
+
} from '@principles/core/runtime-v2';
|
|
8
|
+
import type { OpenClawPluginService } from '../openclaw-sdk.js';
|
|
9
|
+
|
|
10
|
+
export interface SurfaceGuardResult {
|
|
11
|
+
passed: boolean;
|
|
12
|
+
enabledCoreSurfaces: string[];
|
|
13
|
+
disabledNonCoreSurfaces: string[];
|
|
14
|
+
violations: string[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function checkSurfaceGuard(): SurfaceGuardResult {
|
|
19
|
+
const validation = validateSurfaceRegistry(PLUGIN_SURFACE_REGISTRY);
|
|
20
|
+
const violations: string[] = [];
|
|
21
|
+
const warnings: string[] = [...validation.warnings];
|
|
22
|
+
|
|
23
|
+
const coreSurfaces = getSurfacesByCategory(PLUGIN_SURFACE_REGISTRY, 'core');
|
|
24
|
+
const enabledCore = coreSurfaces.filter(s => s.enabledByDefault);
|
|
25
|
+
const nonCoreEnabled = PLUGIN_SURFACE_REGISTRY.filter(
|
|
26
|
+
s => s.category !== 'core' && s.enabledByDefault,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (nonCoreEnabled.length > 0) {
|
|
30
|
+
for (const surface of nonCoreEnabled) {
|
|
31
|
+
violations.push(
|
|
32
|
+
`non-core surface '${surface.id}' (${surface.category}) is enabledByDefault=true — must be false per ADR-0014`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!validation.valid) {
|
|
38
|
+
violations.push(...validation.errors);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
passed: violations.length === 0,
|
|
43
|
+
enabledCoreSurfaces: enabledCore.map(s => s.id),
|
|
44
|
+
disabledNonCoreSurfaces: PLUGIN_SURFACE_REGISTRY
|
|
45
|
+
.filter(s => s.category !== 'core' && !s.enabledByDefault)
|
|
46
|
+
.map(s => s.id),
|
|
47
|
+
violations,
|
|
48
|
+
warnings,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getSurfaceIdForHook(hookEvent: string, label?: string): string {
|
|
53
|
+
if (label) {
|
|
54
|
+
return `hook:${hookEvent}.${label}`;
|
|
55
|
+
}
|
|
56
|
+
return `hook:${hookEvent}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getSurfaceIdForService(serviceName: string): string {
|
|
60
|
+
return `service:${serviceName}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isSurfaceEnabled(
|
|
64
|
+
surfaceId: string,
|
|
65
|
+
overrides: Record<string, boolean> = {},
|
|
66
|
+
): { enabled: boolean; reason?: string } {
|
|
67
|
+
const entry = PLUGIN_SURFACE_REGISTRY.find(s => s.id === surfaceId);
|
|
68
|
+
|
|
69
|
+
if (!entry) {
|
|
70
|
+
return {
|
|
71
|
+
enabled: false,
|
|
72
|
+
reason: `surface '${surfaceId}' not found in registry — classify before enabling (PRI-289)`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Object.hasOwn(overrides, surfaceId)) {
|
|
77
|
+
const override = overrides[surfaceId];
|
|
78
|
+
if (typeof override !== 'boolean') {
|
|
79
|
+
return { enabled: entry.enabledByDefault, reason: `override for '${surfaceId}' is not boolean, using default` };
|
|
80
|
+
}
|
|
81
|
+
if (entry.category === 'gone') {
|
|
82
|
+
return { enabled: false, reason: `surface '${surfaceId}' is gone and cannot be re-enabled` };
|
|
83
|
+
}
|
|
84
|
+
if (entry.category === 'core' && !override) {
|
|
85
|
+
return { enabled: true, reason: `surface '${surfaceId}' is core and cannot be disabled` };
|
|
86
|
+
}
|
|
87
|
+
return { enabled: override };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!entry.enabledByDefault && entry.disabledReason) {
|
|
91
|
+
return { enabled: false, reason: entry.disabledReason };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { enabled: entry.enabledByDefault };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type HookHandler<E, C, R> = (event: E, ctx: C) => R | Promise<R>;
|
|
98
|
+
|
|
99
|
+
export function guardHook<E, C, R>(
|
|
100
|
+
surfaceId: string,
|
|
101
|
+
logger: { info?: (msg: string) => void; debug?: (msg: string) => void } | undefined,
|
|
102
|
+
handler: HookHandler<E, C, R>,
|
|
103
|
+
): HookHandler<E, C, R> {
|
|
104
|
+
const check = isSurfaceEnabled(surfaceId);
|
|
105
|
+
if (check.enabled) {
|
|
106
|
+
return handler;
|
|
107
|
+
}
|
|
108
|
+
const reason = check.reason ?? 'surface not enabled';
|
|
109
|
+
return (_event: E, _ctx: C): R | Promise<R> => {
|
|
110
|
+
logger?.info?.(`[PD:surface-guard] SKIP ${surfaceId}: ${reason}`);
|
|
111
|
+
return undefined as R;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function guardService<T extends OpenClawPluginService>(
|
|
116
|
+
surfaceId: string,
|
|
117
|
+
service: T,
|
|
118
|
+
logger?: { info?: (msg: string) => void; debug?: (msg: string) => void },
|
|
119
|
+
): T | null {
|
|
120
|
+
const check = isSurfaceEnabled(surfaceId);
|
|
121
|
+
if (check.enabled) {
|
|
122
|
+
return service;
|
|
123
|
+
}
|
|
124
|
+
const reason = check.reason ?? 'surface not enabled';
|
|
125
|
+
logger?.info?.(`[PD:surface-guard] SKIP service ${surfaceId}: ${reason}`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { PLUGIN_SURFACE_REGISTRY, validateSurfaceRegistry, getSurfacesByCategory };
|
|
130
|
+
export type { PluginSurfaceEntry, MvpCategory };
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { computeRuntimeShadowTaskFingerprint, PD_LOCAL_PROFILES } from './utils/
|
|
|
62
62
|
import type { WorkerProfile } from './core/model-deployment-registry.js';
|
|
63
63
|
import { validateWorkspaceDir } from './core/workspace-dir-validation.js';
|
|
64
64
|
import { resolveWorkspaceDirFromApi } from './core/path-resolver.js';
|
|
65
|
+
import { checkSurfaceGuard, guardHook, guardService } from './core/surface-guard.js';
|
|
65
66
|
|
|
66
67
|
// Track started workspaces — one-time init + evolution worker per workspace
|
|
67
68
|
const startedWorkspaces = new Set<string>();
|
|
@@ -190,12 +191,25 @@ const plugin = {
|
|
|
190
191
|
}, 1000);
|
|
191
192
|
healthCheckTimer.unref(); // Don't keep process alive for health check
|
|
192
193
|
|
|
194
|
+
// ── MVP Surface Guard (PRI-289): Verify surface classification ──
|
|
195
|
+
const surfaceGuard = checkSurfaceGuard();
|
|
196
|
+
if (!surfaceGuard.passed) {
|
|
197
|
+
for (const violation of surfaceGuard.violations) {
|
|
198
|
+
api.logger.error(`[PD:surface-guard] VIOLATION: ${violation}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
api.logger.info(`[PD:surface-guard] Core surfaces: ${surfaceGuard.enabledCoreSurfaces.join(', ')}`);
|
|
202
|
+
api.logger.info(`[PD:surface-guard] Disabled non-core surfaces: ${surfaceGuard.disabledNonCoreSurfaces.length}`);
|
|
203
|
+
for (const warning of surfaceGuard.warnings) {
|
|
204
|
+
api.logger.warn(`[PD:surface-guard] ${warning}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
193
207
|
const language = (api.pluginConfig?.language as string) || 'en';
|
|
194
208
|
|
|
195
209
|
// ── Hook: Prompt Building ──
|
|
196
210
|
api.on(
|
|
197
211
|
'before_prompt_build',
|
|
198
|
-
async (event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext): Promise<PluginHookBeforePromptBuildResult | void> => {
|
|
212
|
+
guardHook('hook:before_prompt_build', api.logger, async (event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext): Promise<PluginHookBeforePromptBuildResult | void> => {
|
|
199
213
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_prompt_build');
|
|
200
214
|
if (!workspaceDir) {
|
|
201
215
|
api.logger.error(
|
|
@@ -251,13 +265,13 @@ const plugin = {
|
|
|
251
265
|
});
|
|
252
266
|
api.logger.error(`[PD] Error in before_prompt_build: ${String(err)}`);
|
|
253
267
|
}
|
|
254
|
-
}
|
|
268
|
+
})
|
|
255
269
|
);
|
|
256
270
|
|
|
257
271
|
// ── Hook: Security Gate ──
|
|
258
272
|
api.on(
|
|
259
273
|
'before_tool_call',
|
|
260
|
-
(event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): PluginHookBeforeToolCallResult | void => {
|
|
274
|
+
guardHook('hook:before_tool_call', api.logger, (event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): PluginHookBeforeToolCallResult | void => {
|
|
261
275
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_tool_call');
|
|
262
276
|
if (!workspaceDir) {
|
|
263
277
|
api.logger.error(
|
|
@@ -286,13 +300,13 @@ const plugin = {
|
|
|
286
300
|
}, { flushImmediately: true });
|
|
287
301
|
api.logger.error(`[PD] Error in before_tool_call: ${String(err)}`);
|
|
288
302
|
}
|
|
289
|
-
}
|
|
303
|
+
})
|
|
290
304
|
);
|
|
291
305
|
|
|
292
306
|
// ── Hook: Pain & Trust ──
|
|
293
307
|
api.on(
|
|
294
308
|
'after_tool_call',
|
|
295
|
-
(event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
309
|
+
guardHook('hook:after_tool_call', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
296
310
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'after_tool_call');
|
|
297
311
|
if (!workspaceDir) {
|
|
298
312
|
api.logger.error(
|
|
@@ -319,13 +333,13 @@ const plugin = {
|
|
|
319
333
|
}, { flushImmediately: true });
|
|
320
334
|
api.logger.error(`[PD:EmpathyObserver] Error in after_tool_call: ${String(err)}`);
|
|
321
335
|
}
|
|
322
|
-
}
|
|
336
|
+
})
|
|
323
337
|
);
|
|
324
338
|
|
|
325
339
|
// ── Hook: LLM Analysis ──
|
|
326
340
|
api.on(
|
|
327
341
|
'llm_output',
|
|
328
|
-
(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
342
|
+
guardHook('hook:llm_output', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
329
343
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'llm_output');
|
|
330
344
|
if (!workspaceDir) {
|
|
331
345
|
api.logger.error(
|
|
@@ -352,14 +366,14 @@ const plugin = {
|
|
|
352
366
|
});
|
|
353
367
|
api.logger.error(`[PD] Error in llm_output: ${String(err)}`);
|
|
354
368
|
}
|
|
355
|
-
}
|
|
369
|
+
})
|
|
356
370
|
);
|
|
357
371
|
|
|
358
372
|
// ── Hook: Trajectory Collection (Behavior Evolution Phase 0) ──
|
|
359
373
|
// Note: after_tool_call and llm_output are safe to collect
|
|
360
374
|
api.on(
|
|
361
375
|
'after_tool_call',
|
|
362
|
-
(event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
376
|
+
guardHook('hook:after_tool_call.trajectory', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
363
377
|
try {
|
|
364
378
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'trajectory.after_tool_call');
|
|
365
379
|
if (!workspaceDir) return;
|
|
@@ -368,12 +382,12 @@ const plugin = {
|
|
|
368
382
|
} catch (_err) {
|
|
369
383
|
// Non-critical: don't log, just skip
|
|
370
384
|
}
|
|
371
|
-
}
|
|
385
|
+
})
|
|
372
386
|
);
|
|
373
387
|
|
|
374
388
|
api.on(
|
|
375
389
|
'llm_output',
|
|
376
|
-
(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
390
|
+
guardHook('hook:llm_output.trajectory', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
377
391
|
try {
|
|
378
392
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'trajectory.llm_output');
|
|
379
393
|
if (!workspaceDir) return;
|
|
@@ -382,14 +396,14 @@ const plugin = {
|
|
|
382
396
|
} catch (_err) {
|
|
383
397
|
// Non-critical: don't log, just skip
|
|
384
398
|
}
|
|
385
|
-
}
|
|
399
|
+
})
|
|
386
400
|
);
|
|
387
401
|
|
|
388
402
|
// ── Hook: Subagent Loop Closure ──
|
|
389
403
|
api.on(
|
|
390
404
|
'subagent_spawning',
|
|
391
405
|
|
|
392
|
-
(event: PluginHookSubagentSpawningEvent, _ctx: PluginHookSubagentContext): void | PluginHookSubagentSpawningResult => {
|
|
406
|
+
guardHook('hook:subagent_spawning', api.logger, (event: PluginHookSubagentSpawningEvent, _ctx: PluginHookSubagentContext): void | PluginHookSubagentSpawningResult => {
|
|
393
407
|
try {
|
|
394
408
|
// FIX (B): Never fall back to '.' — fail-fast with ERROR log if workspaceDir cannot be resolved.
|
|
395
409
|
// For subagent hooks, we use event.agentId as the target agent for workspace resolution.
|
|
@@ -428,12 +442,12 @@ const plugin = {
|
|
|
428
442
|
api.logger.error(`[PD] Error in subagent_spawning shadow routing: ${String(err)}`);
|
|
429
443
|
return { status: 'ok' }; // Don't block spawn on shadow observation errors
|
|
430
444
|
}
|
|
431
|
-
}
|
|
445
|
+
})
|
|
432
446
|
);
|
|
433
447
|
|
|
434
448
|
api.on(
|
|
435
449
|
'subagent_ended',
|
|
436
|
-
(event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void => {
|
|
450
|
+
guardHook('hook:subagent_ended', api.logger, (event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void => {
|
|
437
451
|
try {
|
|
438
452
|
// FIX (B): Never fall back to '.' — fail-fast with ERROR log if workspaceDir cannot be resolved.
|
|
439
453
|
const workspaceDir = resolveWorkspaceDirFromApi(api, undefined);
|
|
@@ -465,11 +479,11 @@ const plugin = {
|
|
|
465
479
|
} catch (err) {
|
|
466
480
|
api.logger.error(`[PD] Error in subagent_ended: ${String(err)}`);
|
|
467
481
|
}
|
|
468
|
-
}
|
|
482
|
+
})
|
|
469
483
|
);
|
|
470
484
|
|
|
471
485
|
// ── Hook: Lifecycle ──
|
|
472
|
-
api.on('before_reset', (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
|
|
486
|
+
api.on('before_reset', guardHook('hook:before_reset', api.logger, (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
|
|
473
487
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_reset');
|
|
474
488
|
if (!workspaceDir) {
|
|
475
489
|
api.logger.error(
|
|
@@ -480,9 +494,9 @@ const plugin = {
|
|
|
480
494
|
return;
|
|
481
495
|
}
|
|
482
496
|
return handleBeforeReset(event, { ...ctx, workspaceDir });
|
|
483
|
-
});
|
|
497
|
+
}));
|
|
484
498
|
|
|
485
|
-
api.on('before_compaction', (event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
499
|
+
api.on('before_compaction', guardHook('hook:before_compaction', api.logger, (event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
486
500
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_compaction');
|
|
487
501
|
if (!workspaceDir) {
|
|
488
502
|
api.logger.error(
|
|
@@ -493,9 +507,9 @@ const plugin = {
|
|
|
493
507
|
return;
|
|
494
508
|
}
|
|
495
509
|
return handleBeforeCompaction(event, { ...ctx, workspaceDir });
|
|
496
|
-
});
|
|
510
|
+
}));
|
|
497
511
|
|
|
498
|
-
api.on('after_compaction', (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
512
|
+
api.on('after_compaction', guardHook('hook:after_compaction', api.logger, (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
499
513
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'after_compaction');
|
|
500
514
|
if (!workspaceDir) {
|
|
501
515
|
api.logger.error(
|
|
@@ -506,17 +520,21 @@ const plugin = {
|
|
|
506
520
|
return;
|
|
507
521
|
}
|
|
508
522
|
return handleAfterCompaction(event, { ...ctx, workspaceDir });
|
|
509
|
-
});
|
|
523
|
+
}));
|
|
510
524
|
|
|
511
525
|
// ── Service: Background Evolution Worker ──
|
|
512
526
|
try {
|
|
513
527
|
EvolutionWorkerService.api = api;
|
|
514
|
-
api.
|
|
515
|
-
api.registerService(
|
|
516
|
-
api.
|
|
517
|
-
api.registerService(
|
|
528
|
+
const guardedEvolutionWorker = guardService('service:evolution-worker', EvolutionWorkerService, api.logger);
|
|
529
|
+
if (guardedEvolutionWorker) api.registerService(guardedEvolutionWorker);
|
|
530
|
+
const guardedTrajectory = guardService('service:trajectory', TrajectoryService, api.logger);
|
|
531
|
+
if (guardedTrajectory) api.registerService(guardedTrajectory);
|
|
532
|
+
const guardedPdTask = guardService('service:pd-task', PDTaskService, api.logger);
|
|
533
|
+
if (guardedPdTask) api.registerService(guardedPdTask);
|
|
534
|
+
const guardedCentralSync = guardService('service:central-sync', CentralSyncService, api.logger);
|
|
535
|
+
if (guardedCentralSync) api.registerService(guardedCentralSync);
|
|
518
536
|
} catch (err) {
|
|
519
|
-
api.logger.error(`[PD] Failed to register
|
|
537
|
+
api.logger.error(`[PD] Failed to register services: ${String(err)}`);
|
|
520
538
|
}
|
|
521
539
|
|
|
522
540
|
// ── Slash Commands ──
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
PLUGIN_SURFACE_REGISTRY,
|
|
6
|
+
validateSurfaceRegistry,
|
|
7
|
+
findUnclassifiedSurfaces,
|
|
8
|
+
getSurfacesByCategory,
|
|
9
|
+
getSurfacesByKind,
|
|
10
|
+
} from '@principles/core/runtime-v2';
|
|
11
|
+
|
|
12
|
+
function findRepoRoot(cwd: string): string {
|
|
13
|
+
let dir = cwd;
|
|
14
|
+
while (dir !== path.dirname(dir)) {
|
|
15
|
+
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
dir = path.dirname(dir);
|
|
19
|
+
}
|
|
20
|
+
return cwd;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
24
|
+
|
|
25
|
+
function read(relativePath: string): string {
|
|
26
|
+
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ApiOnRegistration {
|
|
30
|
+
event: string;
|
|
31
|
+
surfaceId: string | null;
|
|
32
|
+
rawLine: string;
|
|
33
|
+
index: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractApiOnRegistrations(source: string): ApiOnRegistration[] {
|
|
37
|
+
const registrations: ApiOnRegistration[] = [];
|
|
38
|
+
const apiOnPattern = /api\.on\s*\(\s*['"]([^'"]+)['"]\s*,\s*/g;
|
|
39
|
+
let match: RegExpExecArray | null;
|
|
40
|
+
let regIndex = 0;
|
|
41
|
+
while ((match = apiOnPattern.exec(source)) !== null) {
|
|
42
|
+
const event = match[1];
|
|
43
|
+
const afterMatch = source.slice(match.index + match[0].length);
|
|
44
|
+
const guardHookMatch = afterMatch.match(/^guardHook\s*\(\s*['"]([^'"]+)['"]\s*,/);
|
|
45
|
+
registrations.push({
|
|
46
|
+
event,
|
|
47
|
+
surfaceId: guardHookMatch ? guardHookMatch[1] : null,
|
|
48
|
+
rawLine: match[0],
|
|
49
|
+
index: regIndex++,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return registrations;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ServiceRegistration {
|
|
56
|
+
surfaceId: string;
|
|
57
|
+
index: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractServiceRegistrations(source: string): ServiceRegistration[] {
|
|
61
|
+
const registrations: ServiceRegistration[] = [];
|
|
62
|
+
const servicePattern = /guardService\s*\(\s*['"]([^'"]+)['"]\s*,/g;
|
|
63
|
+
let match: RegExpExecArray | null;
|
|
64
|
+
let serviceIndex = 0;
|
|
65
|
+
while ((match = servicePattern.exec(source)) !== null) {
|
|
66
|
+
registrations.push({
|
|
67
|
+
surfaceId: match[1],
|
|
68
|
+
index: serviceIndex++,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return registrations;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('MVP Surface Registry Guard (PRI-289)', () => {
|
|
75
|
+
describe('registry self-validation', () => {
|
|
76
|
+
it('PLUGIN_SURFACE_REGISTRY passes validateSurfaceRegistry', () => {
|
|
77
|
+
const result = validateSurfaceRegistry(PLUGIN_SURFACE_REGISTRY);
|
|
78
|
+
expect(result.valid).toBe(true);
|
|
79
|
+
expect(result.errors).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('has no duplicate surface ids', () => {
|
|
83
|
+
const ids = PLUGIN_SURFACE_REGISTRY.map(s => s.id);
|
|
84
|
+
const uniqueIds = new Set(ids);
|
|
85
|
+
expect(uniqueIds.size).toBe(ids.length);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('core surfaces are all enabledByDefault', () => {
|
|
89
|
+
const coreSurfaces = getSurfacesByCategory(PLUGIN_SURFACE_REGISTRY, 'core');
|
|
90
|
+
for (const surface of coreSurfaces) {
|
|
91
|
+
expect(surface.enabledByDefault).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('non-core surfaces are not enabledByDefault', () => {
|
|
96
|
+
const nonCore = PLUGIN_SURFACE_REGISTRY.filter(s => s.category !== 'core');
|
|
97
|
+
for (const surface of nonCore) {
|
|
98
|
+
expect(surface.enabledByDefault).toBe(false);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('quiet/gone/legacy_retire surfaces have disabledReason', () => {
|
|
103
|
+
const disabled = PLUGIN_SURFACE_REGISTRY.filter(
|
|
104
|
+
s => s.category === 'quiet' || s.category === 'gone' || s.category === 'legacy_retire',
|
|
105
|
+
);
|
|
106
|
+
for (const surface of disabled) {
|
|
107
|
+
expect(surface.disabledReason).toBeDefined();
|
|
108
|
+
expect(typeof surface.disabledReason).toBe('string');
|
|
109
|
+
expect(surface.disabledReason!.length).toBeGreaterThan(0);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('api.on() registration coverage — every hook must be guarded', () => {
|
|
115
|
+
const source = read('packages/openclaw-plugin/src/index.ts');
|
|
116
|
+
const registrations = extractApiOnRegistrations(source);
|
|
117
|
+
|
|
118
|
+
it('has at least one api.on registration', () => {
|
|
119
|
+
expect(registrations.length).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('every api.on() handler is wrapped with guardHook()', () => {
|
|
123
|
+
const unguarded = registrations.filter(r => r.surfaceId === null);
|
|
124
|
+
if (unguarded.length > 0) {
|
|
125
|
+
const details = unguarded.map(r => `api.on('${r.event}', ...) #${r.index} — NOT wrapped with guardHook`);
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Found ${unguarded.length} unguarded api.on() registration(s):\n${details.join('\n')}\n` +
|
|
128
|
+
`Every api.on() handler MUST be wrapped with guardHook('<surfaceId>', api.logger, ...) per PRI-289.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('every guardHook surface id exists in PLUGIN_SURFACE_REGISTRY', () => {
|
|
134
|
+
const guarded = registrations.filter(r => r.surfaceId !== null);
|
|
135
|
+
const registeredIds = new Set(PLUGIN_SURFACE_REGISTRY.map(s => s.id));
|
|
136
|
+
const unclassified: string[] = [];
|
|
137
|
+
for (const reg of guarded) {
|
|
138
|
+
if (!registeredIds.has(reg.surfaceId!)) {
|
|
139
|
+
unclassified.push(reg.surfaceId!);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
expect(unclassified).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('each individual api.on registration is covered (no dedup by event name)', () => {
|
|
146
|
+
const guarded = registrations.filter(r => r.surfaceId !== null);
|
|
147
|
+
const registeredIds = new Set(PLUGIN_SURFACE_REGISTRY.map(s => s.id));
|
|
148
|
+
for (const reg of guarded) {
|
|
149
|
+
expect(registeredIds.has(reg.surfaceId!)).toBe(true);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('after_tool_call has two registrations: core + trajectory', () => {
|
|
154
|
+
const afterToolCallRegs = registrations.filter(r => r.event === 'after_tool_call');
|
|
155
|
+
expect(afterToolCallRegs.length).toBe(2);
|
|
156
|
+
expect(afterToolCallRegs[0].surfaceId).toBe('hook:after_tool_call');
|
|
157
|
+
expect(afterToolCallRegs[1].surfaceId).toBe('hook:after_tool_call.trajectory');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('llm_output has two registrations: core + trajectory', () => {
|
|
161
|
+
const llmOutputRegs = registrations.filter(r => r.event === 'llm_output');
|
|
162
|
+
expect(llmOutputRegs.length).toBe(2);
|
|
163
|
+
expect(llmOutputRegs[0].surfaceId).toBe('hook:llm_output');
|
|
164
|
+
expect(llmOutputRegs[1].surfaceId).toBe('hook:llm_output.trajectory');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('total api.on registrations with guardHook match registry hook count', () => {
|
|
168
|
+
const guarded = registrations.filter(r => r.surfaceId !== null);
|
|
169
|
+
const registryHookCount = PLUGIN_SURFACE_REGISTRY.filter(s => s.kind === 'hook').length;
|
|
170
|
+
expect(guarded.length).toBe(registryHookCount);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('all guardHook calls pass api.logger as second argument', () => {
|
|
174
|
+
const guardHookWithLogger = /guardHook\s*\(\s*['"][^'"]+['"]\s*,\s*api\.logger\s*,/g;
|
|
175
|
+
const guardHookTotal = /guardHook\s*\(\s*['"][^'"]+['"]\s*,/g;
|
|
176
|
+
const withLoggerCount = (source.match(guardHookWithLogger) ?? []).length;
|
|
177
|
+
const totalCount = (source.match(guardHookTotal) ?? []).length;
|
|
178
|
+
expect(withLoggerCount).toBe(totalCount);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('service registration coverage — per-registration', () => {
|
|
183
|
+
it('every guardService() call in index.ts has a classified surface id', () => {
|
|
184
|
+
const source = read('packages/openclaw-plugin/src/index.ts');
|
|
185
|
+
const registrations = extractServiceRegistrations(source);
|
|
186
|
+
|
|
187
|
+
expect(registrations.length).toBeGreaterThan(0);
|
|
188
|
+
|
|
189
|
+
const registeredIds = new Set(
|
|
190
|
+
PLUGIN_SURFACE_REGISTRY.filter(s => s.kind === 'service').map(s => s.id),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const unclassified: string[] = [];
|
|
194
|
+
for (const reg of registrations) {
|
|
195
|
+
if (!registeredIds.has(reg.surfaceId)) {
|
|
196
|
+
unclassified.push(reg.surfaceId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
expect(unclassified).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('total service registrations match expected count', () => {
|
|
204
|
+
const source = read('packages/openclaw-plugin/src/index.ts');
|
|
205
|
+
const registrations = extractServiceRegistrations(source);
|
|
206
|
+
|
|
207
|
+
const registryServiceCount = PLUGIN_SURFACE_REGISTRY.filter(s => s.kind === 'service').length;
|
|
208
|
+
expect(registrations.length).toBe(registryServiceCount);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('ADR-0014 compliance', () => {
|
|
213
|
+
it('only MVP-Core surfaces are enabledByDefault', () => {
|
|
214
|
+
const enabledByDefault = PLUGIN_SURFACE_REGISTRY.filter(s => s.enabledByDefault);
|
|
215
|
+
for (const surface of enabledByDefault) {
|
|
216
|
+
expect(surface.category).toBe('core');
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('core hooks match ADR-0014 MVP-Core activation paths', () => {
|
|
221
|
+
const coreHooks = getSurfacesByKind(PLUGIN_SURFACE_REGISTRY, 'hook')
|
|
222
|
+
.filter(s => s.category === 'core')
|
|
223
|
+
.map(s => s.id);
|
|
224
|
+
|
|
225
|
+
expect(coreHooks).toContain('hook:before_prompt_build');
|
|
226
|
+
expect(coreHooks).toContain('hook:before_tool_call');
|
|
227
|
+
expect(coreHooks).toContain('hook:after_tool_call');
|
|
228
|
+
expect(coreHooks).toContain('hook:llm_output');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('evolution-worker service is MVP-Quiet (PRI-288/ADR-0014 alignment)', () => {
|
|
232
|
+
const ew = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'service:evolution-worker');
|
|
233
|
+
expect(ew).toBeDefined();
|
|
234
|
+
expect(ew!.category).toBe('quiet');
|
|
235
|
+
expect(ew!.enabledByDefault).toBe(false);
|
|
236
|
+
expect(ew!.disabledReason).toContain('PRI-288');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('evolution-worker startup is MVP-Quiet (PRI-288/ADR-0014 alignment)', () => {
|
|
240
|
+
const ew = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'startup:evolution-worker');
|
|
241
|
+
expect(ew).toBeDefined();
|
|
242
|
+
expect(ew!.category).toBe('quiet');
|
|
243
|
+
expect(ew!.enabledByDefault).toBe(false);
|
|
244
|
+
expect(ew!.disabledReason).toContain('PRI-288');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('trajectory hooks are MVP-Quiet (ADR-0014 §2.5)', () => {
|
|
248
|
+
const trajectoryHooks = PLUGIN_SURFACE_REGISTRY.filter(
|
|
249
|
+
s => s.kind === 'hook' && s.id.includes('trajectory'),
|
|
250
|
+
);
|
|
251
|
+
for (const hook of trajectoryHooks) {
|
|
252
|
+
expect(hook.category).toBe('quiet');
|
|
253
|
+
expect(hook.enabledByDefault).toBe(false);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('subagent/shadow hooks are MVP-Quiet (ADR-0014 §2.5)', () => {
|
|
258
|
+
const shadowHooks = PLUGIN_SURFACE_REGISTRY.filter(
|
|
259
|
+
s => s.kind === 'hook' && (s.id.includes('subagent') || s.id.includes('shadow')),
|
|
260
|
+
);
|
|
261
|
+
for (const hook of shadowHooks) {
|
|
262
|
+
expect(hook.category).toBe('quiet');
|
|
263
|
+
expect(hook.enabledByDefault).toBe(false);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('lifecycle hooks are MVP-Quiet (ADR-0014 §2.5)', () => {
|
|
268
|
+
const lifecycleHooks = PLUGIN_SURFACE_REGISTRY.filter(
|
|
269
|
+
s => s.kind === 'hook' && (s.id.includes('reset') || s.id.includes('compaction')),
|
|
270
|
+
);
|
|
271
|
+
for (const hook of lifecycleHooks) {
|
|
272
|
+
expect(hook.category).toBe('quiet');
|
|
273
|
+
expect(hook.enabledByDefault).toBe(false);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('central-sync service is MVP-Quiet (ADR-0014 §2.5: single workspace)', () => {
|
|
278
|
+
const centralSync = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'service:central-sync');
|
|
279
|
+
expect(centralSync).toBeDefined();
|
|
280
|
+
expect(centralSync!.category).toBe('quiet');
|
|
281
|
+
expect(centralSync!.enabledByDefault).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('surface guard runtime', () => {
|
|
286
|
+
it('checkSurfaceGuard passes with current registry', async () => {
|
|
287
|
+
const { checkSurfaceGuard } = await import('../../src/core/surface-guard.js');
|
|
288
|
+
const result = checkSurfaceGuard();
|
|
289
|
+
expect(result.passed).toBe(true);
|
|
290
|
+
expect(result.violations).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('isSurfaceEnabled returns false for unknown surface with reason', async () => {
|
|
294
|
+
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
295
|
+
const result = isSurfaceEnabled('hook:unknown_new_hook');
|
|
296
|
+
expect(result.enabled).toBe(false);
|
|
297
|
+
expect(result.reason).toContain('not found in registry');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('isSurfaceEnabled returns true for core surfaces', async () => {
|
|
301
|
+
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
302
|
+
const result = isSurfaceEnabled('hook:before_prompt_build');
|
|
303
|
+
expect(result.enabled).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('isSurfaceEnabled returns false for unknown surfaces even with override', async () => {
|
|
307
|
+
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
308
|
+
const result = isSurfaceEnabled('hook:nonexistent_gone', { 'hook:nonexistent_gone': true });
|
|
309
|
+
expect(result.enabled).toBe(false);
|
|
310
|
+
expect(result.reason).toContain('not found in registry');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('isSurfaceEnabled returns true for core surfaces even with false override', async () => {
|
|
314
|
+
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
315
|
+
const result = isSurfaceEnabled('hook:before_prompt_build', { 'hook:before_prompt_build': false });
|
|
316
|
+
expect(result.enabled).toBe(true);
|
|
317
|
+
expect(result.reason).toContain('core');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('isSurfaceEnabled returns false for quiet surfaces by default', async () => {
|
|
321
|
+
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
322
|
+
const result = isSurfaceEnabled('hook:after_tool_call.trajectory');
|
|
323
|
+
expect(result.enabled).toBe(false);
|
|
324
|
+
expect(result.reason).toBeDefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('isSurfaceEnabled allows quiet surfaces with explicit override', async () => {
|
|
328
|
+
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
329
|
+
const result = isSurfaceEnabled('hook:after_tool_call.trajectory', { 'hook:after_tool_call.trajectory': true });
|
|
330
|
+
expect(result.enabled).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('guardHook returns original handler for core surfaces', async () => {
|
|
334
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
335
|
+
const handler = () => 'result';
|
|
336
|
+
const guarded = guardHook('hook:before_prompt_build', undefined, handler);
|
|
337
|
+
expect(guarded).toBe(handler);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('guardHook returns no-op handler for quiet surfaces', async () => {
|
|
341
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
342
|
+
const handler = () => 'result';
|
|
343
|
+
const guarded = guardHook('hook:after_tool_call.trajectory', undefined, handler);
|
|
344
|
+
expect(guarded).not.toBe(handler);
|
|
345
|
+
expect(guarded({} as never, {} as never)).toBeUndefined();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('guardHook returns no-op handler for unregistered surfaces', async () => {
|
|
349
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
350
|
+
const handler = () => 'result';
|
|
351
|
+
const guarded = guardHook('hook:nonexistent_hook', undefined, handler);
|
|
352
|
+
expect(guarded).not.toBe(handler);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('guardHook logs disabled reason via logger for quiet surfaces', async () => {
|
|
356
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
357
|
+
const logs: string[] = [];
|
|
358
|
+
const logger = { info: (msg: string) => { logs.push(msg); } };
|
|
359
|
+
const handler = () => 'result';
|
|
360
|
+
const guarded = guardHook('hook:after_tool_call.trajectory', logger, handler);
|
|
361
|
+
guarded({} as never, {} as never);
|
|
362
|
+
expect(logs.length).toBe(1);
|
|
363
|
+
expect(logs[0]).toContain('[PD:surface-guard] SKIP');
|
|
364
|
+
expect(logs[0]).toContain('hook:after_tool_call.trajectory');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('guardHook does not log for enabled surfaces', async () => {
|
|
368
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
369
|
+
const logs: string[] = [];
|
|
370
|
+
const logger = { info: (msg: string) => { logs.push(msg); } };
|
|
371
|
+
const handler = () => 'result';
|
|
372
|
+
const guarded = guardHook('hook:before_prompt_build', logger, handler);
|
|
373
|
+
guarded({} as never, {} as never);
|
|
374
|
+
expect(logs.length).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('guardService returns null for evolution-worker (quiet, default off)', async () => {
|
|
378
|
+
const { guardService } = await import('../../src/core/surface-guard.js');
|
|
379
|
+
const service = { api: null, start: () => {} };
|
|
380
|
+
const guarded = guardService('service:evolution-worker', service);
|
|
381
|
+
expect(guarded).toBeNull();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('guardService returns null for quiet surfaces', async () => {
|
|
385
|
+
const { guardService } = await import('../../src/core/surface-guard.js');
|
|
386
|
+
const service = { api: null, start: () => {} };
|
|
387
|
+
const guarded = guardService('service:trajectory', service);
|
|
388
|
+
expect(guarded).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('guardService returns null for unregistered surfaces', async () => {
|
|
392
|
+
const { guardService } = await import('../../src/core/surface-guard.js');
|
|
393
|
+
const service = { api: null, start: () => {} };
|
|
394
|
+
const guarded = guardService('service:nonexistent_service', service);
|
|
395
|
+
expect(guarded).toBeNull();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|