principles-disciple 1.86.0 → 1.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.86.0",
5
+ "version": "1.88.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.86.0",
3
+ "version": "1.88.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -0,0 +1,400 @@
1
+ /**
2
+ * PD Config Loader (Plugin I/O boundary) — PRI-307
3
+ *
4
+ * Reads `.pd/config.yaml`, validates via core, computes effective config.
5
+ * Replaces the old `.pd/feature-flags.yaml` and `.state/workflows.yaml` reading
6
+ * for plugin production paths.
7
+ *
8
+ * ADR-0016: PD owns exactly one user config file.
9
+ * - Missing config → defaults with nextAction
10
+ * - Malformed config → fail loud with errors and nextAction
11
+ * - No secrets in output
12
+ * - Observer disabled → no start / no noisy log cycling
13
+ * - Observer enabled + missing setup → structured needs_setup + nextAction
14
+ */
15
+
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import yaml from 'js-yaml';
19
+ import {
20
+ validatePdConfig,
21
+ computeEffectivePdConfig,
22
+ computeFeatureFlagsFromConfig,
23
+ INTERNAL_AGENT_NAMES,
24
+ } from '@principles/core/runtime-v2';
25
+ import type {
26
+ EffectivePdConfig,
27
+ PdConfigValidationResult,
28
+ InternalAgentName,
29
+ } from '@principles/core/runtime-v2';
30
+
31
+ // ── Constants ────────────────────────────────────────────────────────────────
32
+
33
+ export const PD_CONFIG_DIR = '.pd';
34
+ export const PD_CONFIG_FILENAME = 'config.yaml';
35
+
36
+ // ── Types ────────────────────────────────────────────────────────────────────
37
+
38
+ export type ObserverReadiness = 'disabled' | 'needs_setup' | 'ready' | 'not_ready' | 'config_malformed';
39
+
40
+ export interface ObserverConfigResult {
41
+ /** Whether the observer feature is enabled in config */
42
+ enabled: boolean;
43
+ /** Observer readiness state */
44
+ readiness: ObserverReadiness;
45
+ /** Config source: 'defaults' | 'user_config' | 'malformed' */
46
+ source: string;
47
+ /** Reason for current state */
48
+ reason: string;
49
+ /** What the user should do next */
50
+ nextAction: string;
51
+ /** The runtime profile ID for this observer, if configured */
52
+ runtimeProfileId: string | null;
53
+ /** The runtime profile type, if configured */
54
+ runtimeProfileType: string | null;
55
+ /** The apiKeyEnv for the runtime profile, if applicable */
56
+ apiKeyEnv: string | null;
57
+ /** Whether the apiKeyEnv is present in process.env */
58
+ apiKeyPresent: boolean;
59
+ /** Provider name from runtime profile */
60
+ provider: string | null;
61
+ /** Model name from runtime profile */
62
+ model: string | null;
63
+ /** Timeout from runtime profile */
64
+ timeoutMs: number | null;
65
+ /** Base URL from runtime profile */
66
+ baseUrl: string | null;
67
+ /** Config validation errors (only present when readiness=config_malformed) */
68
+ configErrors?: Array<{ path: string; reason: string; nextAction: string }>;
69
+ }
70
+
71
+ export interface PluginConfigLoadResult {
72
+ ok: boolean;
73
+ effective: EffectivePdConfig;
74
+ source: 'defaults' | 'user_config' | 'malformed';
75
+ configPath: string;
76
+ warnings: string[];
77
+ errors: Array<{ path: string; reason: string; nextAction: string }>;
78
+ }
79
+
80
+ // ── Config Path ──────────────────────────────────────────────────────────────
81
+
82
+ export function getPdConfigPath(workspaceDir: string): string {
83
+ return path.join(workspaceDir, PD_CONFIG_DIR, PD_CONFIG_FILENAME);
84
+ }
85
+
86
+ // ── Load PD Config ───────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Load and validate `.pd/config.yaml` from the workspace.
90
+ * Never throws on malformed input. Always provides a usable fallback.
91
+ */
92
+ export function loadPdConfigForPlugin(workspaceDir: string): PluginConfigLoadResult {
93
+ const configPath = getPdConfigPath(workspaceDir);
94
+
95
+ // 1) Config file missing → use defaults
96
+ if (!fs.existsSync(configPath)) {
97
+ const effective = computeEffectivePdConfig(null);
98
+ return {
99
+ ok: true,
100
+ effective,
101
+ source: 'defaults',
102
+ configPath,
103
+ warnings: effective.warnings,
104
+ errors: [],
105
+ };
106
+ }
107
+
108
+ // 2) Read the file
109
+ let raw: string;
110
+ try {
111
+ raw = fs.readFileSync(configPath, 'utf8');
112
+ } catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ const effective = computeEffectivePdConfig(null);
115
+ return {
116
+ ok: false,
117
+ effective,
118
+ source: 'malformed',
119
+ configPath,
120
+ warnings: [],
121
+ errors: [{ path: '', reason: `Failed to read .pd/config.yaml: ${message}`, nextAction: 'Check file permissions for .pd/config.yaml' }],
122
+ };
123
+ }
124
+
125
+ // 3) Parse YAML — treat as unknown (ERR-001)
126
+ let parsed: unknown;
127
+ try {
128
+ parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
129
+ } catch (err) {
130
+ const message = err instanceof Error ? err.message : String(err);
131
+ const effective = computeEffectivePdConfig(null);
132
+ return {
133
+ ok: false,
134
+ effective,
135
+ source: 'malformed',
136
+ configPath,
137
+ warnings: [],
138
+ errors: [{ path: '', reason: `YAML parse error in .pd/config.yaml: ${message}`, nextAction: 'Fix YAML syntax in .pd/config.yaml' }],
139
+ };
140
+ }
141
+
142
+ // 4) Validate via core (ERR-001, ERR-005: no `as` bypasses)
143
+ const validationResult: PdConfigValidationResult = validatePdConfig(parsed);
144
+
145
+ if (!validationResult.ok) {
146
+ const effective = computeEffectivePdConfig(null);
147
+ return {
148
+ ok: false,
149
+ effective,
150
+ source: 'malformed',
151
+ configPath,
152
+ warnings: [],
153
+ errors: validationResult.errors.map(e => ({
154
+ path: e.path,
155
+ reason: e.reason,
156
+ nextAction: e.nextAction,
157
+ })),
158
+ };
159
+ }
160
+
161
+ // 5) Compute effective config
162
+ const effective = computeEffectivePdConfig(validationResult.value);
163
+
164
+ return {
165
+ ok: true,
166
+ effective,
167
+ source: 'user_config',
168
+ configPath,
169
+ warnings: effective.warnings,
170
+ errors: [],
171
+ };
172
+ }
173
+
174
+ // ── Feature Flag from Config ─────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Get a single feature flag's enabled state from .pd/config.yaml.
178
+ * Replaces the old `loadFeatureFlagFromWorkspace` which read .pd/feature-flags.yaml.
179
+ */
180
+ export function loadFeatureFlagFromConfig(
181
+ workspaceDir: string,
182
+ flagId: string,
183
+ logger?: { warn?: (msg: string) => void; info?: (msg: string) => void },
184
+ ): { enabled: boolean; source: string } {
185
+ const result = loadPdConfigForPlugin(workspaceDir);
186
+ const flags = computeFeatureFlagsFromConfig(result.effective);
187
+ const flag = flags.flags[flagId];
188
+
189
+ if (!result.ok) {
190
+ logger?.warn?.(`[PD:Config] Config validation failed: ${result.errors.map(e => e.reason).join('; ')} — using defaults`);
191
+ }
192
+
193
+ return {
194
+ enabled: flag?.enabled ?? false,
195
+ source: result.source,
196
+ };
197
+ }
198
+
199
+ // ── Observer Config Resolution ───────────────────────────────────────────────
200
+
201
+ /**
202
+ * Resolve observer configuration from .pd/config.yaml.
203
+ *
204
+ * Returns structured state:
205
+ * - config_malformed: config file is invalid — no guessing, fail loud
206
+ * - disabled: observer feature flag is off OR agent.enabled=false → no start, no noisy logs
207
+ * - needs_setup: observer enabled but runtime profile missing, API key not set, or unsupported profile type
208
+ * - ready: observer enabled and fully configured (pi-ai with key present)
209
+ * - not_ready: observer enabled, API key present, but runtime availability unknown
210
+ */
211
+ export function resolveObserverConfig(
212
+ workspaceDir: string,
213
+ observerFlagId: string,
214
+ observerAgentName: string,
215
+ _logger?: { warn?: (msg: string) => void; info?: (msg: string) => void; debug?: (msg: string) => void },
216
+ ): ObserverConfigResult {
217
+ const result = loadPdConfigForPlugin(workspaceDir);
218
+
219
+ // 0) Malformed config → fail loud, do NOT swallow as "disabled"
220
+ if (!result.ok) {
221
+ return {
222
+ enabled: false,
223
+ readiness: 'config_malformed',
224
+ source: 'malformed',
225
+ reason: `Config validation failed: ${result.errors.map(e => e.reason).join('; ')}`,
226
+ nextAction: result.errors[0]?.nextAction ?? 'Fix .pd/config.yaml and retry',
227
+ runtimeProfileId: null,
228
+ runtimeProfileType: null,
229
+ apiKeyEnv: null,
230
+ apiKeyPresent: false,
231
+ provider: null,
232
+ model: null,
233
+ timeoutMs: null,
234
+ baseUrl: null,
235
+ configErrors: result.errors,
236
+ };
237
+ }
238
+
239
+ const config = result.effective.config;
240
+
241
+ // 1) Check if the observer feature flag is enabled
242
+ const featureFlag = config.features[observerFlagId];
243
+ if (!featureFlag || !featureFlag.enabled) {
244
+ return {
245
+ enabled: false,
246
+ readiness: 'disabled',
247
+ source: result.source,
248
+ reason: `${observerFlagId} is disabled in .pd/config.yaml`,
249
+ nextAction: `Set features.${observerFlagId}.enabled=true in .pd/config.yaml to enable`,
250
+ runtimeProfileId: null,
251
+ runtimeProfileType: null,
252
+ apiKeyEnv: null,
253
+ apiKeyPresent: false,
254
+ provider: null,
255
+ model: null,
256
+ timeoutMs: null,
257
+ baseUrl: null,
258
+ };
259
+ }
260
+
261
+ // 2) Check if the agent itself is enabled (feature flag ≠ agent.enabled)
262
+ const knownNames: readonly string[] = INTERNAL_AGENT_NAMES;
263
+ if (!knownNames.includes(observerAgentName)) {
264
+ return {
265
+ enabled: false,
266
+ readiness: 'needs_setup',
267
+ source: result.source,
268
+ reason: `Unknown agent name '${observerAgentName}'`,
269
+ nextAction: `Use one of the known agent names: ${INTERNAL_AGENT_NAMES.join(', ')}`,
270
+ runtimeProfileId: null,
271
+ runtimeProfileType: null,
272
+ apiKeyEnv: null,
273
+ apiKeyPresent: false,
274
+ provider: null,
275
+ model: null,
276
+ timeoutMs: null,
277
+ baseUrl: null,
278
+ };
279
+ }
280
+ const agentKey = observerAgentName as InternalAgentName;
281
+ const agentConfig = config.internalAgents.agents[agentKey];
282
+
283
+ // Feature flag on but agent.enabled=false → disabled (not enabled)
284
+ if (!agentConfig || !agentConfig.enabled) {
285
+ return {
286
+ enabled: false,
287
+ readiness: 'disabled',
288
+ source: result.source,
289
+ reason: `${observerFlagId} feature flag is enabled but internalAgents.agents.${observerAgentName}.enabled is false`,
290
+ nextAction: `Set internalAgents.agents.${observerAgentName}.enabled=true in .pd/config.yaml, or disable features.${observerFlagId}`,
291
+ runtimeProfileId: null,
292
+ runtimeProfileType: null,
293
+ apiKeyEnv: null,
294
+ apiKeyPresent: false,
295
+ provider: null,
296
+ model: null,
297
+ timeoutMs: null,
298
+ baseUrl: null,
299
+ };
300
+ }
301
+
302
+ // 3) Find the agent's runtime profile
303
+ const runtimeProfileId = agentConfig.runtimeProfile ?? config.internalAgents.defaultRuntime;
304
+ const profile = config.runtimeProfiles[runtimeProfileId];
305
+
306
+ if (!profile) {
307
+ return {
308
+ enabled: true,
309
+ readiness: 'needs_setup',
310
+ source: result.source,
311
+ reason: `Runtime profile '${runtimeProfileId}' not found in .pd/config.yaml`,
312
+ nextAction: `Add runtime profile '${runtimeProfileId}' to .pd/config.yaml runtimeProfiles`,
313
+ runtimeProfileId,
314
+ runtimeProfileType: null,
315
+ apiKeyEnv: null,
316
+ apiKeyPresent: false,
317
+ provider: null,
318
+ model: null,
319
+ timeoutMs: null,
320
+ baseUrl: null,
321
+ };
322
+ }
323
+
324
+ // 4) For pi-ai profiles, check API key
325
+ if (profile.type === 'pi-ai') {
326
+ const apiKeyEnv = profile.apiKeyEnv ?? null;
327
+ const apiKeyPresent = !!apiKeyEnv && Object.prototype.hasOwnProperty.call(process.env, apiKeyEnv) && !!process.env[apiKeyEnv];
328
+
329
+ if (!apiKeyEnv) {
330
+ return {
331
+ enabled: true,
332
+ readiness: 'needs_setup',
333
+ source: result.source,
334
+ reason: `pi-ai profile '${runtimeProfileId}' missing apiKeyEnv`,
335
+ nextAction: `Add apiKeyEnv to runtime profile '${runtimeProfileId}' in .pd/config.yaml`,
336
+ runtimeProfileId,
337
+ runtimeProfileType: profile.type,
338
+ apiKeyEnv: null,
339
+ apiKeyPresent: false,
340
+ provider: profile.provider ?? null,
341
+ model: profile.model ?? null,
342
+ timeoutMs: profile.timeoutMs ?? null,
343
+ baseUrl: profile.baseUrl ?? null,
344
+ };
345
+ }
346
+
347
+ if (!apiKeyPresent) {
348
+ return {
349
+ enabled: true,
350
+ readiness: 'needs_setup',
351
+ source: result.source,
352
+ reason: `Environment variable '${apiKeyEnv}' is not set or empty`,
353
+ nextAction: `Set the environment variable '${apiKeyEnv}' with a valid API key`,
354
+ runtimeProfileId,
355
+ runtimeProfileType: profile.type,
356
+ apiKeyEnv,
357
+ apiKeyPresent: false,
358
+ provider: profile.provider ?? null,
359
+ model: profile.model ?? null,
360
+ timeoutMs: profile.timeoutMs ?? null,
361
+ baseUrl: profile.baseUrl ?? null,
362
+ };
363
+ }
364
+
365
+ // pi-ai with key present — runtime availability unknown without actual probe
366
+ return {
367
+ enabled: true,
368
+ readiness: 'not_ready',
369
+ source: result.source,
370
+ reason: `pi-ai profile configured with apiKeyEnv='${apiKeyEnv}' (key present); runtime availability unknown`,
371
+ nextAction: 'Run pd runtime probe to verify end-to-end connectivity',
372
+ runtimeProfileId,
373
+ runtimeProfileType: profile.type,
374
+ apiKeyEnv,
375
+ apiKeyPresent: true,
376
+ provider: profile.provider ?? null,
377
+ model: profile.model ?? null,
378
+ timeoutMs: profile.timeoutMs ?? null,
379
+ baseUrl: profile.baseUrl ?? null,
380
+ };
381
+ }
382
+
383
+ // 5) OpenClaw profile — CorrectionObserver does NOT support OpenClaw runtime
384
+ // Mark as needs_setup with nextAction to configure a pi-ai profile
385
+ return {
386
+ enabled: true,
387
+ readiness: 'needs_setup',
388
+ source: result.source,
389
+ reason: `OpenClaw profile '${runtimeProfileId}' is not supported for observer runtime. Observers require a pi-ai profile with an API key.`,
390
+ nextAction: `Configure a pi-ai runtime profile for ${observerAgentName} in .pd/config.yaml (e.g., add a pi-ai profile with provider, model, and apiKeyEnv)`,
391
+ runtimeProfileId,
392
+ runtimeProfileType: profile.type,
393
+ apiKeyEnv: null,
394
+ apiKeyPresent: false,
395
+ provider: profile.provider ?? null,
396
+ model: profile.model ?? null,
397
+ timeoutMs: null,
398
+ baseUrl: null,
399
+ };
400
+ }
@@ -1,8 +1,6 @@
1
- import * as path from 'path';
2
- import * as fs from 'fs';
3
- import * as yaml from 'js-yaml';
4
- import { SqliteConnection, SqliteActivationStateStore, computeEffectiveFlags, DEFAULT_FEATURE_FLAGS, filterPromptActivations, resolvePrincipleFromArtifact } from '@principles/core/runtime-v2';
5
- import type { EffectiveFeatureFlags, ActivatedPrinciple, PromptActivationReaderResult } from '@principles/core/runtime-v2';
1
+ import { SqliteConnection, SqliteActivationStateStore, computeFeatureFlagsFromConfig, filterPromptActivations, resolvePrincipleFromArtifact } from '@principles/core/runtime-v2';
2
+ import type { FeatureFlagsResult, ActivatedPrinciple, PromptActivationReaderResult } from '@principles/core/runtime-v2';
3
+ import { loadPdConfigForPlugin } from './pd-config-loader.js';
6
4
 
7
5
  export { RUNTIME_V2_PRINCIPLE_BUDGET } from '@principles/core/runtime-v2';
8
6
  export type { ActivatedPrinciple, PromptActivationReaderResult };
@@ -11,12 +9,6 @@ export interface PromptActivationReaderDeps {
11
9
  logger?: { warn?: (msg: string) => void; info?: (msg: string) => void; error?: (msg: string) => void };
12
10
  }
13
11
 
14
- const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
15
-
16
- function isRecord(value: unknown): value is Record<string, unknown> {
17
- return value !== null && typeof value === 'object' && !Array.isArray(value);
18
- }
19
-
20
12
  export class PromptActivationReader {
21
13
  private readonly workspaceDir: string;
22
14
  private readonly deps: PromptActivationReaderDeps;
@@ -103,60 +95,20 @@ export class PromptActivationReader {
103
95
  }
104
96
  }
105
97
 
106
- private loadFeatureFlags(): EffectiveFeatureFlags {
107
- const configPath = path.join(this.workspaceDir, '.pd', 'feature-flags.yaml');
108
-
109
- if (!fs.existsSync(configPath)) {
110
- return computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
111
- }
112
-
113
- let raw: string;
114
- try {
115
- raw = fs.readFileSync(configPath, 'utf8');
116
- } catch (e) {
117
- const msg = e instanceof Error ? e.message : String(e);
118
- this.deps.logger?.warn?.(`[PD:RuntimeV2] Feature flags unreadable: ${msg} — using defaults`);
119
- return computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
120
- }
121
-
122
- let parsed: unknown;
123
- try {
124
- parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
125
- } catch {
126
- this.deps.logger?.warn?.(`[PD:RuntimeV2] Feature flags YAML parse error — using defaults`);
127
- return {
128
- ...computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath),
129
- warnings: ['feature-flags.yaml: YAML parse error, using defaults'],
130
- };
131
- }
132
-
133
- if (!isRecord(parsed)) {
134
- this.deps.logger?.warn?.(`[PD:RuntimeV2] Feature flags not a mapping — using defaults`);
135
- return {
136
- ...computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath),
137
- warnings: ['feature-flags.yaml: expected a mapping, using defaults'],
138
- };
139
- }
140
-
141
- const parsedRecord: Record<string, unknown> = Object.create(null);
142
- const yamlWarnings: string[] = [];
143
- for (const key of Object.keys(parsed)) {
144
- if (DANGEROUS_KEYS.has(key)) {
145
- yamlWarnings.push(`feature-flags.yaml: dangerous key '${key}' rejected`);
146
- continue;
147
- }
148
- if (Object.hasOwn(parsed, key)) {
149
- parsedRecord[key] = parsed[key];
98
+ /**
99
+ * PRI-305/PRI-307: Load feature flags from .pd/config.yaml instead of .pd/feature-flags.yaml.
100
+ * Uses the shared plugin config loader for consistency.
101
+ */
102
+ private loadFeatureFlags(): FeatureFlagsResult {
103
+ const result = loadPdConfigForPlugin(this.workspaceDir);
104
+ const flags = computeFeatureFlagsFromConfig(result.effective);
105
+
106
+ if (!result.ok) {
107
+ for (const err of result.errors) {
108
+ this.deps.logger?.warn?.(`[PD:RuntimeV2] Config error at ${err.path}: ${err.reason}`);
150
109
  }
151
110
  }
152
111
 
153
- const result = computeEffectiveFlags(parsedRecord, DEFAULT_FEATURE_FLAGS, configPath);
154
- if (yamlWarnings.length > 0) {
155
- result.warnings = [...yamlWarnings, ...result.warnings];
156
- for (const w of yamlWarnings) {
157
- this.deps.logger?.warn?.(`[PD:RuntimeV2] ${w}`);
158
- }
159
- }
160
- return result;
112
+ return flags;
161
113
  }
162
114
  }
@@ -15,6 +15,53 @@ export interface SurfaceGuardResult {
15
15
  warnings: string[];
16
16
  }
17
17
 
18
+ // Surface-level once-only log state (PRI-298).
19
+ // The first time a quiet/non-core surface guard actually fires in this
20
+ // process, the disabled reason is emitted once. Subsequent fires for the
21
+ // same surfaceId are still observable (the no-op handler preserves
22
+ // behaviour) but no longer flood the log. Fresh processes start with an
23
+ // empty set, so each plugin load gets one observable skip per surface.
24
+ //
25
+ // The Set is only updated when the log was actually emitted, so passing
26
+ // `undefined` for the logger on a quiet first fire does NOT consume the
27
+ // one-shot slot — a later registration that supplies a logger still gets
28
+ // the first-fire reason (PRI-298 / ERR-002).
29
+ const loggedSkipSurfaces = new Set<string>();
30
+
31
+ type LoggerLike = { info?: (msg: string) => void; debug?: (msg: string) => void };
32
+
33
+ /**
34
+ * Emit the disabled-reason log line for `surfaceId` at most once per
35
+ * process. Returns true if the log was emitted, false if it was suppressed
36
+ * (already logged, or no logger available). Only marks the surface as
37
+ * logged when the log was actually written, so a missing logger on first
38
+ * call does not consume the one-shot slot.
39
+ */
40
+ function logSkipOnce(
41
+ surfaceId: string,
42
+ logger: LoggerLike | undefined,
43
+ message: string,
44
+ ): boolean {
45
+ if (loggedSkipSurfaces.has(surfaceId)) {
46
+ return false;
47
+ }
48
+ if (!logger?.info) {
49
+ return false;
50
+ }
51
+ loggedSkipSurfaces.add(surfaceId);
52
+ logger.info(message);
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * Reset the per-process surface-guard skip log bookkeeping. Intended for tests
58
+ * that need to assert on the first-fire log without cross-test pollution.
59
+ * Not part of the production API surface; do not call from runtime code.
60
+ */
61
+ export function __resetSurfaceGuardSkipLogStateForTests(): void {
62
+ loggedSkipSurfaces.clear();
63
+ }
64
+
18
65
  export function checkSurfaceGuard(): SurfaceGuardResult {
19
66
  const validation = validateSurfaceRegistry(PLUGIN_SURFACE_REGISTRY);
20
67
  const violations: string[] = [];
@@ -98,7 +145,7 @@ export type HookHandler<E, C, R> = (event: E, ctx: C) => R | Promise<R>;
98
145
 
99
146
  export function guardHook<E, C, R>(
100
147
  surfaceId: string,
101
- logger: { info?: (msg: string) => void; debug?: (msg: string) => void } | undefined,
148
+ logger: LoggerLike | undefined,
102
149
  handler: HookHandler<E, C, R>,
103
150
  ): HookHandler<E, C, R> {
104
151
  const check = isSurfaceEnabled(surfaceId);
@@ -106,8 +153,15 @@ export function guardHook<E, C, R>(
106
153
  return handler;
107
154
  }
108
155
  const reason = check.reason ?? 'surface not enabled';
156
+ // Log on the first ACTUAL no-op invocation, not at construction time
157
+ // (PRI-298). Construction-time logging would emit a `SKIP` line at
158
+ // plugin startup for every registered quiet hook, regardless of
159
+ // whether the hook ever fires — which is exactly the startup log
160
+ // noise this change is meant to prevent. The one-shot is consumed only
161
+ // when the log was actually written, so `undefined` logger on first
162
+ // call does not eat the slot.
109
163
  return (_event: E, _ctx: C): R | Promise<R> => {
110
- logger?.info?.(`[PD:surface-guard] SKIP ${surfaceId}: ${reason}`);
164
+ logSkipOnce(surfaceId, logger, `[PD:surface-guard] SKIP ${surfaceId}: ${reason}`);
111
165
  return undefined as R;
112
166
  };
113
167
  }
@@ -115,14 +169,18 @@ export function guardHook<E, C, R>(
115
169
  export function guardService<T extends OpenClawPluginService>(
116
170
  surfaceId: string,
117
171
  service: T,
118
- logger?: { info?: (msg: string) => void; debug?: (msg: string) => void },
172
+ logger?: LoggerLike,
119
173
  ): T | null {
120
174
  const check = isSurfaceEnabled(surfaceId);
121
175
  if (check.enabled) {
122
176
  return service;
123
177
  }
124
178
  const reason = check.reason ?? 'surface not enabled';
125
- logger?.info?.(`[PD:surface-guard] SKIP service ${surfaceId}: ${reason}`);
179
+ // guardService is called once per service during plugin registration,
180
+ // so the once-only check fires on the registration call itself. The
181
+ // shared helper makes the consumption rule identical to guardHook:
182
+ // a missing logger on first call does not consume the one-shot.
183
+ logSkipOnce(surfaceId, logger, `[PD:surface-guard] SKIP service ${surfaceId}: ${reason}`);
126
184
  return null;
127
185
  }
128
186