principles-disciple 1.91.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.91.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.91.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
 
@@ -1,4 +1,11 @@
1
1
  import type { PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult } from '../openclaw-sdk.js';
2
+ import {
3
+ sanitizeString as coreSanitizeString,
4
+ sanitizeValue as coreSanitizeValue,
5
+ sanitizeToolParams as coreSanitizeToolParams,
6
+ convergePath,
7
+ MAX_EVIDENCE_VALUE_CHARS,
8
+ } from '@principles/core/runtime-v2';
2
9
 
3
10
  const INTERNAL_TAG_PATTERNS = [
4
11
  /\[EMOTIONAL_DAMAGE_DETECTED(?::(?:mild|moderate|severe))?\]/gi,
@@ -21,6 +28,41 @@ function isAssistantMessageWithContent(
21
28
  );
22
29
  }
23
30
 
31
+ // Re-export core constants and functions for backward compatibility
32
+ export { MAX_EVIDENCE_VALUE_CHARS, convergePath };
33
+
34
+ /**
35
+ * Sanitize a single string value for evidence storage.
36
+ * Delegates to core sanitizer with optional workspaceDir for path convergence.
37
+ */
38
+ export function sanitizeForEvidence(value: unknown, workspaceDir?: string): string {
39
+ if (value === null || value === undefined) return '';
40
+ return coreSanitizeString(String(value), workspaceDir);
41
+ }
42
+
43
+ /**
44
+ * Recursively sanitize any value for evidence storage.
45
+ * Delegates to core sanitizer.
46
+ */
47
+ export function sanitizeValueForEvidence(value: unknown, workspaceDir?: string): unknown {
48
+ return coreSanitizeValue(value, 0, workspaceDir);
49
+ }
50
+
51
+ /**
52
+ * Sanitize tool-call params for evidence/trajectory storage.
53
+ * Delegates to core sanitizer — accepts unknown, runtime-validates.
54
+ *
55
+ * ERR-001: no `as` casts on input
56
+ * ERR-055: ANY-segment sensitive field matching
57
+ * ERR-056: token redaction on ALL strings via recursive sanitizeValue
58
+ */
59
+ export function sanitizeToolParamsForEvidence(
60
+ params: unknown,
61
+ workspaceDir?: string,
62
+ ): Record<string, unknown> {
63
+ return coreSanitizeToolParams(params, workspaceDir);
64
+ }
65
+
24
66
  export function sanitizeAssistantText(text: string): string {
25
67
  let result = text;
26
68
  for (const pattern of INTERNAL_TAG_PATTERNS) {
package/src/hooks/pain.ts CHANGED
@@ -13,8 +13,9 @@ import type { PluginHookAfterToolCallEvent, PluginHookToolContext, OpenClawPlugi
13
13
  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
- import { sanitizeAssistantText } from './message-sanitize.js';
17
- import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
16
+ import { sanitizeAssistantText, sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.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'
@@ -348,7 +349,7 @@ export function handleAfterToolCall(
348
349
  errorMessage: event.error ? String(event.error) : undefined,
349
350
  gfiBefore,
350
351
  gfiAfter: updatedState.currentGfi,
351
- paramsJson: event.params,
352
+ paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
352
353
  });
353
354
 
354
355
  const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
@@ -409,7 +410,7 @@ export function handleAfterToolCall(
409
410
  exitCode,
410
411
  gfiBefore,
411
412
  gfiAfter: resetState.currentGfi,
412
- paramsJson: event.params,
413
+ paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
413
414
  });
414
415
 
415
416
  const filePath = params.file_path || params.path || params.file;
@@ -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,
@@ -512,7 +532,7 @@ export function handleAfterToolCall(
512
532
  reason: `Tool ${event.toolName} failed on ${relPath}`,
513
533
  severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
514
534
  origin: 'system_infer',
515
- text: params.text ?? params.content ?? undefined,
535
+ text: sanitizeForEvidence(params.text ?? params.content, effectiveWorkspaceDir) || undefined,
516
536
  });
517
537
 
518
538
  // Pain signal emitted via emitPainDetectedEvent below — no .pain_flag file written (M8: single-path chain)
@@ -30,6 +30,7 @@ import {
30
30
  extractMessageContent,
31
31
  isMinimalTrigger,
32
32
  } from '@principles/core/prompt-builder';
33
+ import { sanitizeForEvidence } from './message-sanitize.js';
33
34
 
34
35
  // ---------------------------------------------------------------------------
35
36
  // Static file cache — avoids re-reading rarely-changing files every message
@@ -574,7 +575,7 @@ The empathy observer subagent handles pain detection independently.
574
575
  confidence: result.confidence,
575
576
  detection_mode: 'structured',
576
577
  deduped: false,
577
- trigger_text_excerpt: latestUserMessage.substring(0, 120),
578
+ trigger_text_excerpt: sanitizeForEvidence(latestUserMessage, workspaceDir).substring(0, 120),
578
579
  raw_score: painScore,
579
580
  calibrated_score: painScore,
580
581
  eventId,
@@ -589,7 +590,7 @@ The empathy observer subagent handles pain detection independently.
589
590
  severity: result.severity,
590
591
  origin: 'system_infer',
591
592
  confidence: result.confidence,
592
- text: latestUserMessage,
593
+ text: sanitizeForEvidence(latestUserMessage, workspaceDir),
593
594
  });
594
595
  } catch (error) {
595
596
  logger?.warn?.(`[PD:Empathy] Failed to persist trajectory: ${String(error)}`);
@@ -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
+ });
@@ -1,5 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { handleBeforeMessageWrite, sanitizeAssistantText } from '../../src/hooks/message-sanitize';
2
+ import {
3
+ handleBeforeMessageWrite,
4
+ sanitizeAssistantText,
5
+ sanitizeForEvidence,
6
+ sanitizeToolParamsForEvidence,
7
+ sanitizeValueForEvidence,
8
+ MAX_EVIDENCE_VALUE_CHARS,
9
+ } from '../../src/hooks/message-sanitize';
3
10
 
4
11
  describe('message-sanitize hook', () => {
5
12
  it('removes empathy control tags from assistant text', () => {
@@ -9,28 +16,229 @@ describe('message-sanitize hook', () => {
9
16
 
10
17
  it('returns modified message for assistant role', () => {
11
18
  const result = handleBeforeMessageWrite({
12
- message: {
13
- role: 'assistant',
14
- content: 'hello [EMOTIONAL_DAMAGE_DETECTED] world'
15
- }
19
+ message: { role: 'assistant', content: 'hello [EMOTIONAL_DAMAGE_DETECTED] world' }
16
20
  } as any);
17
-
18
21
  expect(result).toEqual({
19
- message: {
20
- role: 'assistant',
21
- content: 'hello world'
22
- }
22
+ message: { role: 'assistant', content: 'hello world' }
23
23
  });
24
24
  });
25
25
 
26
26
  it('ignores non-assistant messages', () => {
27
27
  const result = handleBeforeMessageWrite({
28
- message: {
29
- role: 'user',
30
- content: '[EMOTIONAL_DAMAGE_DETECTED]'
31
- }
28
+ message: { role: 'user', content: '[EMOTIONAL_DAMAGE_DETECTED]' }
32
29
  } as any);
33
-
34
30
  expect(result).toBeUndefined();
35
31
  });
32
+
33
+ // ── sanitizeForEvidence ──
34
+
35
+ it('binds long string values to MAX_EVIDENCE_VALUE_CHARS', () => {
36
+ const segment = ' data ';
37
+ const long = segment.repeat(100);
38
+ const result = sanitizeForEvidence(long);
39
+ expect(result).toMatch(/___TRUNCATED___$/);
40
+ expect(result.length).toBeLessThanOrEqual(MAX_EVIDENCE_VALUE_CHARS + 20);
41
+ });
42
+
43
+ it('redacts OpenAI-style secret keys (sk-*)', () => {
44
+ const token = 'sk-proj-' + 'a'.repeat(30);
45
+ const result = sanitizeForEvidence(`token is ${token}`);
46
+ expect(result).toContain('___REDACTED___');
47
+ expect(result).not.toContain(token);
48
+ });
49
+
50
+ it('redacts JWT-like patterns (eyJ*)', () => {
51
+ const jwt = 'eyJ' + 'a'.repeat(30) + '.bc';
52
+ const result = sanitizeForEvidence(`Bearer ${jwt}`);
53
+ expect(result).toContain('___REDACTED___');
54
+ expect(result).not.toContain(jwt);
55
+ });
56
+
57
+ it('redacts long base64-like strings', () => {
58
+ const token = 'A'.repeat(50);
59
+ const result = sanitizeForEvidence(`hash: ${token}`);
60
+ expect(result).toContain('___REDACTED___');
61
+ expect(result).not.toContain(token);
62
+ });
63
+
64
+ it('redacts GitHub PATs (ghp_*)', () => {
65
+ const token = 'ghp_' + 'a'.repeat(40);
66
+ const result = sanitizeForEvidence(token);
67
+ expect(result).toContain('___REDACTED___');
68
+ });
69
+
70
+ it('strips internal PD tags from evidence', () => {
71
+ const result = sanitizeForEvidence('[EMOTIONAL_DAMAGE_DETECTED:severe] something went wrong');
72
+ expect(result).not.toContain('EMOTIONAL_DAMAGE_DETECTED');
73
+ expect(result).toContain('something went wrong');
74
+ });
75
+
76
+ it('returns safe string for non-string values', () => {
77
+ expect(sanitizeForEvidence(42)).toBe('42');
78
+ expect(sanitizeForEvidence(null)).toBe('');
79
+ expect(sanitizeForEvidence(undefined)).toBe('');
80
+ });
81
+
82
+ it('converges absolute paths to basename when no workspaceDir', () => {
83
+ const result = sanitizeForEvidence('/home/user/secrets/token.json');
84
+ expect(result).toBe('<path:token.json>');
85
+ });
86
+
87
+ it('converges absolute paths to repo-relative when workspaceDir matches', () => {
88
+ const result = sanitizeForEvidence('/workspace/my-repo/src/index.ts', '/workspace/my-repo');
89
+ expect(result).toBe('src/index.ts');
90
+ });
91
+
92
+ // ── Path substring sanitization ──
93
+
94
+ it('replaces Windows absolute path in command with basename', () => {
95
+ const result = sanitizeForEvidence('cd D:\\Code\\principles && git status');
96
+ expect(result).not.toContain('D:\\Code\\principles');
97
+ expect(result).toContain('principles');
98
+ });
99
+
100
+ it('replaces Windows absolute path in reason with basename', () => {
101
+ const result = sanitizeForEvidence('error in C:\\Users\\Administrator\\secret.txt');
102
+ expect(result).not.toContain('C:\\Users\\Administrator');
103
+ expect(result).not.toContain('Administrator');
104
+ expect(result).toContain('secret.txt');
105
+ });
106
+
107
+ it('replaces POSIX absolute path in string', () => {
108
+ const result = sanitizeForEvidence('failed to read /home/user/project/src/config.ts');
109
+ expect(result).not.toContain('/home/user/project');
110
+ expect(result).toContain('config.ts');
111
+ });
112
+
113
+ it('converges workspace-internal path in string to repo-relative', () => {
114
+ const result = sanitizeForEvidence(
115
+ 'edit failed on D:\\Code\\principles\\src\\index.ts',
116
+ 'D:\\Code\\principles',
117
+ );
118
+ expect(result).not.toContain('D:\\Code\\principles');
119
+ expect(result).toContain('src\\index.ts');
120
+ });
121
+
122
+ // ── sanitizeToolParamsForEvidence ──
123
+
124
+ it('redacts long content/text/input/new_string fields', () => {
125
+ const params = {
126
+ file_path: 'src/file.ts',
127
+ content: ' data chunk '.repeat(60),
128
+ text: ' report line '.repeat(60),
129
+ };
130
+ const result = sanitizeToolParamsForEvidence(params);
131
+ expect(result.file_path).toBe('src/file.ts');
132
+ expect(result.content).toMatch(/___TRUNCATED___$/);
133
+ expect(result.text).toMatch(/___TRUNCATED___$/);
134
+ expect(result.content.length).toBeLessThan(500);
135
+ });
136
+
137
+ it('keeps short normal fields intact', () => {
138
+ const params = { file_path: 'src/index.ts', content: 'short-content', query: 'SELECT * FROM users' };
139
+ const result = sanitizeToolParamsForEvidence(params);
140
+ expect(result.file_path).toBe('src/index.ts');
141
+ expect(result.content).toBe('short-content');
142
+ expect(result.query).toBe('SELECT * FROM users');
143
+ });
144
+
145
+ it('redacts token-like strings inside content/text fields', () => {
146
+ const token = 'sk-proj-' + 'a'.repeat(30);
147
+ const params = { content: `key is ${token}` };
148
+ const result = sanitizeToolParamsForEvidence(params);
149
+ expect(result.content).toContain('___REDACTED___');
150
+ expect(result.content).not.toContain(token);
151
+ });
152
+
153
+ // ── edit params: nested edits array ──
154
+
155
+ it('sanitizes edit params with nested edits[].oldText/newText', () => {
156
+ const secretToken = 'sk-proj-' + 'a'.repeat(30);
157
+ const params = {
158
+ file_path: '/repo/src/config.ts',
159
+ edits: [
160
+ { oldText: secretToken, newText: 'safe-value' },
161
+ { oldText: 'const x = 1;', newText: ' data '.repeat(200) },
162
+ ],
163
+ };
164
+ const result = sanitizeToolParamsForEvidence(params, '/repo') as Record<string, unknown>;
165
+ const edits = result.edits as Array<Record<string, unknown>>;
166
+ // Token in oldText redacted
167
+ expect(edits[0].oldText).toContain('___REDACTED___');
168
+ expect(edits[0].oldText).not.toContain(secretToken);
169
+ // Short value preserved
170
+ expect(edits[0].newText).toBe('safe-value');
171
+ // Long newText truncated
172
+ expect(edits[1].newText).toMatch(/___TRUNCATED___$/);
173
+ // file_path converged to relative (workspaceDir provided)
174
+ expect(result.file_path).toBe('src/config.ts');
175
+ });
176
+
177
+ // ── command/query: token redaction ──
178
+
179
+ it('redacts tokens in command and query fields', () => {
180
+ const jwt = 'eyJ' + 'a'.repeat(30) + '.bc';
181
+ const skToken = 'sk-proj-' + 'a'.repeat(30);
182
+ const params = {
183
+ command: `curl -H "Authorization: Bearer ${jwt}" https://api.example.com`,
184
+ query: `SELECT * FROM users WHERE api_key = "${skToken}"`,
185
+ };
186
+ const result = sanitizeToolParamsForEvidence(params);
187
+ expect(result.command).toContain('___REDACTED___');
188
+ expect(result.command).not.toContain(jwt);
189
+ expect(result.query).toContain('___REDACTED___');
190
+ expect(result.query).not.toContain(skToken);
191
+ });
192
+
193
+ // ── null/array/string input: no throw ──
194
+
195
+ it('handles null input without throwing', () => {
196
+ expect(() => sanitizeToolParamsForEvidence(null)).not.toThrow();
197
+ expect(sanitizeToolParamsForEvidence(null)).toEqual({});
198
+ });
199
+
200
+ it('handles array input without throwing', () => {
201
+ const result = sanitizeToolParamsForEvidence(['a', 'b', 'c']);
202
+ expect(result['<array-input>']).toBeDefined();
203
+ });
204
+
205
+ it('handles string input without throwing', () => {
206
+ const result = sanitizeToolParamsForEvidence('raw string input');
207
+ expect(result['<string-input>']).toBeDefined();
208
+ });
209
+
210
+ it('handles undefined input without throwing', () => {
211
+ expect(sanitizeToolParamsForEvidence(undefined)).toEqual({});
212
+ });
213
+
214
+ it('handles number input without throwing', () => {
215
+ expect(sanitizeToolParamsForEvidence(42)).toEqual({});
216
+ });
217
+
218
+ // ── sanitizeValueForEvidence: recursive ──
219
+
220
+ it('recursively sanitizes nested objects', () => {
221
+ const token = 'sk-proj-' + 'a'.repeat(30);
222
+ const input = { a: { b: { c: token } } };
223
+ const result = sanitizeValueForEvidence(input) as Record<string, unknown>;
224
+ const nested = (result.a as Record<string, unknown>).b as Record<string, unknown>;
225
+ expect(nested.c).toContain('___REDACTED___');
226
+ });
227
+
228
+ it('respects max array items limit', () => {
229
+ const input = { items: Array.from({ length: 100 }, (_, i) => `item-${i}`) };
230
+ const result = sanitizeValueForEvidence(input) as Record<string, unknown>;
231
+ const items = result.items as unknown[];
232
+ expect(items.length).toBeLessThanOrEqual(22);
233
+ });
234
+
235
+ it('respects max depth limit', () => {
236
+ const deep: any = { a: { b: { c: { d: { e: 'too deep' } } } } };
237
+ const result = sanitizeValueForEvidence(deep, 0) as Record<string, unknown>;
238
+ const a = result.a as Record<string, unknown>;
239
+ const b = a.b as Record<string, unknown>;
240
+ const c = b.c as Record<string, unknown>;
241
+ const d = c.d as Record<string, unknown>;
242
+ expect(d.e).toBe('<max-depth>');
243
+ });
36
244
  });
@@ -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
+ });