principles-disciple 1.75.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 +1 -1
- package/package.json +1 -1
- package/src/core/surface-guard.ts +130 -0
- package/src/index.ts +159 -37
- package/tests/core-anti-growth.test.ts +1 -0
- package/tests/evolution-worker-quarantine.test.ts +342 -0
- package/tests/integration/mvp-surface-registry-guard.test.ts +398 -0
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
|
@@ -18,6 +18,9 @@ import type {
|
|
|
18
18
|
PluginHookSubagentContext,
|
|
19
19
|
} from './openclaw-sdk.js';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as yaml from 'js-yaml';
|
|
23
|
+
import { computeEffectiveFlags, DEFAULT_FEATURE_FLAGS } from '@principles/core/runtime-v2';
|
|
21
24
|
import { classifyTask } from './core/local-worker-routing.js';
|
|
22
25
|
import { completeShadowObservation, recordShadowRouting } from './core/shadow-observation-registry.js';
|
|
23
26
|
import { getCommandDescription } from './i18n/commands.js';
|
|
@@ -59,6 +62,7 @@ import { computeRuntimeShadowTaskFingerprint, PD_LOCAL_PROFILES } from './utils/
|
|
|
59
62
|
import type { WorkerProfile } from './core/model-deployment-registry.js';
|
|
60
63
|
import { validateWorkspaceDir } from './core/workspace-dir-validation.js';
|
|
61
64
|
import { resolveWorkspaceDirFromApi } from './core/path-resolver.js';
|
|
65
|
+
import { checkSurfaceGuard, guardHook, guardService } from './core/surface-guard.js';
|
|
62
66
|
|
|
63
67
|
// Track started workspaces — one-time init + evolution worker per workspace
|
|
64
68
|
const startedWorkspaces = new Set<string>();
|
|
@@ -71,6 +75,99 @@ const HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION =
|
|
|
71
75
|
// Used to complete shadow observations when subagent ends
|
|
72
76
|
const pendingShadowObservations = new Map<string, string>();
|
|
73
77
|
|
|
78
|
+
// ── Feature Flag Loader (plugin I/O boundary) ─────────────────────────────
|
|
79
|
+
// Reads workspace feature-flags.yaml and checks a specific flag.
|
|
80
|
+
// Returns the flag definition with effective enabled state.
|
|
81
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
82
|
+
|
|
83
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadFeatureFlagFromWorkspace(
|
|
88
|
+
workspaceDir: string,
|
|
89
|
+
flagId: string,
|
|
90
|
+
logger?: { warn?: (msg: string) => void; info?: (msg: string) => void },
|
|
91
|
+
): { enabled: boolean; source: string } {
|
|
92
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
93
|
+
|
|
94
|
+
if (!fs.existsSync(configPath)) {
|
|
95
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
96
|
+
const flag = flags.flags[flagId];
|
|
97
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
105
|
+
logger?.warn?.(`[PD:FeatureFlags] Feature flags unreadable: ${msg} — using defaults`);
|
|
106
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
107
|
+
const flag = flags.flags[flagId];
|
|
108
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
try {
|
|
113
|
+
parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const parseMsg = e instanceof Error ? e.message : String(e);
|
|
116
|
+
logger?.warn?.(`[PD:FeatureFlags] Feature flags YAML parse error: ${parseMsg} — using defaults`);
|
|
117
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
118
|
+
const flag = flags.flags[flagId];
|
|
119
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!isRecord(parsed)) {
|
|
123
|
+
logger?.warn?.(`[PD:FeatureFlags] Feature flags not a mapping — using defaults`);
|
|
124
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
125
|
+
const flag = flags.flags[flagId];
|
|
126
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// parsed is now narrowed to Record<string, unknown> by isRecord guard
|
|
130
|
+
const parsedRecord: Record<string, unknown> = Object.create(null);
|
|
131
|
+
for (const key of Object.keys(parsed)) {
|
|
132
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
133
|
+
if (Object.hasOwn(parsed, key)) {
|
|
134
|
+
parsedRecord[key] = parsed[key];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const flags = computeEffectiveFlags(parsedRecord, DEFAULT_FEATURE_FLAGS, configPath);
|
|
139
|
+
const flag = flags.flags[flagId];
|
|
140
|
+
return { enabled: flag?.enabled ?? false, source: flags.source };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Evolution Worker Startup Gate (shared between index.ts and tests) ───────
|
|
144
|
+
// Determines whether the legacy evolution worker should start and produces
|
|
145
|
+
// structured observability when disabled (ERR-002).
|
|
146
|
+
|
|
147
|
+
export interface EvolutionWorkerGateResult {
|
|
148
|
+
shouldStart: boolean;
|
|
149
|
+
flagSource: string;
|
|
150
|
+
disabledInfo: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function shouldStartEvolutionWorker(
|
|
154
|
+
workspaceDir: string,
|
|
155
|
+
logger: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
|
156
|
+
): EvolutionWorkerGateResult {
|
|
157
|
+
const flag = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
158
|
+
if (flag.enabled) {
|
|
159
|
+
return { shouldStart: true, flagSource: flag.source, disabledInfo: null };
|
|
160
|
+
}
|
|
161
|
+
const disabledInfo = JSON.stringify({
|
|
162
|
+
reason: 'mvp_quiet_per_adr0014',
|
|
163
|
+
nextAction: 'set evolution_worker.enabled=true in .pd/feature-flags.yaml to enable',
|
|
164
|
+
featureFlag: 'evolution_worker',
|
|
165
|
+
boundedContext: 'legacy_evolution_worker',
|
|
166
|
+
flagSource: flag.source,
|
|
167
|
+
});
|
|
168
|
+
return { shouldStart: false, flagSource: flag.source, disabledInfo };
|
|
169
|
+
}
|
|
170
|
+
|
|
74
171
|
const plugin = {
|
|
75
172
|
name: "Principles Disciple",
|
|
76
173
|
description: "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
@@ -94,12 +191,25 @@ const plugin = {
|
|
|
94
191
|
}, 1000);
|
|
95
192
|
healthCheckTimer.unref(); // Don't keep process alive for health check
|
|
96
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
|
+
|
|
97
207
|
const language = (api.pluginConfig?.language as string) || 'en';
|
|
98
208
|
|
|
99
209
|
// ── Hook: Prompt Building ──
|
|
100
210
|
api.on(
|
|
101
211
|
'before_prompt_build',
|
|
102
|
-
async (event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext): Promise<PluginHookBeforePromptBuildResult | void> => {
|
|
212
|
+
guardHook('hook:before_prompt_build', api.logger, async (event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext): Promise<PluginHookBeforePromptBuildResult | void> => {
|
|
103
213
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_prompt_build');
|
|
104
214
|
if (!workspaceDir) {
|
|
105
215
|
api.logger.error(
|
|
@@ -120,16 +230,22 @@ const plugin = {
|
|
|
120
230
|
SystemLogger.log(workspaceDir, 'SYSTEM_BOOT', `Principles Disciple online. Language: ${language}`);
|
|
121
231
|
|
|
122
232
|
// ── Start EvolutionWorker for THIS workspace ──
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
233
|
+
// Gated behind evolution_worker feature flag (MVP-Quiet, default OFF per ADR-0014).
|
|
234
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, api.logger);
|
|
235
|
+
if (gate.shouldStart) {
|
|
236
|
+
EvolutionWorkerService.api = api;
|
|
237
|
+
EvolutionWorkerService.start({
|
|
238
|
+
config: api.config,
|
|
239
|
+
workspaceDir,
|
|
240
|
+
stateDir: path.join(workspaceDir, '.state'),
|
|
241
|
+
logger: api.logger,
|
|
242
|
+
});
|
|
243
|
+
api.logger.info(`[PD] EvolutionWorker started for workspace: ${workspaceDir} (flag source: ${gate.flagSource})`);
|
|
244
|
+
} else {
|
|
245
|
+
// Structured observability per ERR-002: no silent skip
|
|
246
|
+
api.logger.info(`[PD] EvolutionWorker NOT started for workspace: ${workspaceDir}. ${gate.disabledInfo}`);
|
|
247
|
+
SystemLogger.log(workspaceDir, 'EVOLUTION_WORKER_DISABLED', gate.disabledInfo ?? '');
|
|
248
|
+
}
|
|
133
249
|
}
|
|
134
250
|
|
|
135
251
|
const result = await handleBeforePromptBuild(event, { ...ctx, api: api as Parameters<typeof handleBeforePromptBuild>[1]['api'], workspaceDir });
|
|
@@ -149,13 +265,13 @@ const plugin = {
|
|
|
149
265
|
});
|
|
150
266
|
api.logger.error(`[PD] Error in before_prompt_build: ${String(err)}`);
|
|
151
267
|
}
|
|
152
|
-
}
|
|
268
|
+
})
|
|
153
269
|
);
|
|
154
270
|
|
|
155
271
|
// ── Hook: Security Gate ──
|
|
156
272
|
api.on(
|
|
157
273
|
'before_tool_call',
|
|
158
|
-
(event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): PluginHookBeforeToolCallResult | void => {
|
|
274
|
+
guardHook('hook:before_tool_call', api.logger, (event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): PluginHookBeforeToolCallResult | void => {
|
|
159
275
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_tool_call');
|
|
160
276
|
if (!workspaceDir) {
|
|
161
277
|
api.logger.error(
|
|
@@ -184,13 +300,13 @@ const plugin = {
|
|
|
184
300
|
}, { flushImmediately: true });
|
|
185
301
|
api.logger.error(`[PD] Error in before_tool_call: ${String(err)}`);
|
|
186
302
|
}
|
|
187
|
-
}
|
|
303
|
+
})
|
|
188
304
|
);
|
|
189
305
|
|
|
190
306
|
// ── Hook: Pain & Trust ──
|
|
191
307
|
api.on(
|
|
192
308
|
'after_tool_call',
|
|
193
|
-
(event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
309
|
+
guardHook('hook:after_tool_call', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
194
310
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'after_tool_call');
|
|
195
311
|
if (!workspaceDir) {
|
|
196
312
|
api.logger.error(
|
|
@@ -217,13 +333,13 @@ const plugin = {
|
|
|
217
333
|
}, { flushImmediately: true });
|
|
218
334
|
api.logger.error(`[PD:EmpathyObserver] Error in after_tool_call: ${String(err)}`);
|
|
219
335
|
}
|
|
220
|
-
}
|
|
336
|
+
})
|
|
221
337
|
);
|
|
222
338
|
|
|
223
339
|
// ── Hook: LLM Analysis ──
|
|
224
340
|
api.on(
|
|
225
341
|
'llm_output',
|
|
226
|
-
(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
342
|
+
guardHook('hook:llm_output', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
227
343
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'llm_output');
|
|
228
344
|
if (!workspaceDir) {
|
|
229
345
|
api.logger.error(
|
|
@@ -250,14 +366,14 @@ const plugin = {
|
|
|
250
366
|
});
|
|
251
367
|
api.logger.error(`[PD] Error in llm_output: ${String(err)}`);
|
|
252
368
|
}
|
|
253
|
-
}
|
|
369
|
+
})
|
|
254
370
|
);
|
|
255
371
|
|
|
256
372
|
// ── Hook: Trajectory Collection (Behavior Evolution Phase 0) ──
|
|
257
373
|
// Note: after_tool_call and llm_output are safe to collect
|
|
258
374
|
api.on(
|
|
259
375
|
'after_tool_call',
|
|
260
|
-
(event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
376
|
+
guardHook('hook:after_tool_call.trajectory', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
261
377
|
try {
|
|
262
378
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'trajectory.after_tool_call');
|
|
263
379
|
if (!workspaceDir) return;
|
|
@@ -266,12 +382,12 @@ const plugin = {
|
|
|
266
382
|
} catch (_err) {
|
|
267
383
|
// Non-critical: don't log, just skip
|
|
268
384
|
}
|
|
269
|
-
}
|
|
385
|
+
})
|
|
270
386
|
);
|
|
271
387
|
|
|
272
388
|
api.on(
|
|
273
389
|
'llm_output',
|
|
274
|
-
(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
390
|
+
guardHook('hook:llm_output.trajectory', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
275
391
|
try {
|
|
276
392
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'trajectory.llm_output');
|
|
277
393
|
if (!workspaceDir) return;
|
|
@@ -280,14 +396,14 @@ const plugin = {
|
|
|
280
396
|
} catch (_err) {
|
|
281
397
|
// Non-critical: don't log, just skip
|
|
282
398
|
}
|
|
283
|
-
}
|
|
399
|
+
})
|
|
284
400
|
);
|
|
285
401
|
|
|
286
402
|
// ── Hook: Subagent Loop Closure ──
|
|
287
403
|
api.on(
|
|
288
404
|
'subagent_spawning',
|
|
289
405
|
|
|
290
|
-
(event: PluginHookSubagentSpawningEvent, _ctx: PluginHookSubagentContext): void | PluginHookSubagentSpawningResult => {
|
|
406
|
+
guardHook('hook:subagent_spawning', api.logger, (event: PluginHookSubagentSpawningEvent, _ctx: PluginHookSubagentContext): void | PluginHookSubagentSpawningResult => {
|
|
291
407
|
try {
|
|
292
408
|
// FIX (B): Never fall back to '.' — fail-fast with ERROR log if workspaceDir cannot be resolved.
|
|
293
409
|
// For subagent hooks, we use event.agentId as the target agent for workspace resolution.
|
|
@@ -326,12 +442,12 @@ const plugin = {
|
|
|
326
442
|
api.logger.error(`[PD] Error in subagent_spawning shadow routing: ${String(err)}`);
|
|
327
443
|
return { status: 'ok' }; // Don't block spawn on shadow observation errors
|
|
328
444
|
}
|
|
329
|
-
}
|
|
445
|
+
})
|
|
330
446
|
);
|
|
331
447
|
|
|
332
448
|
api.on(
|
|
333
449
|
'subagent_ended',
|
|
334
|
-
(event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void => {
|
|
450
|
+
guardHook('hook:subagent_ended', api.logger, (event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void => {
|
|
335
451
|
try {
|
|
336
452
|
// FIX (B): Never fall back to '.' — fail-fast with ERROR log if workspaceDir cannot be resolved.
|
|
337
453
|
const workspaceDir = resolveWorkspaceDirFromApi(api, undefined);
|
|
@@ -363,11 +479,11 @@ const plugin = {
|
|
|
363
479
|
} catch (err) {
|
|
364
480
|
api.logger.error(`[PD] Error in subagent_ended: ${String(err)}`);
|
|
365
481
|
}
|
|
366
|
-
}
|
|
482
|
+
})
|
|
367
483
|
);
|
|
368
484
|
|
|
369
485
|
// ── Hook: Lifecycle ──
|
|
370
|
-
api.on('before_reset', (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
|
|
486
|
+
api.on('before_reset', guardHook('hook:before_reset', api.logger, (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
|
|
371
487
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_reset');
|
|
372
488
|
if (!workspaceDir) {
|
|
373
489
|
api.logger.error(
|
|
@@ -378,9 +494,9 @@ const plugin = {
|
|
|
378
494
|
return;
|
|
379
495
|
}
|
|
380
496
|
return handleBeforeReset(event, { ...ctx, workspaceDir });
|
|
381
|
-
});
|
|
497
|
+
}));
|
|
382
498
|
|
|
383
|
-
api.on('before_compaction', (event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
499
|
+
api.on('before_compaction', guardHook('hook:before_compaction', api.logger, (event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
384
500
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_compaction');
|
|
385
501
|
if (!workspaceDir) {
|
|
386
502
|
api.logger.error(
|
|
@@ -391,9 +507,9 @@ const plugin = {
|
|
|
391
507
|
return;
|
|
392
508
|
}
|
|
393
509
|
return handleBeforeCompaction(event, { ...ctx, workspaceDir });
|
|
394
|
-
});
|
|
510
|
+
}));
|
|
395
511
|
|
|
396
|
-
api.on('after_compaction', (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
512
|
+
api.on('after_compaction', guardHook('hook:after_compaction', api.logger, (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
397
513
|
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'after_compaction');
|
|
398
514
|
if (!workspaceDir) {
|
|
399
515
|
api.logger.error(
|
|
@@ -404,17 +520,21 @@ const plugin = {
|
|
|
404
520
|
return;
|
|
405
521
|
}
|
|
406
522
|
return handleAfterCompaction(event, { ...ctx, workspaceDir });
|
|
407
|
-
});
|
|
523
|
+
}));
|
|
408
524
|
|
|
409
525
|
// ── Service: Background Evolution Worker ──
|
|
410
526
|
try {
|
|
411
527
|
EvolutionWorkerService.api = api;
|
|
412
|
-
api.
|
|
413
|
-
api.registerService(
|
|
414
|
-
api.
|
|
415
|
-
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);
|
|
416
536
|
} catch (err) {
|
|
417
|
-
api.logger.error(`[PD] Failed to register
|
|
537
|
+
api.logger.error(`[PD] Failed to register services: ${String(err)}`);
|
|
418
538
|
}
|
|
419
539
|
|
|
420
540
|
// ── Slash Commands ──
|
|
@@ -758,5 +878,7 @@ const plugin = {
|
|
|
758
878
|
};
|
|
759
879
|
|
|
760
880
|
export { PrincipleTreeLedgerAdapter } from './core/principle-tree-ledger-adapter.js';
|
|
881
|
+
/* istanbul ignore next — test exports for evolution worker gate */
|
|
882
|
+
export { loadFeatureFlagFromWorkspace, isRecord };
|
|
761
883
|
|
|
762
884
|
export default plugin;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-288: Quarantine EvolutionWorkerService default startup behind MVP feature flag.
|
|
3
|
+
*
|
|
4
|
+
* Tests prove:
|
|
5
|
+
* 1. Default config (no feature-flags.yaml) → EvolutionWorkerService does NOT start.
|
|
6
|
+
* 2. Explicit enable in feature-flags.yaml → EvolutionWorkerService starts.
|
|
7
|
+
* 3. Disabled state has structured observability from real helper, not hand-written JSON.
|
|
8
|
+
* 4. api.registerService still works regardless of flag state.
|
|
9
|
+
*
|
|
10
|
+
* ERR-002: disabled startup must be observable — verified via real shouldStartEvolutionWorker output.
|
|
11
|
+
* ERR-025: tests cover the real gate helper + loadFeatureFlagFromWorkspace, not hand-coded JSON.
|
|
12
|
+
* ERR-027: DEFAULT_FEATURE_FLAGS declaration matches runtime behavior.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as yaml from 'js-yaml';
|
|
19
|
+
|
|
20
|
+
// ── Mock dependencies that would trigger side effects ──
|
|
21
|
+
vi.mock('../src/core/dictionary-service.js', () => ({
|
|
22
|
+
DictionaryService: { get: vi.fn(() => ({ flush: vi.fn() })) },
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('../src/core/session-tracker.js', () => ({
|
|
26
|
+
initPersistence: vi.fn(),
|
|
27
|
+
flushAllSessions: vi.fn(),
|
|
28
|
+
listSessions: vi.fn(() => []),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../src/core/workspace-context.js', () => {
|
|
32
|
+
const mockCtx = {
|
|
33
|
+
stateDir: '',
|
|
34
|
+
workspaceDir: '',
|
|
35
|
+
config: { get: vi.fn() },
|
|
36
|
+
eventLog: { recordHookExecution: vi.fn() },
|
|
37
|
+
dictionary: { flush: vi.fn() },
|
|
38
|
+
resolve: vi.fn((key: string) => `/mock/${key}`),
|
|
39
|
+
trajectory: null,
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
WorkspaceContext: {
|
|
43
|
+
fromHookContext: vi.fn(() => mockCtx),
|
|
44
|
+
clearCache: vi.fn(),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Import after mocks — real helpers, not re-implemented logic
|
|
50
|
+
import { loadFeatureFlagFromWorkspace, shouldStartEvolutionWorker, isRecord } from '../src/index.js';
|
|
51
|
+
import { EvolutionWorkerService } from '../src/service/evolution-worker.js';
|
|
52
|
+
import { computeEffectiveFlags, DEFAULT_FEATURE_FLAGS } from '@principles/core/runtime-v2';
|
|
53
|
+
|
|
54
|
+
// ── Helpers ──
|
|
55
|
+
|
|
56
|
+
function createTempWorkspace(): string {
|
|
57
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-quarantine-'));
|
|
58
|
+
fs.mkdirSync(path.join(dir, '.pd'), { recursive: true });
|
|
59
|
+
fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeFeatureFlags(workspaceDir: string, flags: Record<string, unknown>): void {
|
|
64
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
65
|
+
const content = yaml.dump(flags, { schema: yaml.JSON_SCHEMA });
|
|
66
|
+
fs.writeFileSync(configPath, content, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMockLogger() {
|
|
70
|
+
return {
|
|
71
|
+
info: vi.fn(),
|
|
72
|
+
warn: vi.fn(),
|
|
73
|
+
error: vi.fn(),
|
|
74
|
+
debug: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Tests ──
|
|
79
|
+
|
|
80
|
+
describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
81
|
+
let workspaceDir: string;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
workspaceDir = createTempWorkspace();
|
|
85
|
+
EvolutionWorkerService.api = null;
|
|
86
|
+
EvolutionWorkerService._startedWorkspaces.clear();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
EvolutionWorkerService.api = null;
|
|
91
|
+
EvolutionWorkerService._startedWorkspaces.clear();
|
|
92
|
+
// Clean up temp dir
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
|
95
|
+
} catch {
|
|
96
|
+
// best-effort
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── 1. Feature flag registry ──
|
|
101
|
+
|
|
102
|
+
describe('DEFAULT_FEATURE_FLAGS includes evolution_worker', () => {
|
|
103
|
+
it('has evolution_worker flag with quiet category and enabled=false', () => {
|
|
104
|
+
const flag = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'evolution_worker');
|
|
105
|
+
expect(flag).toBeDefined();
|
|
106
|
+
expect(flag!.category).toBe('quiet');
|
|
107
|
+
expect(flag!.enabled).toBe(false);
|
|
108
|
+
expect(flag!.since).toBe('2026-06-01');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── 2. loadFeatureFlagFromWorkspace ──
|
|
113
|
+
|
|
114
|
+
describe('loadFeatureFlagFromWorkspace', () => {
|
|
115
|
+
it('returns enabled=false when no feature-flags.yaml exists', () => {
|
|
116
|
+
const logger = createMockLogger();
|
|
117
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
118
|
+
expect(result.enabled).toBe(false);
|
|
119
|
+
expect(result.source).toBe('defaults');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns enabled=false when feature-flags.yaml has no evolution_worker entry', () => {
|
|
123
|
+
writeFeatureFlags(workspaceDir, { prompt: { enabled: true } });
|
|
124
|
+
const logger = createMockLogger();
|
|
125
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
126
|
+
expect(result.enabled).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns enabled=true when feature-flags.yaml explicitly enables evolution_worker', () => {
|
|
130
|
+
writeFeatureFlags(workspaceDir, {
|
|
131
|
+
evolution_worker: { enabled: true },
|
|
132
|
+
});
|
|
133
|
+
const logger = createMockLogger();
|
|
134
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
135
|
+
expect(result.enabled).toBe(true);
|
|
136
|
+
expect(result.source).toBe('workspace_file');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns enabled=false when YAML is malformed and warning includes error detail', () => {
|
|
140
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
141
|
+
fs.writeFileSync(configPath, ' bad: [yaml: content', 'utf8');
|
|
142
|
+
const logger = createMockLogger();
|
|
143
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
144
|
+
expect(result.enabled).toBe(false);
|
|
145
|
+
// YAML parse warning must include error detail (fix #6)
|
|
146
|
+
const warnCalls = logger.warn.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
147
|
+
const parseWarn = warnCalls.find((m: string) => m.includes('YAML parse error'));
|
|
148
|
+
expect(parseWarn).toBeDefined();
|
|
149
|
+
// Error detail must contain something beyond "using defaults"
|
|
150
|
+
expect(parseWarn!.length).toBeGreaterThan('YAML parse error — using defaults'.length);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns defaults when file is unreadable', () => {
|
|
154
|
+
// Create a directory where a file should be — causes read error
|
|
155
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
156
|
+
fs.mkdirSync(configPath, { recursive: true });
|
|
157
|
+
const logger = createMockLogger();
|
|
158
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
159
|
+
expect(result.enabled).toBe(false);
|
|
160
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects dangerous keys (__proto__) and does not enable via prototype pollution', () => {
|
|
164
|
+
// Write raw YAML with __proto__ to test dangerous key rejection on raw parsed output
|
|
165
|
+
writeFeatureFlags(workspaceDir, {
|
|
166
|
+
__proto__: { enabled: true },
|
|
167
|
+
evolution_worker: { enabled: false },
|
|
168
|
+
});
|
|
169
|
+
const logger = createMockLogger();
|
|
170
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
171
|
+
expect(result.enabled).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── 3. shouldStartEvolutionWorker — real helper, real output ──
|
|
176
|
+
|
|
177
|
+
describe('shouldStartEvolutionWorker gate helper', () => {
|
|
178
|
+
it('returns shouldStart=false by default (no feature-flags.yaml)', () => {
|
|
179
|
+
const logger = createMockLogger();
|
|
180
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
181
|
+
expect(gate.shouldStart).toBe(false);
|
|
182
|
+
expect(gate.flagSource).toBe('defaults');
|
|
183
|
+
expect(gate.disabledInfo).not.toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns shouldStart=true when explicitly enabled', () => {
|
|
187
|
+
writeFeatureFlags(workspaceDir, {
|
|
188
|
+
evolution_worker: { enabled: true },
|
|
189
|
+
});
|
|
190
|
+
const logger = createMockLogger();
|
|
191
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
192
|
+
expect(gate.shouldStart).toBe(true);
|
|
193
|
+
expect(gate.flagSource).toBe('workspace_file');
|
|
194
|
+
expect(gate.disabledInfo).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('disabledInfo is valid JSON with required structured fields', () => {
|
|
198
|
+
const logger = createMockLogger();
|
|
199
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
200
|
+
expect(gate.disabledInfo).not.toBeNull();
|
|
201
|
+
// Parse the real output — not hand-written JSON
|
|
202
|
+
const parsed = JSON.parse(gate.disabledInfo!);
|
|
203
|
+
expect(parsed.reason).toBe('mvp_quiet_per_adr0014');
|
|
204
|
+
expect(parsed.nextAction).toContain('evolution_worker.enabled=true');
|
|
205
|
+
expect(parsed.featureFlag).toBe('evolution_worker');
|
|
206
|
+
expect(parsed.boundedContext).toBe('legacy_evolution_worker');
|
|
207
|
+
expect(parsed.flagSource).toBe('defaults');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── 4. EvolutionWorkerService does NOT start by default ──
|
|
212
|
+
|
|
213
|
+
describe('default config: worker does not start', () => {
|
|
214
|
+
it('EvolutionWorkerService.start is NOT called when gate returns shouldStart=false', () => {
|
|
215
|
+
const startSpy = vi.spyOn(EvolutionWorkerService, 'start');
|
|
216
|
+
const logger = createMockLogger();
|
|
217
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
218
|
+
|
|
219
|
+
expect(gate.shouldStart).toBe(false);
|
|
220
|
+
expect(EvolutionWorkerService._startedWorkspaces.has(workspaceDir)).toBe(false);
|
|
221
|
+
expect(startSpy).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
startSpy.mockRestore();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('disabled observability comes from real helper — logger receives structured output', () => {
|
|
227
|
+
const logger = createMockLogger();
|
|
228
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
229
|
+
|
|
230
|
+
// Simulate what index.ts does with the gate result
|
|
231
|
+
if (!gate.shouldStart && gate.disabledInfo) {
|
|
232
|
+
logger.info(`[PD] EvolutionWorker NOT started for workspace: ${workspaceDir}. ${gate.disabledInfo}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
expect(logger.info).toHaveBeenCalledTimes(1);
|
|
236
|
+
const loggedMsg = logger.info.mock.calls[0][0] as string;
|
|
237
|
+
expect(loggedMsg).toContain('EvolutionWorker NOT started');
|
|
238
|
+
// Parse the JSON from the real helper output embedded in the log message
|
|
239
|
+
const jsonStart = loggedMsg.indexOf('{');
|
|
240
|
+
expect(jsonStart).toBeGreaterThan(0);
|
|
241
|
+
const parsed = JSON.parse(loggedMsg.substring(jsonStart));
|
|
242
|
+
expect(parsed.reason).toBe('mvp_quiet_per_adr0014');
|
|
243
|
+
expect(parsed.featureFlag).toBe('evolution_worker');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── 5. Explicit enable: worker starts ──
|
|
248
|
+
|
|
249
|
+
describe('explicit enable: worker starts', () => {
|
|
250
|
+
it('shouldStartEvolutionWorker returns true when enabled in config', () => {
|
|
251
|
+
writeFeatureFlags(workspaceDir, {
|
|
252
|
+
evolution_worker: { enabled: true },
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const logger = createMockLogger();
|
|
256
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
257
|
+
expect(gate.shouldStart).toBe(true);
|
|
258
|
+
expect(gate.flagSource).toBe('workspace_file');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('EvolutionWorkerService.start actually runs when gate is true', () => {
|
|
262
|
+
writeFeatureFlags(workspaceDir, {
|
|
263
|
+
evolution_worker: { enabled: true },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const mockLogger = createMockLogger();
|
|
267
|
+
const mockConfig = { get: (k: string) => k === 'intervals.worker_poll_ms' ? 60000 : undefined };
|
|
268
|
+
const mockApi = {
|
|
269
|
+
logger: mockLogger,
|
|
270
|
+
config: mockConfig,
|
|
271
|
+
runtime: { subagent: {} },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
EvolutionWorkerService.api = mockApi as typeof EvolutionWorkerService.api;
|
|
275
|
+
EvolutionWorkerService.start({
|
|
276
|
+
config: mockConfig,
|
|
277
|
+
workspaceDir,
|
|
278
|
+
stateDir: path.join(workspaceDir, '.state'),
|
|
279
|
+
logger: mockLogger,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(EvolutionWorkerService._startedWorkspaces.has(workspaceDir)).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── 6. Core flags remain functional ──
|
|
287
|
+
|
|
288
|
+
describe('MVP-Core flags unaffected', () => {
|
|
289
|
+
it('prompt, code_tool_hook, defer_archive remain core+enabled', () => {
|
|
290
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'prompt', createMockLogger());
|
|
291
|
+
expect(result.enabled).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('computeEffectiveFlags preserves core flags even with evolution_worker override', () => {
|
|
295
|
+
writeFeatureFlags(workspaceDir, {
|
|
296
|
+
evolution_worker: { enabled: true },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
300
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
301
|
+
const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
302
|
+
|
|
303
|
+
// Use isRecord type guard instead of `as`
|
|
304
|
+
expect(isRecord(parsed)).toBe(true);
|
|
305
|
+
const flags = computeEffectiveFlags(parsed as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
|
|
306
|
+
expect(flags.flags['prompt']?.enabled).toBe(true);
|
|
307
|
+
expect(flags.flags['code_tool_hook']?.enabled).toBe(true);
|
|
308
|
+
expect(flags.flags['defer_archive']?.enabled).toBe(true);
|
|
309
|
+
expect(flags.flags['evolution_worker']?.enabled).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('core flags cannot be disabled by user override', () => {
|
|
313
|
+
writeFeatureFlags(workspaceDir, {
|
|
314
|
+
prompt: { enabled: false },
|
|
315
|
+
code_tool_hook: { enabled: false },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
319
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
320
|
+
const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
321
|
+
|
|
322
|
+
expect(isRecord(parsed)).toBe(true);
|
|
323
|
+
const flags = computeEffectiveFlags(parsed as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
|
|
324
|
+
expect(flags.flags['prompt']?.enabled).toBe(true); // core cannot be disabled
|
|
325
|
+
expect(flags.flags['code_tool_hook']?.enabled).toBe(true); // core cannot be disabled
|
|
326
|
+
expect(flags.warnings.length).toBeGreaterThan(0); // warnings about core override attempt
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── 7. No PLAN.md / confirm-first gate regression ──
|
|
331
|
+
|
|
332
|
+
describe('no confirm-first gate regression', () => {
|
|
333
|
+
it('no PLAN.md or confirm-first files are created in workspace', () => {
|
|
334
|
+
writeFeatureFlags(workspaceDir, {
|
|
335
|
+
evolution_worker: { enabled: false },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const planMd = path.join(workspaceDir, 'PLAN.md');
|
|
339
|
+
expect(fs.existsSync(planMd)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -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
|
+
});
|