principles-disciple 1.61.0 → 1.63.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,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { EventLogService, EventLog } from '../../src/core/event-log.js';
3
- import type { DailyStats, DeepReflectionEventData } from '../../src/types/event-types.js';
3
+ import type { DailyStats, DeepReflectionEventData, DiagnosticianReportEventData } from '../../src/types/event-types.js';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import * as os from 'os';
@@ -252,5 +252,60 @@ describe('EventLog', () => {
252
252
  expect(stats.pain.avgScore).toBe(60); // (50+70+60)/3 = 60
253
253
  expect(stats.pain.maxScore).toBe(70);
254
254
  });
255
+
256
+ // PD-FUNNEL-1.2: Legacy backward compat — old events with { success: boolean } shape
257
+ // Stats are loaded from daily-stats.json (not re-read from JSONL), so we
258
+ // populate the stats cache directly by writing to daily-stats.json and
259
+ // creating a new EventLog instance that loads it via loadStats().
260
+ it('should count legacy success:true events in diagnosticianReportsWritten', () => {
261
+ const today = new Date().toISOString().slice(0, 10);
262
+ // Build a legacy daily-stats.json entry: old format had no category on
263
+ // diagnostician_report, and success:true meant it counted as written.
264
+ // statsFile lives at {tempDir}/logs/daily-stats.json (see EventLog constructor).
265
+ const statsFile = path.join(tempDir, 'logs', 'daily-stats.json');
266
+ fs.mkdirSync(path.dirname(statsFile), { recursive: true });
267
+ const legacyDailyStats = JSON.stringify({
268
+ [today]: {
269
+ date: today,
270
+ createdAt: new Date().toISOString(),
271
+ updatedAt: new Date().toISOString(),
272
+ tools: { total: 0, success: 0, failure: 0 },
273
+ pain: { signalsDetected: 0, avgScore: 0, maxScore: 0, signalsBySource: {} },
274
+ empathy: { totalEvents: 0, dedupedCount: 0, dedupeHitRate: 0, rolledBackScore: 0, rollbackCount: 0, bySeverity: { mild: 0, moderate: 0, severe: 0 }, scoreBySeverity: { mild: 0, moderate: 0, severe: 0 }, byDetectionMode: { structured: 0, legacy_tag: 0 }, byOrigin: { assistant_self_report: 0, user_manual: 0, system_infer: 0 }, confidenceDistribution: { high: 0, medium: 0, low: 0 }, dailyTrend: [] },
275
+ hooks: { total: 0, success: 0, failure: 0, byType: {} },
276
+ evolution: {
277
+ diagnosisTasksWritten: 0, heartbeatsInjected: 0,
278
+ diagnosticianReportsWritten: 1, // legacy success:true counted here
279
+ reportsMissingJson: 0, reportsIncompleteFields: 0,
280
+ principleCandidatesCreated: 0, rulesEnforced: 0,
281
+ nocturnalDreamerCompleted: 0, nocturnalArtifactPersisted: 0,
282
+ nocturnalCodeCandidateCreated: 0, rulehostEvaluated: 0,
283
+ rulehostBlocked: 0, rulehostRequireApproval: 0,
284
+ },
285
+ },
286
+ }, null, 2);
287
+ fs.writeFileSync(statsFile, legacyDailyStats, 'utf8');
288
+
289
+ // Create new EventLog instance so it loads the legacy stats via loadStats()
290
+ const reloaded = new EventLog(tempDir);
291
+ const stats = reloaded.getDailyStats(today);
292
+ expect(stats.evolution.diagnosticianReportsWritten).toBe(1);
293
+ });
294
+
295
+ it('should count incomplete_fields in both diagnosticianReportsWritten and reportsIncompleteFields', () => {
296
+ const today = new Date().toISOString().slice(0, 10);
297
+ eventLog.recordDiagnosticianReport({
298
+ taskId: 'task-incomplete',
299
+ reportPath: '/test/incomplete.json',
300
+ category: 'incomplete_fields',
301
+ });
302
+ eventLog.flush();
303
+
304
+ const stats = eventLog.getDailyStats(today);
305
+ expect(stats.evolution.diagnosticianReportsWritten).toBe(1);
306
+ expect(stats.evolution.reportsIncompleteFields).toBe(1);
307
+ // Other sub-counters should not be set
308
+ expect(stats.evolution.reportsMissingJson).toBe(0);
309
+ });
255
310
  });
256
311
  });
@@ -1,385 +1,230 @@
1
1
  /**
2
- * Gate Rule Host Pipeline Integration Tests
2
+ * Gate Rule Host Only - Pipeline Integration Tests
3
3
  *
4
- * PURPOSE: Verify that the Rule Host is correctly wired into the gate chain
5
- * between GFI and Progressive Gate, with correct ordering and behavior.
4
+ * PURPOSE: Verify gate.ts with Rule Host Only (no hardcoded gates).
6
5
  *
7
6
  * Tests:
8
- * 1. When GFI blocks, Rule Host is never called
9
- * 2. When Rule Host blocks, Progressive Gate is never called
10
- * 3. When Rule Host returns undefined (no active implementations), Progressive Gate runs normally
11
- * 4. When Rule Host throws, gate continues to Progressive Gate (D-08)
12
- * 5. Block result uses blockSource='rule-host'
13
- * 6. Existing gate flow still works when no active implementations exist
7
+ * 1. Rule Host blocks operation block result with blockSource='rule-host'
8
+ * 2. Rule Host allow (no match) operation passes
9
+ * 3. Rule Host throws degrades conservatively, allows operation
10
+ * 4. Rule Host requireApproval records event, does not block
11
+ * 5. Non-target tools (read) → pass through early
14
12
  */
15
13
 
16
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
15
  import { handleBeforeToolCall } from '../../src/hooks/gate.js';
18
- import * as fs from 'fs';
19
- import * as path from 'path';
20
- import { WorkspaceContext } from '../../src/core/workspace-context.js';
21
16
  import * as sessionTracker from '../../src/core/session-tracker.js';
22
17
  import * as evolutionEngine from '../../src/core/evolution-engine.js';
23
18
 
24
- // Mock fs
25
- vi.mock('fs');
19
+ const workspaceDir = '/mock/workspace';
20
+ const sessionId = 'test-session-rh';
26
21
 
27
- // Mock workspace context
28
- vi.mock('../../src/core/workspace-context.js');
22
+ const mockEvolution = {
23
+ getTier: vi.fn().mockReturnValue(3),
24
+ getPoints: vi.fn().mockReturnValue(200),
25
+ };
29
26
 
30
- // Mock session tracker
31
27
  vi.mock('../../src/core/session-tracker.js', () => ({
32
28
  getSession: vi.fn(() => ({ currentGfi: 0 })),
33
29
  trackBlock: vi.fn(),
34
30
  hasRecentThinking: vi.fn(() => false),
35
31
  }));
36
32
 
37
- // Mock evolution engine
38
- vi.mock('../../src/core/evolution-engine.js', async () => {
39
- const actual = await vi.importActual('../../src/core/evolution-engine.js');
40
- return {
41
- ...actual,
42
- checkEvolutionGate: vi.fn(() => ({ allowed: true, currentTier: 'SEED' })),
43
- getEvolutionEngine: vi.fn(),
44
- };
45
- });
33
+ vi.mock('../../src/core/evolution-engine.js', () => ({
34
+ getEvolutionEngine: vi.fn(() => mockEvolution),
35
+ }));
46
36
 
47
- // Mock Rule Host module — controls RuleHost.evaluate behavior
48
- // Use a shared mutable evaluate mock that tests can override
49
- let _mockEvaluate: ReturnType<typeof vi.fn> = vi.fn().mockReturnValue(undefined);
37
+ const mockEventLogInstance = {
38
+ recordRuleHostEvaluated: vi.fn(),
39
+ recordRuleEnforced: vi.fn(),
40
+ recordRuleHostBlocked: vi.fn(),
41
+ recordRuleHostRequireApproval: vi.fn(),
42
+ };
43
+ vi.mock('../../src/core/event-log.js', () => ({
44
+ EventLogService: { get: vi.fn(() => mockEventLogInstance) },
45
+ }));
50
46
 
51
- vi.mock('../../src/core/rule-host.js', () => {
52
- return {
53
- RuleHost: vi.fn(function(this: any, _stateDir: string) {
54
- this.evaluate = _mockEvaluate;
55
- }),
56
- };
57
- });
47
+ let _mockEvaluate = vi.fn().mockReturnValue(undefined);
48
+ vi.mock('../../src/core/rule-host.js', () => ({
49
+ RuleHost: vi.fn(function(this: any, _stateDir: string, _logger: any) {
50
+ this.evaluate = _mockEvaluate;
51
+ }),
52
+ }));
58
53
 
59
- // Mock ledger to avoid file reads
60
54
  vi.mock('../../src/core/principle-tree-ledger.js', () => ({
61
55
  loadLedger: vi.fn(),
62
56
  listImplementationsByLifecycleState: vi.fn(() => []),
63
57
  }));
64
58
 
65
- import { RuleHost } from '../../src/core/rule-host.js';
66
- import * as sessionTrackerModule from '../../src/core/session-tracker.js';
67
- import * as evolutionEngineModule from '../../src/core/evolution-engine.js';
68
-
69
- const MockedRuleHost = vi.mocked(RuleHost);
70
-
71
- const mockEvolution = {
72
- getTier: vi.fn().mockReturnValue(3),
73
- getPoints: vi.fn().mockReturnValue(200),
74
- };
75
-
76
- describe('Gate Rule Host Pipeline Integration', () => {
77
- const workspaceDir = '/mock/workspace';
78
- const sessionId = 'test-session-rh';
79
-
80
- const mockConfig = {
81
- get: vi.fn().mockImplementation((key: string) => {
82
- if (key === 'trust') return {
83
- limits: { stage_2_max_lines: 50, stage_3_max_lines: 300 }
84
- };
85
- if (key === 'gfi_gate') return {
86
- enabled: true,
87
- thresholds: { low_risk_block: 70, high_risk_block: 40 },
88
- bash_safe_patterns: ['^(ls|dir|pwd)$'],
89
- bash_dangerous_patterns: ['rm\\s+-rf'],
90
- };
91
- return undefined;
92
- })
93
- };
94
-
95
- const mockEventLog = {
96
- recordGateBlock: vi.fn(),
97
- recordPlanApproval: vi.fn(),
98
- recordGateBypass: vi.fn(),
99
- };
100
-
101
- const mockTrajectory = {
102
- recordGateBlock: vi.fn(),
103
- };
104
-
105
- const mockWctx = {
106
- workspaceDir,
107
- stateDir: '/mock/state',
108
- config: mockConfig,
109
- eventLog: mockEventLog,
110
- trajectory: mockTrajectory,
111
- evolution: mockEvolution,
112
- resolve: vi.fn().mockImplementation((key: string) => {
113
- if (key === 'PROFILE') return path.join(workspaceDir, '.principles', 'PROFILE.json');
114
- if (key === 'PLAN') return path.join(workspaceDir, 'PLAN.md');
115
- if (key === 'STATE_DIR') return path.join(workspaceDir, '.state');
116
- if (typeof key === 'string' && !key.includes(':')) {
117
- return path.join(workspaceDir, key);
118
- }
119
- return key;
120
- }),
121
- };
122
-
59
+ describe('Gate Rule Host Only Pipeline', () => {
123
60
  beforeEach(() => {
124
61
  vi.clearAllMocks();
125
- vi.useFakeTimers();
126
- // Reset the shared evaluate mock to default (returns undefined)
127
62
  _mockEvaluate = vi.fn().mockReturnValue(undefined);
128
- MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
129
- this.evaluate = _mockEvaluate;
130
- });
131
- vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue(mockWctx as any);
132
- vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 0 } as any);
133
- vi.mocked(sessionTrackerModule.trackBlock).mockImplementation(() => {});
134
- vi.mocked(evolutionEngineModule.getEvolutionEngine).mockReturnValue(mockEvolution);
135
63
  });
136
64
 
137
- afterEach(() => {
138
- vi.useRealTimers();
139
- });
65
+ describe('Rule Host blocks', () => {
66
+ it('should block with blockSource=rule-host when Rule Host returns block', () => {
67
+ _mockEvaluate = vi.fn().mockReturnValue({
68
+ decision: 'block',
69
+ matched: true,
70
+ reason: 'Dangerous git force-push detected',
71
+ ruleId: 'R_001',
72
+ principleId: 'P_001',
73
+ });
74
+
75
+ const event = {
76
+ toolName: 'bash',
77
+ params: { command: 'git push --force' },
78
+ };
140
79
 
141
- /**
142
- * Helper: create a standard write event
143
- */
144
- function makeWriteEvent(overrides?: Partial<any>) {
145
- return {
146
- toolName: 'write',
147
- params: {
148
- file_path: 'src/test.ts',
149
- content: 'const x = 1;',
150
- },
151
- ...overrides,
152
- };
153
- }
154
-
155
- /**
156
- * Helper: set up fs mocks for a profile with progressive gate enabled
157
- */
158
- function setupProfileMock(profileOverrides?: Record<string, unknown>) {
159
- vi.mocked(fs.existsSync).mockImplementation((p: any) => {
160
- if (typeof p === 'string' && p.includes('PROFILE.json')) return true;
161
- return false;
162
- });
163
- vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
164
- if (typeof p === 'string' && p.includes('PROFILE.json')) {
165
- return JSON.stringify({
166
- risk_paths: [],
167
- progressive_gate: { enabled: true },
168
- edit_verification: { enabled: true },
169
- ...profileOverrides,
170
- });
171
- }
172
- return '';
173
- });
174
- }
175
-
176
- // ═══════════════════════════════════════════════════════════════════════════
177
- // TEST 1: When GFI blocks, Rule Host is never called
178
- // ═══════════════════════════════════════════════════════════════════════════
179
- it('should not call Rule Host when GFI gate blocks', () => {
180
- // Set high GFI to trigger GFI block
181
- vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 85 } as any);
182
- setupProfileMock();
183
-
184
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
185
-
186
- // GFI should block
187
- expect(result).toBeDefined();
188
- expect(result?.block).toBe(true);
189
- expect(result?.blockReason).toContain('GFI');
190
-
191
- // RuleHost constructor should NOT have been called
192
- // (GFI returns before reaching Rule Host evaluation)
193
- expect(MockedRuleHost).not.toHaveBeenCalled();
194
- });
80
+ const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
195
81
 
196
- // ═══════════════════════════════════════════════════════════════════════════
197
- // TEST 2: When Rule Host blocks, Progressive Gate is never called
198
- // ═══════════════════════════════════════════════════════════════════════════
199
- it('should not reach Progressive Gate when Rule Host blocks', () => {
200
- setupProfileMock();
201
-
202
- // Mock RuleHost.evaluate to return a block
203
- _mockEvaluate = vi.fn().mockReturnValue({
204
- decision: 'block',
205
- matched: true,
206
- reason: 'Rule Host test block',
207
- });
208
- MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
209
- this.evaluate = _mockEvaluate;
82
+ expect(result).toBeDefined();
83
+ expect(result?.block).toBe(true);
84
+ expect(result?.blockReason).toContain('Dangerous git force-push detected');
210
85
  });
211
86
 
212
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
87
+ it('should record rulehost_blocked event when Rule Host blocks', () => {
88
+ _mockEvaluate = vi.fn().mockReturnValue({
89
+ decision: 'block',
90
+ matched: true,
91
+ reason: 'High-risk path',
92
+ ruleId: 'R_002',
93
+ });
94
+
95
+ const event = {
96
+ toolName: 'write',
97
+ params: { file_path: 'src/danger.ts', content: 'bad' },
98
+ };
213
99
 
214
- // Rule Host should block
215
- expect(result).toBeDefined();
216
- expect(result?.block).toBe(true);
100
+ handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
217
101
 
218
- // The block should come from rule-host
219
- expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
220
- sessionId,
221
- expect.objectContaining({ blockSource: 'rule-host' })
222
- );
102
+ expect(mockEventLogInstance.recordRuleHostBlocked).toHaveBeenCalledWith(
103
+ expect.objectContaining({
104
+ toolName: 'write',
105
+ ruleId: 'R_002',
106
+ })
107
+ );
108
+ });
223
109
  });
224
110
 
225
- // ═══════════════════════════════════════════════════════════════════════════
226
- // TEST 3: When Rule Host returns undefined, Progressive Gate runs normally
227
- // ═══════════════════════════════════════════════════════════════════════════
228
- it('should continue to Progressive Gate when Rule Host returns undefined', () => {
229
- setupProfileMock();
111
+ describe('Rule Host allows', () => {
112
+ it('should allow when Rule Host returns undefined (no match)', () => {
113
+ _mockEvaluate = vi.fn().mockReturnValue(undefined);
230
114
 
231
- // Mock RuleHost.evaluate to return undefined (no active implementations)
232
- _mockEvaluate = vi.fn().mockReturnValue(undefined);
233
- MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
234
- this.evaluate = _mockEvaluate;
235
- });
115
+ const event = {
116
+ toolName: 'write',
117
+ params: { file_path: 'src/safe.ts', content: 'const x = 1' },
118
+ };
236
119
 
237
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
120
+ const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
238
121
 
239
- // Should pass through (no block from any gate)
240
- expect(result).toBeUndefined();
122
+ expect(result).toBeUndefined();
123
+ });
241
124
 
242
- // Rule Host was called
243
- expect(_mockEvaluate).toHaveBeenCalled();
244
- });
125
+ it('should record rulehost_evaluated even when no match', () => {
126
+ _mockEvaluate = vi.fn().mockReturnValue(undefined);
245
127
 
246
- // ═══════════════════════════════════════════════════════════════════════════
247
- // TEST 4: When Rule Host throws, gate continues to Progressive Gate (D-08)
248
- // ═══════════════════════════════════════════════════════════════════════════
249
- it('should continue to Progressive Gate when Rule Host throws (D-08)', () => {
250
- setupProfileMock();
128
+ const event = {
129
+ toolName: 'edit',
130
+ params: { file_path: 'src/config.ts', oldText: 'x', newText: 'y' },
131
+ };
251
132
 
252
- // Mock RuleHost.evaluate to throw
253
- _mockEvaluate = vi.fn().mockImplementation(() => {
254
- throw new Error('Host internal error');
255
- });
256
- MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
257
- this.evaluate = _mockEvaluate;
133
+ handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
134
+
135
+ expect(mockEventLogInstance.recordRuleHostEvaluated).toHaveBeenCalledWith(
136
+ expect.objectContaining({
137
+ toolName: 'edit',
138
+ matched: false,
139
+ decision: 'allow',
140
+ })
141
+ );
258
142
  });
143
+ });
259
144
 
260
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
145
+ describe('Rule Host degradation', () => {
146
+ it('should allow operation when Rule Host throws (conservative degradation)', () => {
147
+ _mockEvaluate = vi.fn().mockImplementation(() => {
148
+ throw new Error('Host internal error');
149
+ });
261
150
 
262
- // Should pass through (host error is caught, degrades to Progressive Gate)
263
- // Progressive Gate will pass for this low-risk operation
264
- expect(result).toBeUndefined();
265
- });
151
+ const event = {
152
+ toolName: 'bash',
153
+ params: { command: 'ls -la' },
154
+ };
266
155
 
267
- // ═══════════════════════════════════════════════════════════════════════════
268
- // TEST 5: Block result uses blockSource='rule-host'
269
- // ═══════════════════════════════════════════════════════════════════════════
270
- it('should use blockSource=rule-host for Rule Host blocks', () => {
271
- setupProfileMock();
156
+ const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
272
157
 
273
- _mockEvaluate = vi.fn().mockReturnValue({
274
- decision: 'block',
275
- matched: true,
276
- reason: 'Dangerous file modification',
277
- });
278
- MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
279
- this.evaluate = _mockEvaluate;
158
+ expect(result).toBeUndefined();
280
159
  });
160
+ });
281
161
 
282
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
162
+ describe('Rule Host requireApproval', () => {
163
+ it('should not block when Rule Host returns requireApproval', () => {
164
+ _mockEvaluate = vi.fn().mockReturnValue({
165
+ decision: 'requireApproval',
166
+ matched: true,
167
+ reason: 'High-risk operation needs approval',
168
+ ruleId: 'R_003',
169
+ });
170
+
171
+ const event = {
172
+ toolName: 'bash',
173
+ params: { command: 'rm -rf node_modules' },
174
+ };
283
175
 
284
- expect(result?.block).toBe(true);
176
+ const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
285
177
 
286
- // Verify recordGateBlockAndReturn was called with blockSource='rule-host'
287
- expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
288
- sessionId,
289
- expect.objectContaining({
290
- blockSource: 'rule-host',
291
- reason: 'Dangerous file modification',
292
- })
293
- );
178
+ expect(result).toBeUndefined();
179
+ expect(mockEventLogInstance.recordRuleEnforced).toHaveBeenCalledWith(
180
+ expect.objectContaining({ enforcement: 'requireApproval' })
181
+ );
182
+ });
294
183
  });
295
184
 
296
- // ═══════════════════════════════════════════════════════════════════════════
297
- // TEST 5b: requireApproval result uses blockSource='rule-host' with reason
298
- // ═══════════════════════════════════════════════════════════════════════════
299
- it('should use blockSource=rule-host with approval prefix for requireApproval', () => {
300
- setupProfileMock();
301
-
302
- _mockEvaluate = vi.fn().mockReturnValue({
303
- decision: 'requireApproval',
304
- matched: true,
305
- reason: 'High-risk path requires approval',
306
- });
307
- MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
308
- this.evaluate = _mockEvaluate;
309
- });
185
+ describe('Early return for non-target tools', () => {
186
+ it('should allow read tool without calling Rule Host', () => {
187
+ const event = {
188
+ toolName: 'read',
189
+ params: { file_path: 'src/readonly.ts' },
190
+ };
310
191
 
311
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
192
+ const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
312
193
 
313
- expect(result?.block).toBe(true);
314
- expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
315
- sessionId,
316
- expect.objectContaining({
317
- blockSource: 'rule-host',
318
- reason: expect.stringContaining('[Rule Host] Approval required'),
319
- })
320
- );
321
- });
194
+ expect(result).toBeUndefined();
195
+ expect(_mockEvaluate).not.toHaveBeenCalled();
196
+ });
322
197
 
323
- // ═══════════════════════════════════════════════════════════════════════════
324
- // TEST 6: Existing gate flow still works when no active implementations exist
325
- // ═══════════════════════════════════════════════════════════════════════════
326
- it('should allow operation through existing gate flow with no active implementations', () => {
327
- setupProfileMock();
198
+ it('should allow agent tool without calling Rule Host when no workspace', () => {
199
+ const event = {
200
+ toolName: 'agent',
201
+ params: { task: 'do something' },
202
+ };
328
203
 
329
- // Default mock: RuleHost.evaluate returns undefined (already set in beforeEach)
330
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
204
+ const result = handleBeforeToolCall(event as any, { sessionId } as any);
331
205
 
332
- // Should pass all gates — no block
333
- expect(result).toBeUndefined();
206
+ expect(result).toBeUndefined();
207
+ expect(_mockEvaluate).not.toHaveBeenCalled();
208
+ });
334
209
  });
335
210
 
336
- it('should block with GFI even when Rule Host would allow', () => {
337
- // High GFI triggers GFI block
338
- vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 85 } as any);
339
- setupProfileMock();
211
+ describe('Session GFI context', () => {
212
+ it('should pass current GFI to Rule Host', () => {
213
+ _mockEvaluate = vi.fn().mockReturnValue(undefined);
214
+ vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 75 } as any);
340
215
 
341
- // Even if RuleHost would allow, GFI blocks first
342
- const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
216
+ const event = {
217
+ toolName: 'write',
218
+ params: { file_path: 'src/test.ts', content: 'x' },
219
+ };
343
220
 
344
- expect(result?.block).toBe(true);
345
- expect(result?.blockReason).toContain('GFI');
346
- });
221
+ handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
347
222
 
348
- it('should allow edit when oldText matches (full pipeline with Rule Host)', () => {
349
- const fileContent = 'const x = 1;\n';
350
- const editEvent = {
351
- toolName: 'edit',
352
- params: {
353
- file_path: 'src/example.ts',
354
- oldText: 'const x = 1;',
355
- newText: 'const x = 2;',
356
- },
357
- };
358
-
359
- setupProfileMock();
360
-
361
- vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
362
- if (typeof p === 'string' && p.includes('PROFILE.json')) {
363
- return JSON.stringify({
364
- risk_paths: [],
365
- progressive_gate: { enabled: true },
366
- edit_verification: { enabled: true },
367
- });
368
- }
369
- if (typeof p === 'string' && p.includes('example.ts')) {
370
- return fileContent;
371
- }
372
- return '';
223
+ expect(_mockEvaluate).toHaveBeenCalledWith(
224
+ expect.objectContaining({
225
+ session: expect.objectContaining({ currentGfi: 75 }),
226
+ })
227
+ );
373
228
  });
374
- vi.mocked(fs.statSync).mockReturnValue({ size: 1000 } as any);
375
- vi.mocked(fs.existsSync).mockImplementation((p: any) => {
376
- if (typeof p === 'string' && p.includes('PROFILE.json')) return true;
377
- if (typeof p === 'string' && p.includes('example.ts')) return true;
378
- return false;
379
- });
380
-
381
- const result = handleBeforeToolCall(editEvent as any, { workspaceDir, sessionId } as any);
382
-
383
- expect(result).toBeUndefined();
384
229
  });
385
230
  });
@@ -70,7 +70,11 @@ const noopLogger: PluginLogger = {
70
70
  afterEach(() => {
71
71
  vi.restoreAllMocks();
72
72
  for (const dir of tempDirs.splice(0)) {
73
- fs.rmSync(dir, { recursive: true, force: true });
73
+ try {
74
+ fs.rmSync(dir, { recursive: true, force: true });
75
+ } catch {
76
+ // On Windows, temp dirs may be held open — ignore cleanup errors
77
+ }
74
78
  }
75
79
  });
76
80