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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/hooks/gate-block-helper.ts +72 -29
- package/src/hooks/llm.ts +49 -29
- package/src/hooks/pain.ts +21 -1
- package/src/hooks/triage-adapter.ts +156 -0
- package/tests/hooks/gate-block-helper-profile.test.ts +186 -0
- package/tests/hooks/pain.test.ts +63 -0
- package/tests/hooks/triage-adapter.test.ts +260 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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 (
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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]
|
|
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
|
+
});
|
package/tests/hooks/pain.test.ts
CHANGED
|
@@ -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
|
+
});
|