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.
@@ -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
  });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ import * as yaml from 'js-yaml';
5
6
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
6
7
 
7
8
  const mockLearner = {
@@ -35,6 +36,89 @@ vi.mock('../../src/service/keyword-optimization-service.js', () => ({
35
36
  KeywordOptimizationService: { get: vi.fn(() => mockOptimizationService) },
36
37
  }));
37
38
 
39
+ // PRI-307: Mock the pd-config-loader instead of @principles/core/runtime-v2
40
+ // The service now reads .pd/config.yaml via resolveObserverConfig
41
+ vi.mock('../../src/core/pd-config-loader.js', () => {
42
+ return {
43
+ loadPdConfigForPlugin: vi.fn(() => ({
44
+ ok: true,
45
+ effective: {
46
+ config: {
47
+ version: 1,
48
+ features: {
49
+ prompt: { category: 'core', enabled: true },
50
+ code_tool_hook: { category: 'core', enabled: true },
51
+ defer_archive: { category: 'core', enabled: true },
52
+ correction_observer: { category: 'quiet', enabled: true },
53
+ empathy_observer: { category: 'quiet', enabled: false },
54
+ },
55
+ runtimeProfiles: {
56
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
57
+ 'pd.anthropic-sonnet': { type: 'pi-ai', provider: 'anthropic', model: 'claude-3-5-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY', timeoutMs: 30000 },
58
+ },
59
+ internalAgents: {
60
+ defaultRuntime: 'openclaw.default',
61
+ agents: {
62
+ diagnostician: { enabled: true },
63
+ dreamer: { enabled: true },
64
+ scribe: { enabled: true },
65
+ artificer: { enabled: true },
66
+ philosopher: { enabled: false },
67
+ evaluator: { enabled: false },
68
+ rolloutReviewer: { enabled: false },
69
+ trainer: { enabled: false },
70
+ correctionObserver: { enabled: true, runtimeProfile: 'pd.anthropic-sonnet' },
71
+ empathyObserver: { enabled: false },
72
+ },
73
+ },
74
+ },
75
+ warnings: [],
76
+ },
77
+ source: 'defaults',
78
+ configPath: '.pd/config.yaml',
79
+ warnings: [],
80
+ errors: [],
81
+ })),
82
+ loadFeatureFlagFromConfig: vi.fn(() => ({ enabled: true, source: 'defaults' })),
83
+ resolveObserverConfig: vi.fn((_workspaceDir: string, flagId: string, _agentName: string) => {
84
+ // Default: return disabled for correction_observer (no config file in test tmp dirs)
85
+ if (flagId === 'correction_observer') {
86
+ return {
87
+ enabled: true,
88
+ readiness: 'not_ready',
89
+ source: 'defaults',
90
+ reason: 'pi-ai profile configured with apiKeyEnv',
91
+ nextAction: 'Run pd runtime probe',
92
+ runtimeProfileId: 'pd.anthropic-sonnet',
93
+ runtimeProfileType: 'pi-ai',
94
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
95
+ apiKeyPresent: !!process.env.ANTHROPIC_API_KEY,
96
+ provider: 'anthropic',
97
+ model: 'claude-3-5-sonnet',
98
+ timeoutMs: 30000,
99
+ baseUrl: null,
100
+ };
101
+ }
102
+ return {
103
+ enabled: false,
104
+ readiness: 'disabled',
105
+ source: 'defaults',
106
+ reason: `${flagId} is disabled`,
107
+ nextAction: `Set features.${flagId}.enabled=true in .pd/config.yaml`,
108
+ runtimeProfileId: null,
109
+ runtimeProfileType: null,
110
+ apiKeyEnv: null,
111
+ apiKeyPresent: false,
112
+ provider: null,
113
+ model: null,
114
+ timeoutMs: null,
115
+ baseUrl: null,
116
+ };
117
+ }),
118
+ getPdConfigPath: vi.fn((workspaceDir: string) => path.join(workspaceDir, '.pd', 'config.yaml')),
119
+ };
120
+ });
121
+
38
122
  const mockDispatch = vi.fn().mockResolvedValue({
39
123
  updated: true,
40
124
  summary: 'Keyword store optimized',
@@ -45,23 +129,12 @@ const mockRegister = vi.fn();
45
129
 
46
130
  vi.mock('@principles/core/runtime-v2', () => {
47
131
  return {
48
- WorkflowFunnelLoader: class {
49
- getFunnel = vi.fn(() => ({
50
- policy: {
51
- runtimeKind: 'pi-ai',
52
- provider: 'anthropic',
53
- model: 'anthropic/claude-3-5-sonnet',
54
- apiKeyEnv: 'ANTHROPIC_API_KEY',
55
- timeoutMs: 30000,
56
- }
57
- }));
58
- },
59
132
  PiAiRuntimeAdapter: class {},
60
133
  CorrectionObserver: class {},
61
134
  AgentScheduler: class {
62
135
  register = mockRegister;
63
136
  dispatch = mockDispatch;
64
- }
137
+ },
65
138
  };
66
139
  });
67
140
 
@@ -330,12 +403,12 @@ describe('runCorrectionObserverCycle — Independent Execution', () => {
330
403
  });
331
404
  });
332
405
 
333
- describe('resolveCorrectionObserver — Configuration Resolution', () => {
406
+ describe('resolveCorrectionObserver — Configuration Resolution (PRI-307)', () => {
334
407
  beforeEach(() => {
335
408
  vi.clearAllMocks();
336
409
  });
337
410
 
338
- it('returns observer when API key env is set with mocked policy', async () => {
411
+ it('returns observer when API key env is set with pi-ai profile', async () => {
339
412
  const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-resolve-'));
340
413
  const stateDir = path.join(workspaceDir, '.state');
341
414
  fs.mkdirSync(stateDir, { recursive: true });
@@ -348,7 +421,7 @@ describe('resolveCorrectionObserver — Configuration Resolution', () => {
348
421
  const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
349
422
  const result = resolveCorrectionObserver(wctx, logger as any);
350
423
 
351
- // With mocked WorkflowFunnelLoader returning valid policy, should return observer
424
+ // With mocked resolveObserverConfig returning enabled + not_ready, should return observer
352
425
  expect(result).not.toBeNull();
353
426
  } finally {
354
427
  delete process.env.ANTHROPIC_API_KEY;
@@ -356,23 +429,76 @@ describe('resolveCorrectionObserver — Configuration Resolution', () => {
356
429
  }
357
430
  });
358
431
 
359
- it('returns observer when workflows.yaml provides valid policy', async () => {
360
- const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-policy-'));
432
+ it('returns null when observer is disabled in config', async () => {
433
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-disabled-'));
361
434
  const stateDir = path.join(workspaceDir, '.state');
362
435
  fs.mkdirSync(stateDir, { recursive: true });
363
436
 
364
- process.env.ANTHROPIC_API_KEY = 'test-key';
437
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
438
+
439
+ // Override the mock to return disabled
440
+ const { resolveObserverConfig } = await import('../../src/core/pd-config-loader.js');
441
+ vi.mocked(resolveObserverConfig).mockReturnValueOnce({
442
+ enabled: false,
443
+ readiness: 'disabled',
444
+ source: 'defaults',
445
+ reason: 'correction_observer is disabled in .pd/config.yaml',
446
+ nextAction: 'Set features.correction_observer.enabled=true in .pd/config.yaml to enable',
447
+ runtimeProfileId: null,
448
+ runtimeProfileType: null,
449
+ apiKeyEnv: null,
450
+ apiKeyPresent: false,
451
+ provider: null,
452
+ model: null,
453
+ timeoutMs: null,
454
+ baseUrl: null,
455
+ });
456
+
457
+ try {
458
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
459
+ const result = resolveCorrectionObserver(wctx, logger as any);
460
+
461
+ expect(result).toBeNull();
462
+ } finally {
463
+ safeRmDir(workspaceDir);
464
+ }
465
+ });
466
+
467
+ it('returns null when observer needs setup (no API key)', async () => {
468
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-needs-setup-'));
469
+ const stateDir = path.join(workspaceDir, '.state');
470
+ fs.mkdirSync(stateDir, { recursive: true });
365
471
 
366
472
  const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
367
473
 
474
+ // Override the mock to return needs_setup
475
+ const { resolveObserverConfig } = await import('../../src/core/pd-config-loader.js');
476
+ vi.mocked(resolveObserverConfig).mockReturnValueOnce({
477
+ enabled: true,
478
+ readiness: 'needs_setup',
479
+ source: 'defaults',
480
+ reason: "Environment variable 'ANTHROPIC_API_KEY' is not set or empty",
481
+ nextAction: 'Set the environment variable ANTHROPIC_API_KEY with a valid API key',
482
+ runtimeProfileId: 'pd.anthropic-sonnet',
483
+ runtimeProfileType: 'pi-ai',
484
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
485
+ apiKeyPresent: false,
486
+ provider: 'anthropic',
487
+ model: 'claude-3-5-sonnet',
488
+ timeoutMs: 30000,
489
+ baseUrl: null,
490
+ });
491
+
368
492
  try {
369
493
  const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
370
494
  const result = resolveCorrectionObserver(wctx, logger as any);
371
495
 
372
- // With mocked WorkflowFunnelLoader returning valid policy, should return observer
373
- expect(result).not.toBeNull();
496
+ expect(result).toBeNull();
497
+ // Should log the needs_setup reason, not noisy "no API key" cycling
498
+ expect(logger.info).toHaveBeenCalledWith(
499
+ expect.stringContaining('ANTHROPIC_API_KEY')
500
+ );
374
501
  } finally {
375
- delete process.env.ANTHROPIC_API_KEY;
376
502
  safeRmDir(workspaceDir);
377
503
  }
378
504
  });
@@ -14,7 +14,7 @@ describe('Correction Observer Ownership — Feature Flag & Surface Registry Cons
14
14
  const result = computeEffectiveFlags(
15
15
  { correction_observer: { enabled: false } },
16
16
  DEFAULT_FEATURE_FLAGS,
17
- '.pd/feature-flags.yaml',
17
+ '.pd/config.yaml',
18
18
  );
19
19
  expect(result.flags['correction_observer'].enabled).toBe(false);
20
20
  expect(result.warnings).not.toContain(expect.stringContaining('core flag cannot be disabled'));