principles-disciple 1.86.0 → 1.87.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.87.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.87.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -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
 
@@ -6,11 +6,18 @@ import {
6
6
  guardService,
7
7
  getSurfaceIdForHook,
8
8
  getSurfaceIdForService,
9
+ __resetSurfaceGuardSkipLogStateForTests,
9
10
  } from '../../src/core/surface-guard.js';
10
11
  import { PLUGIN_SURFACE_REGISTRY } from '@principles/core/runtime-v2';
11
12
  import type { OpenClawPluginService } from '../../src/openclaw-sdk.js';
12
13
 
13
14
  describe('surface-guard', () => {
15
+ beforeEach(() => {
16
+ // Each test starts with a clean surface-guard skip log state so the
17
+ // first-fire assertions are deterministic (PRI-298).
18
+ __resetSurfaceGuardSkipLogStateForTests();
19
+ vi.clearAllMocks();
20
+ });
14
21
  describe('getSurfaceIdForHook', () => {
15
22
  it('generates correct surface id without label', () => {
16
23
  expect(getSurfaceIdForHook('before_tool_call')).toBe('hook:before_tool_call');
@@ -144,6 +151,39 @@ describe('surface-guard', () => {
144
151
  expect(mockHandler).not.toHaveBeenCalled();
145
152
  });
146
153
 
154
+ it('does not log at guardHook construction time (PRI-298 / chatgpt P2)', () => {
155
+ // Registering a guard for a quiet hook must not emit the SKIP line on
156
+ // its own; the log fires only when the returned no-op is actually
157
+ // invoked. Plugin startup that registers a dozen quiet hooks would
158
+ // otherwise log a dozen SKIP lines before any real traffic.
159
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
160
+ guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
161
+ expect(mockLogger.info).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('logger undefined on first fire does not consume the one-shot slot (PRI-298 / coderabbit Major)', () => {
165
+ // First call has no logger — the no-op still suppresses the handler,
166
+ // and the once-only slot is preserved for a later call that does
167
+ // have a logger.
168
+ const handler1 = guardHook('hook:after_tool_call.trajectory', undefined, vi.fn());
169
+ handler1({}, {});
170
+
171
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
172
+ const handler2 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
173
+ handler2({}, {});
174
+
175
+ // Only the second call (which had a logger) should have emitted a log.
176
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
177
+ expect(mockLogger.info).toHaveBeenCalledWith(
178
+ expect.stringContaining('[PD:surface-guard] SKIP hook:after_tool_call.trajectory'),
179
+ );
180
+
181
+ // A third call should now be silent (slot consumed by the second call).
182
+ const handler3 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
183
+ handler3({}, {});
184
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
185
+ });
186
+
147
187
  it('does not log for enabled surface', () => {
148
188
  const mockLogger = { info: vi.fn(), debug: vi.fn() };
149
189
  const mockHandler = vi.fn();
@@ -160,6 +200,45 @@ describe('surface-guard', () => {
160
200
  expect.stringContaining('not found in registry'),
161
201
  );
162
202
  });
203
+
204
+ it('logs once on first fire and stays silent on subsequent fires (PRI-298 rate-limit)', () => {
205
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
206
+ const mockHandler = vi.fn();
207
+ const guarded = guardHook('hook:after_tool_call.trajectory', mockLogger, mockHandler);
208
+
209
+ // First fire: log is emitted once with the disabled reason.
210
+ guarded({}, {});
211
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
212
+ expect(mockLogger.info).toHaveBeenCalledWith(
213
+ expect.stringContaining('[PD:surface-guard] SKIP hook:after_tool_call.trajectory'),
214
+ );
215
+
216
+ // Subsequent fires on the same surfaceId: no further log noise, but the
217
+ // handler is still suppressed (observability remains: the no-op returns
218
+ // undefined; the surface is still classified as disabled).
219
+ for (let i = 0; i < 5; i += 1) {
220
+ guarded({}, {});
221
+ }
222
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
223
+ expect(mockHandler).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('logs first fire per surfaceId independently (one log per quiet surface)', () => {
227
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
228
+ const handler1 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
229
+ const handler2 = guardHook('hook:llm_output.trajectory', mockLogger, vi.fn());
230
+ const handler3 = guardHook('hook:subagent_spawning', mockLogger, vi.fn());
231
+
232
+ handler1({}, {});
233
+ handler2({}, {});
234
+ handler3({}, {});
235
+
236
+ expect(mockLogger.info).toHaveBeenCalledTimes(3);
237
+ const calls = mockLogger.info.mock.calls.map(c => String(c[0]));
238
+ expect(calls.some(c => c.includes('hook:after_tool_call.trajectory'))).toBe(true);
239
+ expect(calls.some(c => c.includes('hook:llm_output.trajectory'))).toBe(true);
240
+ expect(calls.some(c => c.includes('hook:subagent_spawning'))).toBe(true);
241
+ });
163
242
  });
164
243
 
165
244
  describe('guardService', () => {
@@ -194,5 +273,68 @@ describe('surface-guard', () => {
194
273
  const result = guardService('service:nonexistent', mockService);
195
274
  expect(result).toBeNull();
196
275
  });
276
+
277
+ it('logs once per service surface on first registration (PRI-298 rate-limit)', () => {
278
+ const mockLogger = { info: vi.fn(), debug: vi.fn() };
279
+ const service: OpenClawPluginService = { id: 'test-service' };
280
+
281
+ // First registration call: the disabled reason is logged once.
282
+ const first = guardService('service:trajectory', service, mockLogger);
283
+ expect(first).toBeNull();
284
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
285
+ expect(mockLogger.info).toHaveBeenCalledWith(
286
+ expect.stringContaining('SKIP service service:trajectory'),
287
+ );
288
+
289
+ // Subsequent guardService calls for the same surfaceId stay silent.
290
+ guardService('service:trajectory', service, mockLogger);
291
+ guardService('service:trajectory', service, mockLogger);
292
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
293
+ });
294
+ });
295
+
296
+ describe('PRI-298 disabledReason copy', () => {
297
+ it('no quiet surface disabledReason references "Story A" or "Story A\'"', () => {
298
+ const quietOrNonCore = PLUGIN_SURFACE_REGISTRY.filter(
299
+ s => s.category === 'quiet' || s.category === 'gone' || s.category === 'legacy_retire',
300
+ );
301
+ expect(quietOrNonCore.length).toBeGreaterThan(0);
302
+ for (const surface of quietOrNonCore) {
303
+ expect(surface.disabledReason).toBeDefined();
304
+ // MVP residue that should not appear in production log copy.
305
+ expect(surface.disabledReason).not.toMatch(/Story A/);
306
+ expect(surface.disabledReason).not.toMatch(/MVP\s*验收/);
307
+ expect(surface.disabledReason).not.toMatch(/测试任务/);
308
+ }
309
+ });
310
+
311
+ it('trajectory hook disabledReason is opt-in and ADR-anchored (PRI-298)', () => {
312
+ const trajectory = PLUGIN_SURFACE_REGISTRY.find(
313
+ s => s.id === 'hook:after_tool_call.trajectory',
314
+ );
315
+ expect(trajectory?.disabledReason).toBeDefined();
316
+ const reason = trajectory!.disabledReason!.toLowerCase();
317
+ // Quiet hook copy is opt-in / opt-out anchored on a real ADR section
318
+ // (no MVP-phase residue, no promise of a feature-flag override that
319
+ // the production guard path does not actually consume — chatgpt P2).
320
+ expect(reason).toContain('opt-in');
321
+ expect(reason).toContain('default off');
322
+ expect(reason).toMatch(/adr-?0014/);
323
+ });
324
+
325
+ it('no quiet surface disabledReason promises a feature flag override (PRI-298 / chatgpt P2)', () => {
326
+ // The runtime guard path (`isSurfaceEnabled(surfaceId)` with no
327
+ // overrides argument) does not consume `.pd/feature-flags.yaml`, so
328
+ // telling operators to "enable via feature flag override" would be
329
+ // an impossible next action. Quiet copy must describe the surface
330
+ // honestly without pointing to a non-existent override path.
331
+ const quiet = PLUGIN_SURFACE_REGISTRY.filter(s => s.category === 'quiet');
332
+ expect(quiet.length).toBeGreaterThan(0);
333
+ for (const surface of quiet) {
334
+ const reason = surface.disabledReason!.toLowerCase();
335
+ expect(reason).not.toContain('enable via feature flag');
336
+ expect(reason).not.toMatch(/enable via .* override/);
337
+ }
338
+ });
197
339
  });
198
340
  });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, beforeEach } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import {
@@ -109,6 +109,42 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
109
109
  expect(surface.disabledReason!.length).toBeGreaterThan(0);
110
110
  }
111
111
  });
112
+
113
+ it('no disabledReason references Story A / Story A\' / MVP 验收 / 测试任务 (PRI-298)', () => {
114
+ const disabled = PLUGIN_SURFACE_REGISTRY.filter(
115
+ s => s.category === 'quiet' || s.category === 'gone' || s.category === 'legacy_retire',
116
+ );
117
+ expect(disabled.length).toBeGreaterThan(0);
118
+ for (const surface of disabled) {
119
+ expect(surface.disabledReason).toBeDefined();
120
+ expect(surface.disabledReason).not.toMatch(/Story A/);
121
+ expect(surface.disabledReason).not.toMatch(/MVP\s*验收/);
122
+ expect(surface.disabledReason).not.toMatch(/测试任务/);
123
+ }
124
+ });
125
+
126
+ it('disabledReason copy is opt-in / feature-flag oriented for quiet surfaces (PRI-298)', () => {
127
+ const quiet = PLUGIN_SURFACE_REGISTRY.filter(s => s.category === 'quiet');
128
+ expect(quiet.length).toBeGreaterThan(0);
129
+ for (const surface of quiet) {
130
+ // Every quiet surface should anchor its reason in at least one
131
+ // stable, long-lived framing so the log copy can live in the product
132
+ // long after MVP. Acceptable framings:
133
+ // - opt-in / disabled language (new quiet entries),
134
+ // - feature-flag path (most existing entries),
135
+ // - ADR reference (entries gated by a specific ADR section).
136
+ // What we still reject: ephemeral MVP-phase copy (covered by the
137
+ // Story A / MVP 验收 test above).
138
+ const reason = surface.disabledReason!.toLowerCase();
139
+ const hasOptInOrDisabled = /opt-?in|disabled/.test(reason);
140
+ const hasFeatureFlag = reason.includes('feature flag');
141
+ const hasAdrReference = /adr-?\d+|adr\s+\d+/.test(reason);
142
+ expect(
143
+ hasOptInOrDisabled || hasFeatureFlag || hasAdrReference,
144
+ `quiet surface ${surface.id} disabledReason must reference opt-in, feature flag, or an ADR: "${surface.disabledReason}"`,
145
+ ).toBe(true);
146
+ }
147
+ });
112
148
  });
113
149
 
114
150
  describe('api.on() registration coverage — every hook must be guarded', () => {
@@ -292,6 +328,17 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
292
328
  });
293
329
 
294
330
  describe('surface guard runtime', () => {
331
+ let resetSurfaceGuardLogState: () => void;
332
+
333
+ beforeEach(async () => {
334
+ // Lazy import so the module state is freshly required per describe and
335
+ // we can reset the PRI-298 rate-limit bookkeeping before every runtime
336
+ // assertion that depends on the first-fire log firing.
337
+ const mod = await import('../../src/core/surface-guard.js');
338
+ resetSurfaceGuardLogState = mod.__resetSurfaceGuardSkipLogStateForTests;
339
+ resetSurfaceGuardLogState();
340
+ });
341
+
295
342
  it('checkSurfaceGuard passes with current registry', async () => {
296
343
  const { checkSurfaceGuard } = await import('../../src/core/surface-guard.js');
297
344
  const result = checkSurfaceGuard();
@@ -403,5 +450,88 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
403
450
  const guarded = guardService('service:nonexistent_service', service);
404
451
  expect(guarded).toBeNull();
405
452
  });
453
+
454
+ it('PRI-298 rate-limit: quiet surface logs once, not per invocation', async () => {
455
+ const { guardHook } = await import('../../src/core/surface-guard.js');
456
+ const logs: string[] = [];
457
+ const logger = { info: (msg: string) => { logs.push(msg); } };
458
+ const handler = () => 'result';
459
+ const guarded = guardHook('hook:after_tool_call.trajectory', logger, handler);
460
+
461
+ // First invocation must surface the disabled reason.
462
+ guarded({} as never, {} as never);
463
+ expect(logs.length).toBe(1);
464
+ expect(logs[0]).toContain('[PD:surface-guard] SKIP');
465
+ expect(logs[0]).toContain('hook:after_tool_call.trajectory');
466
+
467
+ // Subsequent invocations on the same surfaceId stay silent.
468
+ for (let i = 0; i < 10; i += 1) {
469
+ guarded({} as never, {} as never);
470
+ }
471
+ expect(logs.length).toBe(1);
472
+ });
473
+
474
+ it('PRI-298 rate-limit: resetSurfaceGuardSkipLogStateForTests re-arms first-fire', async () => {
475
+ const { guardHook } = await import('../../src/core/surface-guard.js');
476
+ const logs: string[] = [];
477
+ const logger = { info: (msg: string) => { logs.push(msg); } };
478
+ const handler = () => 'result';
479
+ const guarded = guardHook('hook:after_tool_call.trajectory', logger, handler);
480
+
481
+ guarded({} as never, {} as never);
482
+ expect(logs.length).toBe(1);
483
+
484
+ // Additional fires on the same surface: still 1 log.
485
+ guarded({} as never, {} as never);
486
+ expect(logs.length).toBe(1);
487
+
488
+ // Reset the per-process bookkeeping (simulating a fresh process / test
489
+ // isolation). The next fire on a freshly-constructed guarded handler
490
+ // should log again.
491
+ resetSurfaceGuardLogState();
492
+ const guarded2 = guardHook('hook:after_tool_call.trajectory', logger, handler);
493
+ guarded2({} as never, {} as never);
494
+ expect(logs.length).toBe(2);
495
+ });
496
+
497
+ it('PRI-298 / chatgpt P2: guardHook does NOT log at construction time', async () => {
498
+ const { guardHook } = await import('../../src/core/surface-guard.js');
499
+ const logs: string[] = [];
500
+ const logger = { info: (msg: string) => { logs.push(msg); } };
501
+ // The act of constructing the guard must not emit a SKIP line. Plugin
502
+ // startup that registers 7 quiet hooks would otherwise log 7 SKIP
503
+ // lines before any real traffic.
504
+ guardHook('hook:after_tool_call.trajectory', logger, () => 'result');
505
+ expect(logs.length).toBe(0);
506
+
507
+ // The first INVOCATION is when the log fires (and only once).
508
+ const guarded = guardHook('hook:llm_output.trajectory', logger, () => 'result');
509
+ guarded({} as never, {} as never);
510
+ expect(logs.length).toBe(1);
511
+ });
512
+
513
+ it('PRI-298 / coderabbit Major: guardHook logger undefined on first fire does not consume the one-shot slot', async () => {
514
+ const { guardHook } = await import('../../src/core/surface-guard.js');
515
+
516
+ // First call: no logger. The no-op suppresses the handler, but the
517
+ // once-only slot is preserved (a missing logger must not eat the
518
+ // chance to surface the disabled reason later).
519
+ const handler1 = guardHook('hook:after_tool_call.trajectory', undefined, () => 'result');
520
+ handler1({} as never, {} as never);
521
+
522
+ // Second call: real logger. This is now the first log emission for
523
+ // this surfaceId.
524
+ const logs: string[] = [];
525
+ const logger = { info: (msg: string) => { logs.push(msg); } };
526
+ const handler2 = guardHook('hook:after_tool_call.trajectory', logger, () => 'result');
527
+ handler2({} as never, {} as never);
528
+ expect(logs.length).toBe(1);
529
+ expect(logs[0]).toContain('[PD:surface-guard] SKIP');
530
+
531
+ // Third call: slot is now consumed; the third call is silent.
532
+ const handler3 = guardHook('hook:after_tool_call.trajectory', logger, () => 'result');
533
+ handler3({} as never, {} as never);
534
+ expect(logs.length).toBe(1);
535
+ });
406
536
  });
407
537
  });