principles-disciple 1.92.0 → 1.93.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.92.0",
5
+ "version": "1.93.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.92.0",
3
+ "version": "1.93.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -10,11 +10,17 @@
10
10
  * had their own block persistence implementations.
11
11
  */
12
12
 
13
+ import * as fs from 'fs';
13
14
  import { getSession, trackBlock } from '../core/session-tracker.js';
14
15
  import type { WorkspaceContext } from '../core/workspace-context.js';
15
16
  import type { PluginHookBeforeToolCallResult } from '../openclaw-sdk.js';
16
17
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
17
18
  import { emitPainDetectedEvent } from './pain.js';
19
+ import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
20
+ import { evaluateEvidenceTriage } from './triage-adapter.js';
21
+ import { isRisky } from '../utils/io.js';
22
+ import { normalizeProfile } from '../core/profile.js';
23
+ import { SystemLogger } from '../core/system-logger.js';
18
24
  import {
19
25
  TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS,
20
26
  TRAJECTORY_GATE_BLOCK_MAX_RETRIES
@@ -114,38 +120,75 @@ export function recordGateBlockAndReturn(
114
120
  origin: 'system_infer',
115
121
  });
116
122
 
117
- const session = getSession(sessionId);
118
- const gate = evaluatePainDiagnosticGate({
119
- source: 'gate_blocked',
120
- score: GATE_BLOCK_PAIN_SCORE,
121
- currentGfi: session?.currentGfi ?? 0,
122
- consecutiveErrors: session?.consecutiveErrors ?? 0,
123
- sessionId,
124
- errorHash: `${toolName}:${filePath}:${reason}`,
125
- thresholds: {
126
- painTrigger: wctx.config.get('thresholds.pain_trigger') || 40,
127
- highSeverity: wctx.config.get('severity_thresholds.high') || 70,
128
- },
129
- });
123
+ // PEAT-B1: Evidence triage (feature-flagged)
124
+ let triageAdmitted = true;
125
+ const gateBlockTriageFlag = loadFeatureFlagFromConfig(wctx.workspaceDir, 'painEvidenceAdmission');
126
+ if (gateBlockTriageFlag.enabled) {
127
+ // Load profile with 1MB size guard, matching pain.ts pattern
128
+ const profilePath = wctx.resolve('PROFILE');
129
+ let profile = normalizeProfile({});
130
+ if (fs.existsSync(profilePath)) {
131
+ try {
132
+ const content = fs.readFileSync(profilePath, 'utf8');
133
+ if (content.length > 1024 * 1024) {
134
+ logger.warn?.('[PD_GATE] PROFILE.json exceeds 1 MB, skipping');
135
+ SystemLogger.log(wctx.workspaceDir, 'PROFILE_PARSE_WARN', 'PROFILE.json exceeds 1 MB, skipping — fallback to non-risky');
136
+ } else {
137
+ profile = normalizeProfile(JSON.parse(content));
138
+ }
139
+ } catch (e) {
140
+ logger.warn?.(`[PD_GATE] Failed to parse PROFILE.json: ${String(e)}`);
141
+ SystemLogger.log(wctx.workspaceDir, 'PROFILE_PARSE_WARN', `Failed to parse PROFILE.json: ${String(e)} — fallback to non-risky`);
142
+ }
143
+ }
144
+ // Real judgment for rulehost blocks: if a principle blocked an action on a risky path,
145
+ // it IS a high-confidence unsafe action. The pain score (45) is the evidence friction
146
+ // weight, NOT the action risk severity. The rulehost principle already determined
147
+ // this action was important enough to block — that is the real signal.
148
+ const isUnsafe = isRisky(filePath, profile.risk_paths);
149
+ const triage = evaluateEvidenceTriage('rulehost_block', GATE_BLOCK_PAIN_SCORE, {
150
+ isUnsafeHighConfidence: isUnsafe,
151
+ });
152
+ if (triage.decision !== 'admit') {
153
+ triageAdmitted = false;
154
+ logger.info?.(`[PD_GATE] Triage ${triage.decision}: ${triage.reason}`);
155
+ }
156
+ }
130
157
 
131
- if (gate.shouldDiagnose) {
132
- void emitPainDetectedEvent(wctx, {
133
- ts: new Date().toISOString(),
134
- type: 'pain_detected',
135
- data: {
136
- painId: `gate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
137
- painType: 'user_frustration',
138
- source: 'gate_blocked',
139
- reason: `Gate blocked ${toolName} on ${filePath}: ${reason}`,
140
- score: GATE_BLOCK_PAIN_SCORE,
141
- sessionId,
142
- agentId: 'main',
158
+ const session = getSession(sessionId);
159
+ if (triageAdmitted) {
160
+ const gate = evaluatePainDiagnosticGate({
161
+ source: 'gate_blocked',
162
+ score: GATE_BLOCK_PAIN_SCORE,
163
+ currentGfi: session?.currentGfi ?? 0,
164
+ consecutiveErrors: session?.consecutiveErrors ?? 0,
165
+ sessionId,
166
+ errorHash: `${toolName}:${filePath}:${reason}`,
167
+ thresholds: {
168
+ painTrigger: wctx.config.get('thresholds.pain_trigger') || 40,
169
+ highSeverity: wctx.config.get('severity_thresholds.high') || 70,
143
170
  },
144
- }).catch((emitErr) => {
145
- logWarn(`[PD_GATE] Failed to emit gate block pain event: ${String(emitErr)}`);
146
171
  });
147
- } else {
148
- logger.info?.(`[PD_GATE] Gate block recorded without Runtime V2 diagnosis: ${gate.detail}`);
172
+
173
+ if (gate.shouldDiagnose) {
174
+ void emitPainDetectedEvent(wctx, {
175
+ ts: new Date().toISOString(),
176
+ type: 'pain_detected',
177
+ data: {
178
+ painId: `gate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
179
+ painType: 'user_frustration',
180
+ source: 'gate_blocked',
181
+ reason: `Gate blocked ${toolName} on ${filePath}: ${reason}`,
182
+ score: GATE_BLOCK_PAIN_SCORE,
183
+ sessionId,
184
+ agentId: 'main',
185
+ },
186
+ }).catch((emitErr) => {
187
+ logWarn(`[PD_GATE] Failed to emit gate block pain event: ${String(emitErr)}`);
188
+ });
189
+ } else {
190
+ logger.info?.(`[PD_GATE] Gate block recorded without Runtime V2 diagnosis: ${gate.detail}`);
191
+ }
149
192
  }
150
193
  }
151
194
 
package/src/hooks/llm.ts CHANGED
@@ -11,6 +11,8 @@ import { sanitizeAssistantText } from './message-sanitize.js';
11
11
  import { atomicWriteFileSync } from '../utils/io.js';
12
12
  import { emitPainDetectedEvent, buildTrajectoryEvidence } from './pain.js';
13
13
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
14
+ import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
15
+ import { resolveSourceKindFromLlmDetection, evaluateEvidenceTriage } from './triage-adapter.js';
14
16
 
15
17
  export interface EmpathySignal {
16
18
  detected: boolean;
@@ -240,26 +242,26 @@ export function handleLlmOutput(
240
242
  // GFI-triggered pain: when accumulated friction crosses highGfi threshold,
241
243
  // emit pain signal even if L1 detection didn't fire.
242
244
  const highGfiThreshold = Math.max(config.get('severity_thresholds.high') || 70, painTriggerThreshold + 30);
245
+ let isGfiTriggered = false;
243
246
  if (state.currentGfi >= highGfiThreshold && painScore < painTriggerThreshold) {
244
247
  painScore = Math.min(state.currentGfi, 60);
245
248
  source = 'user_empathy';
249
+ isGfiTriggered = true;
246
250
  matchedReason = `Accumulated GFI (${state.currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Source: empathy keyword friction.`;
247
251
  }
248
252
 
249
253
  if (painScore >= painTriggerThreshold) {
250
- const gate = evaluatePainDiagnosticGate({
251
- source: source === 'llm_paralysis' ? 'llm_paralysis' : 'semantic',
252
- score: painScore,
253
- currentGfi: state.currentGfi,
254
- consecutiveErrors: state.consecutiveErrors,
255
- sessionId: ctx.sessionId || 'unknown',
256
- errorHash: source,
257
- thresholds: {
258
- painTrigger: painTriggerThreshold,
259
- highSeverity: config.get('severity_thresholds.high') || 70,
260
- semanticPain: Math.max(painTriggerThreshold, 60),
261
- },
262
- });
254
+ // PEAT-B1: Evidence triage (feature-flagged)
255
+ let triageAdmitted = true;
256
+ const llmTriageFlag = loadFeatureFlagFromConfig(ctx.workspaceDir!, 'painEvidenceAdmission');
257
+ if (llmTriageFlag.enabled) {
258
+ const sourceKind = resolveSourceKindFromLlmDetection(source, isGfiTriggered);
259
+ const triage = evaluateEvidenceTriage(sourceKind, painScore);
260
+ if (triage.decision !== 'admit') {
261
+ triageAdmitted = false;
262
+ ctx.logger?.info?.(`[PD:LLM] Triage ${triage.decision}: ${triage.reason}`);
263
+ }
264
+ }
263
265
 
264
266
  eventLog.recordPainSignal(ctx.sessionId, {
265
267
  score: painScore,
@@ -268,25 +270,43 @@ export function handleLlmOutput(
268
270
  isRisky: false
269
271
  });
270
272
 
271
- if (gate.shouldDiagnose) {
272
- const evidence = buildTrajectoryEvidence(wctx, ctx.sessionId || 'unknown');
273
- emitPainDetectedEvent(wctx, {
274
- ts: new Date().toISOString(),
275
- type: 'pain_detected',
276
- data: {
277
- painId: `llm_${Date.now()}`,
278
- painType: 'user_frustration' as const,
279
- source,
280
- reason: `${matchedReason}; diagnosticGate=${gate.reason}`,
281
- score: painScore,
282
- sessionId: ctx.sessionId || 'unknown',
283
- agentId: ctx.agentId,
284
- provenance: 'openclaw_context_bound',
285
- evidence,
273
+ if (triageAdmitted) {
274
+ const gate = evaluatePainDiagnosticGate({
275
+ source: source === 'llm_paralysis' ? 'llm_paralysis' : 'semantic',
276
+ score: painScore,
277
+ currentGfi: state.currentGfi,
278
+ consecutiveErrors: state.consecutiveErrors,
279
+ sessionId: ctx.sessionId || 'unknown',
280
+ errorHash: source,
281
+ thresholds: {
282
+ painTrigger: painTriggerThreshold,
283
+ highSeverity: config.get('severity_thresholds.high') || 70,
284
+ semanticPain: Math.max(painTriggerThreshold, 60),
286
285
  },
287
286
  });
287
+
288
+ if (gate.shouldDiagnose) {
289
+ const evidence = buildTrajectoryEvidence(wctx, ctx.sessionId || 'unknown');
290
+ emitPainDetectedEvent(wctx, {
291
+ ts: new Date().toISOString(),
292
+ type: 'pain_detected',
293
+ data: {
294
+ painId: `llm_${Date.now()}`,
295
+ painType: 'user_frustration' as const,
296
+ source,
297
+ reason: `${matchedReason}; diagnosticGate=${gate.reason}`,
298
+ score: painScore,
299
+ sessionId: ctx.sessionId || 'unknown',
300
+ agentId: ctx.agentId,
301
+ provenance: 'openclaw_context_bound',
302
+ evidence,
303
+ },
304
+ });
305
+ } else {
306
+ ctx.logger?.info?.(`[PD:LLM] Pain signal recorded without Runtime V2 diagnosis: ${gate.detail}`);
307
+ }
288
308
  } else {
289
- ctx.logger?.info?.(`[PD:LLM] Pain signal recorded without Runtime V2 diagnosis: ${gate.detail}`);
309
+ ctx.logger?.info?.(`[PD:LLM] Triage evidence-only: pain signal recorded without diagnosis or gate evaluation`);
290
310
  }
291
311
  }
292
312
 
package/src/hooks/pain.ts CHANGED
@@ -14,7 +14,8 @@ import { resolveWorkspaceDirForRuntimeV2 } from '../utils/workspace-resolver.js'
14
14
  import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData, type PainEvidenceEntry, MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_NOTE_CHARS } from '@principles/core/runtime-v2';
15
15
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
16
16
  import { sanitizeAssistantText, sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
17
- import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
17
+ import { loadPdConfigForPlugin, loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
18
+ import { resolveSourceKindFromToolFailure, evaluateEvidenceTriage } from './triage-adapter.js';
18
19
 
19
20
  /**
20
21
  * Interface for tool parameters to avoid 'any'
@@ -463,6 +464,25 @@ export function handleAfterToolCall(
463
464
  const isRisk = isRisky(relPath, profile.risk_paths);
464
465
  const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
465
466
  const traceId = createTraceId();
467
+
468
+ // PEAT-B1: Evidence triage (feature-flagged)
469
+ const painTriageFlag = loadFeatureFlagFromConfig(effectiveWorkspaceDir, 'painEvidenceAdmission');
470
+ if (painTriageFlag.enabled) {
471
+ const sourceKind = resolveSourceKindFromToolFailure(event.toolName, failureSource);
472
+ const triage = evaluateEvidenceTriage(sourceKind, painScore);
473
+ if (triage.decision !== 'admit') {
474
+ SystemLogger.log(effectiveWorkspaceDir, 'TRIAGE_EVIDENCE_ONLY', JSON.stringify({
475
+ sourceKind: triage.sourceKind,
476
+ decision: triage.decision,
477
+ reason: triage.reason,
478
+ nextAction: triage.nextAction,
479
+ tool: event.toolName,
480
+ path: relPath,
481
+ }));
482
+ return;
483
+ }
484
+ }
485
+
466
486
  const diagnosticGate = evaluatePainDiagnosticGate({
467
487
  source: failureSource,
468
488
  score: painScore,
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Triage Adapter — PEAT-B1
3
+ *
4
+ * Plugin-side adapter that maps OpenClaw hook context to evidence triage input.
5
+ * Calls the pure triage policy from principles-core.
6
+ *
7
+ * This file lives in openclaw-plugin because it:
8
+ * - Maps hook-specific context (source strings, session state) to SourceKind
9
+ * - Wraps evaluatePainDiagnosticGate as a compatibility sub-policy
10
+ * - Knows about OpenClaw hook conventions (sessionId, toolName, etc.)
11
+ *
12
+ * It does NOT expose evaluatePainDiagnosticGate to core.
13
+ * Core only sees SourceKind and TriageResult.
14
+ *
15
+ * ERR checklist:
16
+ * - ERR-001: Source kind derived from runtime values with guards, not `as` casts.
17
+ * - ERR-002: Every triage result carries reason + nextAction.
18
+ * - ERR-024/025/048: Production-path tests cover this adapter.
19
+ */
20
+
21
+ import {
22
+ evaluateTriage,
23
+ type TriageInput,
24
+ type TriageResult,
25
+ type SourceKind,
26
+ } from '@principles/core/runtime-v2';
27
+
28
+ // ── Source Kind Resolution ───────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Map after_tool_call hook context to SourceKind.
32
+ *
33
+ * Classifies based on:
34
+ * - toolName: 'pain' or 'skill:pain' → agent_on_owner_request
35
+ * - failureSource: 'dispatch_error' vs 'tool_failure'
36
+ * - isRisky + score: only used for rulehost_block upgrade, not for kind resolution
37
+ */
38
+ export function resolveSourceKindFromToolFailure(
39
+ toolName: string | undefined,
40
+ failureSource: 'tool_failure' | 'dispatch_error',
41
+ provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook',
42
+ ): SourceKind {
43
+ // Manual pain via agent tool call
44
+ if (toolName === 'pain' || toolName === 'skill:pain') {
45
+ return provenance === 'openclaw_context_bound' ? 'agent_on_owner_request' : 'owner_reported';
46
+ }
47
+
48
+ // Dispatch errors (tool not found, unknown tool)
49
+ if (failureSource === 'dispatch_error') {
50
+ return 'dispatch_error';
51
+ }
52
+
53
+ // Regular tool failure
54
+ return 'tool_failure';
55
+ }
56
+
57
+ /**
58
+ * Map empathy/semantic detection context to SourceKind.
59
+ *
60
+ * Classifies based on detection source prefix:
61
+ * - 'llm_paralysis' → llm_paralysis
62
+ * - 'llm_*' (detection rule) → semantic
63
+ * - 'user_empathy' or empathy keyword match → empathy_inferred
64
+ * - GFI threshold crossed → gfi_threshold
65
+ */
66
+ export function resolveSourceKindFromLlmDetection(
67
+ detectionSource: string,
68
+ isGfiTriggered: boolean,
69
+ ): SourceKind {
70
+ if (isGfiTriggered) return 'gfi_threshold';
71
+ if (detectionSource === 'llm_paralysis') return 'llm_paralysis';
72
+ if (detectionSource.startsWith('llm_')) return 'semantic';
73
+ if (detectionSource === 'user_empathy') return 'empathy_inferred';
74
+ return 'unknown';
75
+ }
76
+
77
+ /**
78
+ * Map gate-block context to SourceKind.
79
+ */
80
+ export function resolveSourceKindFromGateBlock(): SourceKind {
81
+ return 'rulehost_block';
82
+ }
83
+
84
+ /**
85
+ * Map /pd-pain command to SourceKind.
86
+ */
87
+ export function resolveSourceKindFromCommand(): SourceKind {
88
+ return 'owner_reported';
89
+ }
90
+
91
+ /**
92
+ * Map provider/rate-limit failure to SourceKind.
93
+ */
94
+ export function resolveSourceKindFromProvider(
95
+ isRateLimit: boolean,
96
+ ): SourceKind {
97
+ return isRateLimit ? 'rate_limit' : 'provider_failure';
98
+ }
99
+
100
+ /**
101
+ * Map subagent error to SourceKind.
102
+ */
103
+ export function resolveSourceKindFromSubagent(): SourceKind {
104
+ return 'subagent_error';
105
+ }
106
+
107
+ // ── Triage Evaluation ───────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Evaluate evidence triage for a given source kind and context.
111
+ *
112
+ * This is the main entry point for hooks. It calls the pure triage policy
113
+ * from principles-core and returns the result.
114
+ *
115
+ * The caller (hook) is responsible for:
116
+ * - Checking the painEvidenceAdmission feature flag
117
+ * - Acting on the triage result (proceed to diagnosis, store evidence, etc.)
118
+ * - Falling back to existing behavior when the flag is off
119
+ */
120
+ export function evaluateEvidenceTriage(
121
+ sourceKind: SourceKind,
122
+ score: number,
123
+ options?: {
124
+ isUnsafeHighConfidence?: boolean;
125
+ provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook';
126
+ },
127
+ ): TriageResult {
128
+ const input: TriageInput = {
129
+ sourceKind,
130
+ score,
131
+ isUnsafeHighConfidence: options?.isUnsafeHighConfidence,
132
+ provenance: options?.provenance,
133
+ };
134
+
135
+ return evaluateTriage(input);
136
+ }
137
+
138
+ // ── High-Confidence Unsafe Action Detection ──────────────────────────────────
139
+
140
+ /**
141
+ * Determine if a gate-blocked action is a high-confidence unsafe action.
142
+ *
143
+ * This is a heuristic that the plugin adapter owns. Core does not know about
144
+ * these heuristics — it only receives the boolean flag.
145
+ *
146
+ * Criteria for high-confidence unsafe:
147
+ * - Score >= 70 (high severity)
148
+ * - Tool is in the risky write set
149
+ * - Action would be irreversible (file deletion, force push, etc.)
150
+ */
151
+ export function isHighConfidenceUnsafeAction(
152
+ score: number,
153
+ isRisky: boolean,
154
+ ): boolean {
155
+ return isRisky && score >= 70;
156
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Gate Block Helper — PROFILE loading resilience tests
3
+ *
4
+ * Verifies that recordGateBlockAndReturn handles malformed/oversized PROFILE
5
+ * gracefully: try/catch, 1MB size guard, fallback to non-risky, no crash.
6
+ *
7
+ * ERR checklist:
8
+ * - ERR-026: All PROFILE loads have try/catch (gate-block-helper.ts matches pain.ts)
9
+ * - ERR-024/025: Production-path tests for the edge case
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import * as os from 'os';
16
+ import { WorkspaceContext } from '../../src/core/workspace-context.js';
17
+ import { EventLogService } from '../../src/core/event-log.js';
18
+ import { clearSession } from '../../src/core/session-tracker.js';
19
+ import { resetPainDiagnosticGateForTest } from '../../src/core/pain-diagnostic-gate.js';
20
+
21
+ vi.mock('fs');
22
+ vi.mock('../../src/utils/io.js', () => ({
23
+ isRisky: vi.fn(() => false),
24
+ }));
25
+ vi.mock('../../src/core/evolution-engine.js', () => ({
26
+ recordEvolutionSuccess: vi.fn(),
27
+ recordEvolutionFailure: vi.fn(),
28
+ }));
29
+ vi.mock('../../src/core/evolution-logger.js', () => ({
30
+ createTraceId: vi.fn(() => 'trace-123'),
31
+ getEvolutionLogger: vi.fn(() => ({
32
+ logPainDetected: vi.fn(),
33
+ })),
34
+ }));
35
+ vi.mock('../../src/core/pd-config-loader.js', () => ({
36
+ loadPdConfigForPlugin: vi.fn(() => ({ ok: true, source: 'mock', effective: {}, errors: [] })),
37
+ loadFeatureFlagFromConfig: vi.fn(() => ({ enabled: true, source: 'test' })),
38
+ }));
39
+
40
+ const mockEmitSync = vi.fn();
41
+ const mockRecordProbationFeedback = vi.fn();
42
+ const mockUpdatePrincipleValueMetrics = vi.fn();
43
+
44
+ function makeTestWctx(overrides: Record<string, unknown> = {}) {
45
+ return {
46
+ workspaceDir: '/mock/workspace',
47
+ stateDir: '/mock/state',
48
+ config: { get: vi.fn().mockReturnValue(40) },
49
+ eventLog: {
50
+ recordGateBlock: vi.fn(),
51
+ recordPainSignal: vi.fn(),
52
+ },
53
+ trajectory: {
54
+ recordGateBlock: vi.fn(),
55
+ recordPainEvent: vi.fn(),
56
+ recordToolCall: vi.fn(),
57
+ },
58
+ principleTreeLedger: {
59
+ updatePrincipleValueMetrics: mockUpdatePrincipleValueMetrics,
60
+ },
61
+ evolutionReducer: {
62
+ emitSync: mockEmitSync,
63
+ recordProbationFeedback: mockRecordProbationFeedback,
64
+ getPrincipleById: vi.fn(),
65
+ },
66
+ resolve: vi.fn().mockImplementation((key: string) => {
67
+ if (key === 'PROFILE') return '/mock/workspace/PROFILE.json';
68
+ return '';
69
+ }),
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ describe('Gate Block Helper — PROFILE Resilience', () => {
75
+ const sessionId = 's-profile-test';
76
+
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ mockEmitSync.mockReset();
80
+ mockRecordProbationFeedback.mockReset();
81
+ mockUpdatePrincipleValueMetrics.mockReset();
82
+ vi.spyOn(WorkspaceContext, 'fromHookContext').mockReturnValue(makeTestWctx() as any);
83
+ vi.spyOn(EventLogService, 'get').mockReturnValue({} as any);
84
+ clearSession(sessionId);
85
+ resetPainDiagnosticGateForTest();
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ it('malformed PROFILE.json does not throw, returns block result with non-risky fallback', async () => {
93
+ // Arrange: PROFILE exists but contains invalid JSON
94
+ vi.mocked(fs.existsSync).mockReturnValue(true);
95
+ vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
96
+
97
+ // Dynamic import AFTER mocks are set up
98
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
99
+
100
+ // Act & Assert: does NOT throw
101
+ const result = recordGateBlockAndReturn(
102
+ makeTestWctx() as any,
103
+ {
104
+ filePath: 'src/danger.ts',
105
+ reason: 'Test block reason',
106
+ toolName: 'write',
107
+ sessionId,
108
+ },
109
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
110
+ );
111
+
112
+ expect(result).toBeDefined();
113
+ expect(result.block).toBe(true);
114
+ expect(result.blockReason).toContain('Security Gate Blocked');
115
+ // verify emitPainDetectedEvent was NOT called (triage fell back to non-risky)
116
+ expect(mockEmitSync).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('oversized PROFILE (>1MB) falls back to non-risky without crash', async () => {
120
+ // Arrange: PROFILE > 1MB
121
+ vi.mocked(fs.existsSync).mockReturnValue(true);
122
+ vi.mocked(fs.readFileSync).mockReturnValue('x'.repeat(1024 * 1024 + 1));
123
+
124
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
125
+
126
+ const result = recordGateBlockAndReturn(
127
+ makeTestWctx() as any,
128
+ {
129
+ filePath: 'src/danger.ts',
130
+ reason: 'Test block reason',
131
+ toolName: 'write',
132
+ sessionId,
133
+ },
134
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
135
+ );
136
+
137
+ expect(result).toBeDefined();
138
+ expect(result.block).toBe(true);
139
+ expect(mockEmitSync).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('missing PROFILE.json defaults to non-risky without error', async () => {
143
+ // Arrange: PROFILE does not exist
144
+ vi.mocked(fs.existsSync).mockReturnValue(false);
145
+
146
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
147
+
148
+ const result = recordGateBlockAndReturn(
149
+ makeTestWctx() as any,
150
+ {
151
+ filePath: 'src/danger.ts',
152
+ reason: 'Test block',
153
+ toolName: 'edit',
154
+ sessionId,
155
+ },
156
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
157
+ );
158
+
159
+ expect(result).toBeDefined();
160
+ expect(result.block).toBe(true);
161
+ });
162
+
163
+ it('fs.readFileSync permission error falls back gracefully', async () => {
164
+ // Arrange: existsSync returns true but readFileSync throws
165
+ vi.mocked(fs.existsSync).mockReturnValue(true);
166
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
167
+ throw new Error('EACCES: permission denied');
168
+ });
169
+
170
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
171
+
172
+ const result = recordGateBlockAndReturn(
173
+ makeTestWctx() as any,
174
+ {
175
+ filePath: 'src/danger.ts',
176
+ reason: 'Test block',
177
+ toolName: 'write',
178
+ sessionId,
179
+ },
180
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
181
+ );
182
+
183
+ expect(result).toBeDefined();
184
+ expect(result.block).toBe(true);
185
+ });
186
+ });
@@ -8,9 +8,14 @@ import { WorkspaceContext } from '../../src/core/workspace-context.js';
8
8
  import { EventLogService } from '../../src/core/event-log.js';
9
9
  import { setInjectedProbationIds, clearSession } from '../../src/core/session-tracker.js';
10
10
  import { resetPainDiagnosticGateForTest } from '../../src/core/pain-diagnostic-gate.js';
11
+ import { loadFeatureFlagFromConfig } from '../../src/core/pd-config-loader.js';
11
12
 
12
13
  vi.mock('fs');
13
14
  vi.mock('../../src/utils/io.js');
15
+ vi.mock('../../src/core/pd-config-loader.js', () => ({
16
+ loadPdConfigForPlugin: vi.fn(() => ({ ok: true, source: 'mock', effective: {}, errors: [] })),
17
+ loadFeatureFlagFromConfig: vi.fn(() => ({ enabled: false, source: 'mock' })),
18
+ }));
14
19
  vi.mock('../../src/core/evolution-engine.js', () => ({
15
20
  recordEvolutionSuccess: vi.fn(),
16
21
  recordEvolutionFailure: vi.fn(),
@@ -480,4 +485,62 @@ describe('Post-Write Checks & Pain Hook', () => {
480
485
  }));
481
486
  });
482
487
 
488
+ it('PEAT-B1: triage evidence_only returns early before PainDiagnosticGate (no cooldown pollution)', () => {
489
+ // Enable the evidence triage feature flag
490
+ vi.mocked(loadFeatureFlagFromConfig).mockReturnValue({ enabled: true, source: 'test' });
491
+
492
+ const mockCtx = { workspaceDir, sessionId: 's-triage-evidence', api: { logger: {} } };
493
+ const mockEvent = {
494
+ toolName: 'write',
495
+ params: { file_path: 'src/main.ts' },
496
+ error: 'Permission denied',
497
+ result: { exitCode: 1 },
498
+ };
499
+
500
+ vi.mocked(ioUtils.normalizePath).mockReturnValue('src/main.ts');
501
+ vi.mocked(ioUtils.isRisky).mockReturnValue(false);
502
+ vi.mocked(fs.existsSync).mockReturnValue(false);
503
+
504
+ handleAfterToolCall(mockEvent as any, mockCtx as any);
505
+
506
+ // Core assertion: pain_detected event is NOT emitted — gate was not reached
507
+ expect(mockEmitSync).not.toHaveBeenCalled();
508
+ // Core assertion: recordPainSignal is NOT called — triage prevented gate evaluation
509
+ expect(mockEventLog.recordPainSignal).not.toHaveBeenCalled();
510
+ // Core assertion: trajectory pain event is NOT recorded — cooldown not polluted
511
+ expect(mockWctx.trajectory.recordPainEvent).not.toHaveBeenCalled();
512
+ // But tool call IS still tracked (friction tracking, not diagnosis)
513
+ expect(mockWctx.trajectory.recordToolCall).toHaveBeenCalledWith(expect.objectContaining({
514
+ sessionId: 's-triage-evidence',
515
+ toolName: 'write',
516
+ outcome: 'failure',
517
+ }));
518
+ });
519
+
520
+ it('PEAT-B1: triage admit proceeds to PainDiagnosticGate and cooldown', () => {
521
+ // For owner_reported source kinds, triage admits, so gate IS reached
522
+ vi.mocked(loadFeatureFlagFromConfig).mockReturnValue({ enabled: true, source: 'test' });
523
+
524
+ const mockCtx = { workspaceDir, sessionId: 's-triage-admit', api: { logger: {} } };
525
+ // Manual pain command — triggers the manual pain path
526
+ const mockEvent = {
527
+ toolName: 'pain',
528
+ params: { input: 'test pain' },
529
+ result: { exitCode: 0 },
530
+ error: undefined,
531
+ };
532
+
533
+ handleAfterToolCall(mockEvent as any, mockCtx as any);
534
+
535
+ // Core assertion: pain_detected event IS emitted — gate WAS reached
536
+ expect(mockEmitSync).toHaveBeenCalledWith(expect.objectContaining({
537
+ type: 'pain_detected',
538
+ }));
539
+ // Core assertion: recordPainSignal IS called
540
+ expect(mockEventLog.recordPainSignal).toHaveBeenCalledWith(
541
+ 's-triage-admit',
542
+ expect.objectContaining({ score: 100, source: 'manual' }),
543
+ );
544
+ });
545
+
483
546
  });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Triage Adapter Tests — PEAT-B1
3
+ *
4
+ * Tests the plugin-side adapter that maps hook context to SourceKind
5
+ * and calls the pure triage policy from principles-core.
6
+ *
7
+ * ERR checklist:
8
+ * - ERR-001: Source kind resolved from runtime values, not `as` casts.
9
+ * - ERR-002: Every triage result has reason + nextAction.
10
+ * - ERR-024/025/048: Production-path tests for the adapter.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import {
15
+ resolveSourceKindFromToolFailure,
16
+ resolveSourceKindFromLlmDetection,
17
+ resolveSourceKindFromGateBlock,
18
+ resolveSourceKindFromCommand,
19
+ resolveSourceKindFromProvider,
20
+ resolveSourceKindFromSubagent,
21
+ evaluateEvidenceTriage,
22
+ isHighConfidenceUnsafeAction,
23
+ } from '../../src/hooks/triage-adapter.js';
24
+
25
+ // ── resolveSourceKindFromToolFailure ────────────────────────────────────────
26
+
27
+ describe('resolveSourceKindFromToolFailure', () => {
28
+ it('maps pain tool to agent_on_owner_request with openclaw_context_bound', () => {
29
+ expect(resolveSourceKindFromToolFailure('pain', 'tool_failure', 'openclaw_context_bound')).toBe('agent_on_owner_request');
30
+ });
31
+
32
+ it('maps pain tool to owner_reported without openclaw_context_bound', () => {
33
+ expect(resolveSourceKindFromToolFailure('pain', 'tool_failure')).toBe('owner_reported');
34
+ expect(resolveSourceKindFromToolFailure('pain', 'tool_failure', 'automatic_hook')).toBe('owner_reported');
35
+ });
36
+
37
+ it('maps skill:pain to agent_on_owner_request with openclaw_context_bound', () => {
38
+ expect(resolveSourceKindFromToolFailure('skill:pain', 'tool_failure', 'openclaw_context_bound')).toBe('agent_on_owner_request');
39
+ });
40
+
41
+ it('maps dispatch_error to dispatch_error', () => {
42
+ expect(resolveSourceKindFromToolFailure('read', 'dispatch_error')).toBe('dispatch_error');
43
+ });
44
+
45
+ it('maps regular tool failure to tool_failure', () => {
46
+ expect(resolveSourceKindFromToolFailure('write', 'tool_failure')).toBe('tool_failure');
47
+ expect(resolveSourceKindFromToolFailure('exec', 'tool_failure')).toBe('tool_failure');
48
+ });
49
+
50
+ it('maps undefined tool name with tool_failure to tool_failure', () => {
51
+ expect(resolveSourceKindFromToolFailure(undefined, 'tool_failure')).toBe('tool_failure');
52
+ });
53
+ });
54
+
55
+ // ── resolveSourceKindFromLlmDetection ───────────────────────────────────────
56
+
57
+ describe('resolveSourceKindFromLlmDetection', () => {
58
+ it('maps gfi triggered to gfi_threshold', () => {
59
+ expect(resolveSourceKindFromLlmDetection('llm_some_rule', true)).toBe('gfi_threshold');
60
+ });
61
+
62
+ it('maps llm_paralysis to llm_paralysis', () => {
63
+ expect(resolveSourceKindFromLlmDetection('llm_paralysis', false)).toBe('llm_paralysis');
64
+ });
65
+
66
+ it('maps llm_* detection rules to semantic', () => {
67
+ expect(resolveSourceKindFromLlmDetection('llm_repetition', false)).toBe('semantic');
68
+ expect(resolveSourceKindFromLlmDetection('llm_loop', false)).toBe('semantic');
69
+ });
70
+
71
+ it('maps user_empathy to empathy_inferred', () => {
72
+ expect(resolveSourceKindFromLlmDetection('user_empathy', false)).toBe('empathy_inferred');
73
+ });
74
+
75
+ it('maps unknown source to unknown', () => {
76
+ expect(resolveSourceKindFromLlmDetection('something_else', false)).toBe('unknown');
77
+ });
78
+ });
79
+
80
+ // ── Other resolve functions ─────────────────────────────────────────────────
81
+
82
+ describe('resolveSourceKindFromGateBlock', () => {
83
+ it('returns rulehost_block', () => {
84
+ expect(resolveSourceKindFromGateBlock()).toBe('rulehost_block');
85
+ });
86
+ });
87
+
88
+ describe('resolveSourceKindFromCommand', () => {
89
+ it('returns owner_reported', () => {
90
+ expect(resolveSourceKindFromCommand()).toBe('owner_reported');
91
+ });
92
+ });
93
+
94
+ describe('resolveSourceKindFromProvider', () => {
95
+ it('returns provider_failure for non-rate-limit', () => {
96
+ expect(resolveSourceKindFromProvider(false)).toBe('provider_failure');
97
+ });
98
+
99
+ it('returns rate_limit for rate-limit', () => {
100
+ expect(resolveSourceKindFromProvider(true)).toBe('rate_limit');
101
+ });
102
+ });
103
+
104
+ describe('resolveSourceKindFromSubagent', () => {
105
+ it('returns subagent_error', () => {
106
+ expect(resolveSourceKindFromSubagent()).toBe('subagent_error');
107
+ });
108
+ });
109
+
110
+ // ── evaluateEvidenceTriage ──────────────────────────────────────────────────
111
+
112
+ describe('evaluateEvidenceTriage', () => {
113
+ it('admits owner_reported regardless of score', () => {
114
+ const result = evaluateEvidenceTriage('owner_reported', 100);
115
+ expect(result.decision).toBe('admit');
116
+ expect(result.reason).toBeTruthy();
117
+ expect(result.nextAction).toBeTruthy();
118
+ });
119
+
120
+ it('returns evidence_only for tool_failure', () => {
121
+ const result = evaluateEvidenceTriage('tool_failure', 70);
122
+ expect(result.decision).toBe('evidence_only');
123
+ expect(result.reason).toBeTruthy();
124
+ expect(result.nextAction).toBeTruthy();
125
+ });
126
+
127
+ it('returns health_only for provider_failure', () => {
128
+ const result = evaluateEvidenceTriage('provider_failure', 60);
129
+ expect(result.decision).toBe('health_only');
130
+ });
131
+
132
+ it('returns owner_confirm for empathy_inferred', () => {
133
+ const result = evaluateEvidenceTriage('empathy_inferred', 80);
134
+ expect(result.decision).toBe('owner_confirm');
135
+ });
136
+
137
+ it('admits rulehost_block when isUnsafeHighConfidence is true', () => {
138
+ const result = evaluateEvidenceTriage('rulehost_block', 80, { isUnsafeHighConfidence: true });
139
+ expect(result.decision).toBe('admit');
140
+ });
141
+
142
+ it('returns evidence_only for rulehost_block when isUnsafeHighConfidence is false', () => {
143
+ const result = evaluateEvidenceTriage('rulehost_block', 80, { isUnsafeHighConfidence: false });
144
+ expect(result.decision).toBe('evidence_only');
145
+ });
146
+ });
147
+
148
+ // ── isHighConfidenceUnsafeAction ─────────────────────────────────────────────
149
+
150
+ describe('isHighConfidenceUnsafeAction', () => {
151
+ it('returns true when isRisky and score >= 70', () => {
152
+ expect(isHighConfidenceUnsafeAction(70, true)).toBe(true);
153
+ expect(isHighConfidenceUnsafeAction(90, true)).toBe(true);
154
+ });
155
+
156
+ it('returns false when score < 70', () => {
157
+ expect(isHighConfidenceUnsafeAction(45, true)).toBe(false);
158
+ expect(isHighConfidenceUnsafeAction(69, true)).toBe(false);
159
+ });
160
+
161
+ it('returns false when not risky', () => {
162
+ expect(isHighConfidenceUnsafeAction(90, false)).toBe(false);
163
+ });
164
+ });
165
+
166
+ // ── Evidence-Only Cooldown Contract ──────────────────────────────────────────
167
+ //
168
+ // Core contract: when triage returns evidence_only/owner_confirm/health_only,
169
+ // the caller (hook) MUST NOT proceed to evaluatePainDiagnosticGate, which writes
170
+ // cooldown. These tests verify the adapter-level guarantee: non-admit decisions
171
+ // are surfaced clearly with the right nextAction, so the caller can distinguish
172
+ // evidence-only from admit.
173
+
174
+ describe('evidence-only cooldown contract', () => {
175
+ it('tool_failure returns evidence_only — no admit, caller must skip gate', () => {
176
+ const result = evaluateEvidenceTriage('tool_failure', 70);
177
+ expect(result.decision).toBe('evidence_only');
178
+ expect(result.decision).not.toBe('admit');
179
+ expect(result.nextAction).toContain('evidence');
180
+ });
181
+
182
+ it('dispatch_error returns evidence_only — no admit', () => {
183
+ const result = evaluateEvidenceTriage('dispatch_error', 50);
184
+ expect(result.decision).toBe('evidence_only');
185
+ expect(result.decision).not.toBe('admit');
186
+ });
187
+
188
+ it('semantic (LLM detection) returns evidence_only — no admit', () => {
189
+ const result = evaluateEvidenceTriage('semantic', 55);
190
+ expect(result.decision).toBe('evidence_only');
191
+ expect(result.decision).not.toBe('admit');
192
+ });
193
+
194
+ it('llm_paralysis returns evidence_only — no admit', () => {
195
+ const result = evaluateEvidenceTriage('llm_paralysis', 40);
196
+ expect(result.decision).toBe('evidence_only');
197
+ expect(result.decision).not.toBe('admit');
198
+ });
199
+
200
+ it('gfi_threshold returns evidence_only — no admit', () => {
201
+ const result = evaluateEvidenceTriage('gfi_threshold', 70);
202
+ expect(result.decision).toBe('evidence_only');
203
+ expect(result.decision).not.toBe('admit');
204
+ });
205
+
206
+ it('empathy_inferred returns owner_confirm — no admit', () => {
207
+ const result = evaluateEvidenceTriage('empathy_inferred', 80);
208
+ expect(result.decision).toBe('owner_confirm');
209
+ expect(result.decision).not.toBe('admit');
210
+ });
211
+
212
+ it('provider_failure returns health_only — no admit', () => {
213
+ const result = evaluateEvidenceTriage('provider_failure', 60);
214
+ expect(result.decision).toBe('health_only');
215
+ expect(result.decision).not.toBe('admit');
216
+ });
217
+
218
+ it('rulehost_block WITHOUT isUnsafeHighConfidence returns evidence_only — no admit', () => {
219
+ const result = evaluateEvidenceTriage('rulehost_block', 45);
220
+ expect(result.decision).toBe('evidence_only');
221
+ expect(result.decision).not.toBe('admit');
222
+ });
223
+
224
+ it('rulehost_block WITH isUnsafeHighConfidence=true upgrades to admit', () => {
225
+ // This is the ONLY path where rulehost_block reaches the gate
226
+ const result = evaluateEvidenceTriage('rulehost_block', 80, { isUnsafeHighConfidence: true });
227
+ expect(result.decision).toBe('admit');
228
+ expect(result.reason).toContain('unsafe');
229
+ });
230
+
231
+ it('every LLM-typical source kind produces non-admit decision (cooldown-safe)', () => {
232
+ // These are the source kinds that handleLlmOutput produces
233
+ const llmSources = [
234
+ { kind: 'semantic' as const, score: 55 },
235
+ { kind: 'llm_paralysis' as const, score: 40 },
236
+ { kind: 'gfi_threshold' as const, score: 70 },
237
+ { kind: 'empathy_inferred' as const, score: 80 },
238
+ ];
239
+ for (const { kind, score } of llmSources) {
240
+ const result = evaluateEvidenceTriage(kind, score);
241
+ expect(result.decision).not.toBe('admit');
242
+ expect(result.reason).toBeTruthy();
243
+ expect(result.nextAction).toBeTruthy();
244
+ }
245
+ });
246
+
247
+ it('every after_tool_call-typical source kind produces non-admit decision (cooldown-safe)', () => {
248
+ // These are the source kinds that handleAfterToolCall produces
249
+ const toolSources = [
250
+ { kind: 'tool_failure' as const, score: 70 },
251
+ { kind: 'dispatch_error' as const, score: 50 },
252
+ ];
253
+ for (const { kind, score } of toolSources) {
254
+ const result = evaluateEvidenceTriage(kind, score);
255
+ expect(result.decision).not.toBe('admit');
256
+ expect(result.reason).toBeTruthy();
257
+ expect(result.nextAction).toBeTruthy();
258
+ }
259
+ });
260
+ });