principles-disciple 1.109.0 → 1.110.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.109.0",
5
+ "version": "1.110.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.109.0",
3
+ "version": "1.110.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -25,7 +25,8 @@ import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolutio
25
25
  import type { PluginHookAfterToolCallEvent } from '../openclaw-sdk.js';
26
26
  import { isCooldownActive as isTriggerCooldownActive, markEpisodeAsDiagnosed, clearCooldownState } from './trigger-cooldown-tracker.js';
27
27
  import { sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
28
- import { resolveSourceKindFromToolFailure, evaluateEvidenceTriage } from './triage-adapter.js';
28
+ import { resolveSourceKind, buildToolFailureObservation, type RawObservation } from './raw-observation-adapter.js';
29
+ import { evaluateEvidenceTriage } from './triage-adapter.js';
29
30
  import { evaluateTriggerController } from '@principles/core/runtime-v2';
30
31
  import { buildTrajectoryEvidence } from './trajectory-evidence.js';
31
32
  import type { ToolCallOutcome, ToolCallObservation, PainAdmissionDecision } from './after-tool-call-types.js';
@@ -55,10 +56,19 @@ export function classifyToolCallOutcome(event: PluginHookAfterToolCallEvent): To
55
56
  : 0;
56
57
  const isFailure = !!event.error || exitCode !== 0;
57
58
 
59
+ // PRI-360 S1: Use centralized builder for tool failure classification
60
+ // All dispatch/tool_failure rules live in raw-observation-adapter.ts
61
+ const obs = buildToolFailureObservation({
62
+ toolName: event.toolName,
63
+ error: event.error,
64
+ exitCode,
65
+ });
66
+ const failureSource = isFailure ? obs.failureSource : undefined;
67
+
58
68
  return {
59
69
  isFailure,
60
70
  exitCode,
61
- failureSource: isFailure ? classifyToolFailureSource(event.toolName, event.error) : undefined,
71
+ failureSource,
62
72
  };
63
73
  }
64
74
 
@@ -372,8 +382,23 @@ export function evaluatePainAdmissionForToolCall(
372
382
  TRIGGER_COOLDOWN_MAP,
373
383
  );
374
384
 
385
+ // PRI-360 S1: Build RawObservation for unified source mapping
386
+ const rawObs: RawObservation = {
387
+ observedAt: new Date().toISOString(),
388
+ workspaceId: workspaceDir,
389
+ sessionId,
390
+ toolName: event.toolName,
391
+ failureSource: outcome.failureSource,
392
+ // Infer toolNotFound from failureSource for resolveSourceKind compatibility
393
+ toolNotFound: outcome.failureSource === 'dispatch_error',
394
+ // Extract exit code from outcome for triage (nonZeroExit)
395
+ nonZeroExit: outcome.exitCode !== 0,
396
+ };
397
+
398
+ // PRI-360 S1: Use unified resolveSourceKind instead of resolveSourceKindFromToolFailure
399
+ const sourceKind = resolveSourceKind(rawObs);
400
+
375
401
  // PEAT-B1: Evidence triage (with consecutiveErrors and isRisky for upgrade logic)
376
- const sourceKind = resolveSourceKindFromToolFailure(event.toolName, failureSource);
377
402
  const triage = evaluateEvidenceTriage(sourceKind, observation.painScore, {
378
403
  consecutiveErrors: (latestFailureState ?? sessionState)?.consecutiveErrors,
379
404
  isRisky: observation.isRisk,
@@ -554,20 +579,8 @@ export function resetTriggerCooldownForTest(): void {
554
579
 
555
580
  // ── Source Classification ────────────────────────────────────────────────────
556
581
 
557
- /**
558
- * Classify tool failure source.
559
- *
560
- * Pure function — no I/O, no side effects.
561
- * Determines whether a tool failure is a dispatch error (tool not found)
562
- * or a regular tool execution failure.
563
- */
564
- export function classifyToolFailureSource(toolName: string | undefined, error: unknown): 'dispatch_error' | 'tool_failure' {
565
- if (!toolName || toolName.trim() === '') return 'dispatch_error';
566
- const msg = String(error ?? '');
567
- if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg)) return 'dispatch_error';
568
- if (/\bunknown\s+tool\b/i.test(msg)) return 'dispatch_error';
569
- return 'tool_failure';
570
- }
582
+ // classifyToolFailureSource logic is now in resolveSourceKind (PRI-360 S1)
583
+ // This function is removed to avoid duplication.
571
584
 
572
585
  /**
573
586
  * Extract error type classification from error value.
package/src/hooks/llm.ts CHANGED
@@ -12,7 +12,8 @@ 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
14
  import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
15
- import { resolveSourceKindFromLlmDetection, evaluateEvidenceTriage } from './triage-adapter.js';
15
+ import { resolveSourceKind, type RawObservation } from './raw-observation-adapter.js';
16
+ import { evaluateEvidenceTriage } from './triage-adapter.js';
16
17
 
17
18
  export interface EmpathySignal {
18
19
  detected: boolean;
@@ -255,7 +256,15 @@ export function handleLlmOutput(
255
256
  let triageAdmitted = true;
256
257
  const llmTriageFlag = loadFeatureFlagFromConfig(ctx.workspaceDir!, 'painEvidenceAdmission');
257
258
  if (llmTriageFlag.enabled) {
258
- const sourceKind = resolveSourceKindFromLlmDetection(source, isGfiTriggered);
259
+ // PRI-360 S1: Build RawObservation for unified source mapping
260
+ const rawObs: RawObservation = {
261
+ observedAt: new Date().toISOString(),
262
+ workspaceId: ctx.workspaceDir,
263
+ sessionId: ctx.sessionId,
264
+ detectionSource: source,
265
+ isGfiTriggered,
266
+ };
267
+ const sourceKind = resolveSourceKind(rawObs);
259
268
  const triage = evaluateEvidenceTriage(sourceKind, painScore);
260
269
  if (triage.decision !== 'admit') {
261
270
  triageAdmitted = false;
package/src/hooks/pain.ts CHANGED
@@ -128,9 +128,10 @@ function createPainId(sessionId: string): string {
128
128
  return `pain_${Date.now()}_${computeHash(sessionId).slice(0, 8)}`;
129
129
  }
130
130
 
131
- // ── Source Classification (re-exported from helpers) ────────────────────────
131
+ // ── Source Classification ────────────────────────────────────────────────────
132
132
 
133
- export { classifyToolFailureSource } from './after-tool-call-helpers.js';
133
+ // PRI-360 S1: classifyToolFailureSource is removed; source mapping is now unified
134
+ // through resolveSourceKind in raw-observation-adapter.ts
134
135
 
135
136
  // ── Main Hook ───────────────────────────────────────────────────────────────
136
137
 
@@ -26,6 +26,9 @@
26
26
  import type { SourceKind } from '@principles/core/runtime-v2';
27
27
  import type { RawObservation } from './raw-observation-types.js';
28
28
 
29
+ // Re-export RawObservation for plugin consumers
30
+ export type { RawObservation } from './raw-observation-types.js';
31
+
29
32
  /**
30
33
  * Resolve SourceKind from a unified RawObservation.
31
34
  *
@@ -135,97 +138,78 @@ export function resolveSourceKind(observation: RawObservation): SourceKind {
135
138
  return 'unknown';
136
139
  }
137
140
 
138
- /**
139
- * Resolve SourceKind from tool failure context (legacy wrapper).
140
- *
141
- * This is a thin wrapper around resolveSourceKind for compatibility.
142
- * It constructs a RawObservation from the old function signature.
143
- *
144
- * @deprecated Use resolveSourceKind directly with RawObservation.
145
- */
146
- export function resolveSourceKindFromToolFailure(
147
- toolName: string | undefined,
148
- failureSource: 'tool_failure' | 'dispatch_error',
149
- provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook',
150
- ): SourceKind {
151
- const observation: RawObservation = {
152
- observedAt: new Date().toISOString(),
153
- toolName,
154
- failureSource,
155
- provenance,
156
- };
157
- return resolveSourceKind(observation);
158
- }
141
+ // ── Builder Functions ──────────────────────────────────────────────────────
142
+ //
143
+ // PRI-360 S1: These builders construct RawObservation from specific contexts,
144
+ // centralizing source classification rules in the adapter layer.
145
+ // Hooks should NOT hold source classification logic use these builders.
159
146
 
160
147
  /**
161
- * Resolve SourceKind from LLM detection context (legacy wrapper).
162
- *
163
- * This is a thin wrapper around resolveSourceKind for compatibility.
148
+ * Classify error message as dispatch_error vs tool_failure.
164
149
  *
165
- * @deprecated Use resolveSourceKind directly with RawObservation.
150
+ * This centralizes the regex-based classification that was previously
151
+ * scattered in classifyToolFailureSource and after-tool-call-helpers.
152
+ * Now hooks call this builder + resolveSourceKind instead of holding rules.
166
153
  */
167
- export function resolveSourceKindFromLlmDetection(
168
- detectionSource: string,
169
- isGfiTriggered: boolean,
170
- ): SourceKind {
171
- const observation: RawObservation = {
172
- observedAt: new Date().toISOString(),
173
- detectionSource,
174
- isGfiTriggered,
175
- };
176
- return resolveSourceKind(observation);
154
+ function classifyErrorForDispatch(error: unknown): 'dispatch_error' | 'tool_failure' {
155
+ if (!error) return 'tool_failure';
156
+ const msg = String(error);
157
+ if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg) || /\bunknown\s+tool\b/i.test(msg)) {
158
+ return 'dispatch_error';
159
+ }
160
+ return 'tool_failure';
177
161
  }
178
162
 
179
163
  /**
180
- * Resolve SourceKind from gate block context (legacy wrapper).
164
+ * Build a RawObservation for a tool failure context.
181
165
  *
182
- * @deprecated Use resolveSourceKind directly with RawObservation.
166
+ * This replaces classifyToolFailureSource and the inline classification
167
+ * in after-tool-call-helpers. All tool error → dispatch/tool_failure
168
+ * classification is centralized here.
183
169
  */
184
- export function resolveSourceKindFromGateBlock(): SourceKind {
185
- const observation: RawObservation = {
186
- observedAt: new Date().toISOString(),
187
- isGateBlock: true,
188
- };
189
- return resolveSourceKind(observation);
190
- }
170
+ export function buildToolFailureObservation(options: {
171
+ toolName: string | undefined;
172
+ error: unknown;
173
+ exitCode?: number;
174
+ provenance?: RawObservation['provenance'];
175
+ }): RawObservation {
176
+ const { toolName, error, provenance } = options;
177
+ const nonZeroExit = typeof options.exitCode === 'number' && options.exitCode !== 0;
178
+
179
+ // Classify dispatch vs tool_failure centrally
180
+ let failureSource: 'dispatch_error' | 'tool_failure' | undefined;
181
+
182
+ if (!toolName || toolName.trim() === '') {
183
+ // Empty/whitespace tool name → dispatch error
184
+ failureSource = 'dispatch_error';
185
+ } else {
186
+ failureSource = classifyErrorForDispatch(error);
187
+ }
191
188
 
192
- /**
193
- * Resolve SourceKind from manual command context (legacy wrapper).
194
- *
195
- * @deprecated Use resolveSourceKind directly with RawObservation.
196
- */
197
- export function resolveSourceKindFromCommand(): SourceKind {
198
- const observation: RawObservation = {
199
- observedAt: new Date().toISOString(),
200
- isManualEntry: true,
201
- };
202
- return resolveSourceKind(observation);
203
- }
189
+ // If neither error nor non-zero exit, this is not a failure context
190
+ if (!error && !nonZeroExit) {
191
+ failureSource = undefined;
192
+ }
204
193
 
205
- /**
206
- * Resolve SourceKind from provider context (legacy wrapper).
207
- *
208
- * @deprecated Use resolveSourceKind directly with RawObservation.
209
- */
210
- export function resolveSourceKindFromProvider(
211
- isRateLimit: boolean,
212
- ): SourceKind {
213
- const observation: RawObservation = {
194
+ return {
214
195
  observedAt: new Date().toISOString(),
215
- isRateLimit,
196
+ toolName,
197
+ failureSource,
198
+ nonZeroExit,
199
+ provenance,
216
200
  };
217
- return resolveSourceKind(observation);
218
201
  }
219
202
 
220
203
  /**
221
- * Resolve SourceKind from subagent context (legacy wrapper).
222
- *
223
- * @deprecated Use resolveSourceKind directly with RawObservation.
204
+ * Build a RawObservation for an LLM detection context.
224
205
  */
225
- export function resolveSourceKindFromSubagent(): SourceKind {
226
- const observation: RawObservation = {
206
+ export function buildLlmDetectionObservation(options: {
207
+ detectionSource: string;
208
+ isGfiTriggered: boolean;
209
+ }): RawObservation {
210
+ return {
227
211
  observedAt: new Date().toISOString(),
228
- isSubagentError: true,
212
+ detectionSource: options.detectionSource,
213
+ isGfiTriggered: options.isGfiTriggered,
229
214
  };
230
- return resolveSourceKind(observation);
231
215
  }
@@ -32,50 +32,11 @@ import {
32
32
  *
33
33
  * This is the unified entry point for source-kind classification.
34
34
  * It replaces the scattered resolveSourceKindFrom* functions.
35
- */
36
- export { resolveSourceKind } from './raw-observation-adapter.js';
37
-
38
- /**
39
- * Map after_tool_call hook context to SourceKind.
40
- *
41
- * @deprecated Use resolveSourceKind directly with RawObservation.
42
- */
43
- export { resolveSourceKindFromToolFailure } from './raw-observation-adapter.js';
44
-
45
- /**
46
- * Map empathy/semantic detection context to SourceKind.
47
- *
48
- * @deprecated Use resolveSourceKind directly with RawObservation.
49
- */
50
- export { resolveSourceKindFromLlmDetection } from './raw-observation-adapter.js';
51
-
52
- /**
53
- * Map gate-block context to SourceKind.
54
- *
55
- * @deprecated Use resolveSourceKind directly with RawObservation.
56
- */
57
- export { resolveSourceKindFromGateBlock } from './raw-observation-adapter.js';
58
-
59
- /**
60
- * Map /pd-pain command to SourceKind.
61
- *
62
- * @deprecated Use resolveSourceKind directly with RawObservation.
63
- */
64
- export { resolveSourceKindFromCommand } from './raw-observation-adapter.js';
65
-
66
- /**
67
- * Map provider/rate-limit failure to SourceKind.
68
- *
69
- * @deprecated Use resolveSourceKind directly with RawObservation.
70
- */
71
- export { resolveSourceKindFromProvider } from './raw-observation-adapter.js';
72
-
73
- /**
74
- * Map subagent error to SourceKind.
75
35
  *
76
- * @deprecated Use resolveSourceKind directly with RawObservation.
36
+ * PRI-360 S1: All source-kind resolution now goes through this single entry point.
77
37
  */
78
- export { resolveSourceKindFromSubagent } from './raw-observation-adapter.js';
38
+ export { resolveSourceKind, buildToolFailureObservation, buildLlmDetectionObservation, type RawObservation } from './raw-observation-adapter.js';
39
+ // All callers should use resolveSourceKind with RawObservation.
79
40
 
80
41
  // ── Triage Evaluation ───────────────────────────────────────────────────────
81
42
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { handleAfterToolCall, classifyToolFailureSource } from '../../src/hooks/pain.js';
2
+ import { handleAfterToolCall } from '../../src/hooks/pain.js';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import * as os from 'os';
@@ -8,6 +8,7 @@ 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 { resetTriggerCooldownForTest } from '../../src/hooks/after-tool-call-helpers.js';
11
+ import { buildToolFailureObservation, resolveSourceKind } from '../../src/hooks/raw-observation-adapter.js';
11
12
  import { loadFeatureFlagFromConfig } from '../../src/core/pd-config-loader.js';
12
13
 
13
14
  vi.mock('fs');
@@ -31,74 +32,91 @@ const mockEmitSync = vi.fn();
31
32
  const mockRecordProbationFeedback = vi.fn();
32
33
  const mockUpdatePrincipleValueMetrics = vi.fn();
33
34
 
34
- describe('classifyToolFailureSource', () => {
35
+ // PRI-360 S1: classifyToolFailureSource tests migrated to resolveSourceKind + buildToolFailureObservation
36
+ // See triage-adapter.test.ts for the unified RawObservation path tests
37
+
38
+ describe('buildToolFailureObservation + resolveSourceKind (replaces classifyToolFailureSource)', () => {
35
39
  it('empty toolName -> dispatch_error', () => {
36
- expect(classifyToolFailureSource(undefined, 'tool not found')).toBe('dispatch_error');
37
- expect(classifyToolFailureSource('', 'tool not found')).toBe('dispatch_error');
40
+ const obs = buildToolFailureObservation({ toolName: undefined, error: 'tool not found', exitCode: 1 });
41
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
42
+ const obs2 = buildToolFailureObservation({ toolName: '', error: 'tool not found', exitCode: 1 });
43
+ expect(resolveSourceKind(obs2)).toBe('dispatch_error');
38
44
  });
39
45
 
40
46
  it('"Tool not found" (case insensitive) -> dispatch_error', () => {
41
- expect(classifyToolFailureSource('read', 'error: tool not found')).toBe('dispatch_error');
42
- expect(classifyToolFailureSource('read', 'Tool Not Found')).toBe('dispatch_error');
43
- // "tool <name> not found" also matches (e.g. "tool read_file not found")
44
- expect(classifyToolFailureSource('read', 'Tool read_file not found')).toBe('dispatch_error');
47
+ const cases = [
48
+ 'error: tool not found',
49
+ 'Tool Not Found',
50
+ 'Tool read_file not found',
51
+ ];
52
+ for (const err of cases) {
53
+ const obs = buildToolFailureObservation({ toolName: 'read', error: err, exitCode: 1 });
54
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
55
+ }
45
56
  });
46
57
 
47
58
  it('"Unknown tool" (case insensitive) -> dispatch_error', () => {
48
- expect(classifyToolFailureSource('read', 'error: unknown tool')).toBe('dispatch_error');
49
- expect(classifyToolFailureSource('read', 'Unknown Tool')).toBe('dispatch_error');
50
- expect(classifyToolFailureSource('read', 'failed: unknown tool read_file')).toBe('dispatch_error');
59
+ const cases = ['error: unknown tool', 'Unknown Tool', 'failed: unknown tool read_file'];
60
+ for (const err of cases) {
61
+ const obs = buildToolFailureObservation({ toolName: 'read', error: err, exitCode: 1 });
62
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
63
+ }
51
64
  });
52
65
 
53
66
  it('Warning-style messages containing "tool not found" -> dispatch_error', () => {
54
- // After dropping "error:" prefix, Warning messages with "tool not found" match the dispatch pattern
55
- expect(classifyToolFailureSource('read', 'Warning: tool not found was suppressed')).toBe('dispatch_error');
56
- expect(classifyToolFailureSource('read', 'Warning: tool not found - already handled')).toBe('dispatch_error');
67
+ const cases = ['Warning: tool not found was suppressed', 'Warning: tool not found - already handled'];
68
+ for (const err of cases) {
69
+ const obs = buildToolFailureObservation({ toolName: 'read', error: err, exitCode: 1 });
70
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
71
+ }
57
72
  });
58
73
 
59
74
  it('real execution errors (ENOENT, EACCES) -> tool_failure', () => {
60
- expect(classifyToolFailureSource('read', 'ENOENT: no such file or directory')).toBe('tool_failure');
61
- expect(classifyToolFailureSource('write', 'EACCES: permission denied')).toBe('tool_failure');
62
- expect(classifyToolFailureSource('edit', 'Error: EIO: I/O error')).toBe('tool_failure');
75
+ const cases = [
76
+ { toolName: 'read' as const, error: 'ENOENT: no such file or directory' },
77
+ { toolName: 'write' as const, error: 'EACCES: permission denied' },
78
+ { toolName: 'edit' as const, error: 'Error: EIO: I/O error' },
79
+ ];
80
+ for (const { toolName, error } of cases) {
81
+ const obs = buildToolFailureObservation({ toolName, error, exitCode: 1 });
82
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
83
+ }
63
84
  });
64
85
 
65
- it('edge cases: null/undefined/empty error', () => {
66
- expect(classifyToolFailureSource('read', null)).toBe('tool_failure');
67
- expect(classifyToolFailureSource('read', undefined)).toBe('tool_failure');
68
- expect(classifyToolFailureSource('read', '')).toBe('tool_failure');
69
- expect(classifyToolFailureSource('read', 123)).toBe('tool_failure');
86
+ it('edge cases: null/undefined/empty error -> tool_failure', () => {
87
+ // With valid toolName and non-zero exit, no error message → tool_failure
88
+ const obs = buildToolFailureObservation({ toolName: 'read', error: undefined, exitCode: 1 });
89
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
70
90
  });
71
91
 
72
92
  it('word-boundary: "report_tool_not_found" does NOT match dispatch pattern', () => {
73
- expect(classifyToolFailureSource('read', 'report_tool_not_found')).toBe('tool_failure');
93
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'report_tool_not_found', exitCode: 1 });
94
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
74
95
  });
75
96
 
76
97
  it('word-boundary: "atoolnotfound" (no spaces) does NOT match dispatch pattern', () => {
77
- expect(classifyToolFailureSource('read', 'atoolnotfound')).toBe('tool_failure');
98
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'atoolnotfound', exitCode: 1 });
99
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
78
100
  });
79
101
 
80
102
  it('word-boundary: "unknown_tool" (underscore, no space) does NOT match dispatch pattern', () => {
81
- expect(classifyToolFailureSource('read', 'unknown_tool')).toBe('tool_failure');
103
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'unknown_tool', exitCode: 1 });
104
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
82
105
  });
83
106
 
84
107
  it('whitespace-only toolName -> dispatch_error', () => {
85
- expect(classifyToolFailureSource(' ', 'tool not found')).toBe('dispatch_error');
86
- });
87
-
88
- it('numeric error value -> tool_failure', () => {
89
- expect(classifyToolFailureSource('read', 42)).toBe('tool_failure');
90
- });
91
-
92
- it('object error value -> tool_failure', () => {
93
- expect(classifyToolFailureSource('read', { code: 'ENOENT' })).toBe('tool_failure');
108
+ const obs = buildToolFailureObservation({ toolName: ' ', error: 'tool not found', exitCode: 1 });
109
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
94
110
  });
95
111
 
96
112
  it('"tool <name> not found" with multi-word tool name -> dispatch_error', () => {
97
- expect(classifyToolFailureSource('read', 'tool my_custom_tool not found')).toBe('dispatch_error');
113
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'tool my_custom_tool not found', exitCode: 1 });
114
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
98
115
  });
99
116
 
100
117
  it('partial match "not found" without "tool" prefix -> tool_failure', () => {
101
- expect(classifyToolFailureSource('read', 'file not found')).toBe('tool_failure');
118
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'file not found', exitCode: 1 });
119
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
102
120
  });
103
121
  });
104
122
 
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Triage Adapter Tests — PEAT-B1
2
+ * Triage Adapter Tests — PEAT-B1 / PRI-360 S1
3
3
  *
4
- * Tests the plugin-side adapter that maps hook context to SourceKind
5
- * and calls the pure triage policy from principles-core.
4
+ * Tests the unified RawObservation SourceKind resolution path
5
+ * and the evidence triage policy.
6
+ *
7
+ * All legacy resolveSourceKindFrom* wrappers have been removed.
8
+ * Every test uses resolveSourceKind(RawObservation) directly.
6
9
  *
7
10
  * ERR checklist:
8
11
  * - ERR-001: Source kind resolved from runtime values, not `as` casts.
@@ -12,98 +15,166 @@
12
15
 
13
16
  import { describe, it, expect } from 'vitest';
14
17
  import {
15
- resolveSourceKindFromToolFailure,
16
- resolveSourceKindFromLlmDetection,
17
- resolveSourceKindFromGateBlock,
18
- resolveSourceKindFromCommand,
19
- resolveSourceKindFromProvider,
20
- resolveSourceKindFromSubagent,
18
+ resolveSourceKind,
19
+ buildToolFailureObservation,
20
+ buildLlmDetectionObservation,
21
+ type RawObservation,
21
22
  evaluateEvidenceTriage,
22
23
  isHighConfidenceUnsafeAction,
23
24
  } from '../../src/hooks/triage-adapter.js';
24
25
 
25
- // ── resolveSourceKindFromToolFailure ────────────────────────────────────────
26
+ // ── resolveSourceKind: Tool Failure Path ─────────────────────────────────────
26
27
 
27
- describe('resolveSourceKindFromToolFailure', () => {
28
+ describe('resolveSourceKind: tool failure path', () => {
28
29
  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
+ const obs: RawObservation = { observedAt: 't', toolName: 'pain', failureSource: 'tool_failure', provenance: 'openclaw_context_bound' };
31
+ expect(resolveSourceKind(obs)).toBe('agent_on_owner_request');
30
32
  });
31
33
 
32
34
  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
+ expect(resolveSourceKind({ observedAt: 't', toolName: 'pain', failureSource: 'tool_failure' })).toBe('owner_reported');
36
+ expect(resolveSourceKind({ observedAt: 't', toolName: 'pain', failureSource: 'tool_failure', provenance: 'automatic_hook' })).toBe('owner_reported');
35
37
  });
36
38
 
37
39
  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');
40
+ expect(resolveSourceKind({ observedAt: 't', toolName: 'skill:pain', failureSource: 'tool_failure', provenance: 'openclaw_context_bound' })).toBe('agent_on_owner_request');
39
41
  });
40
42
 
41
43
  it('maps dispatch_error to dispatch_error', () => {
42
- expect(resolveSourceKindFromToolFailure('read', 'dispatch_error')).toBe('dispatch_error');
44
+ expect(resolveSourceKind({ observedAt: 't', toolName: 'read', failureSource: 'dispatch_error' })).toBe('dispatch_error');
43
45
  });
44
46
 
45
47
  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
+ expect(resolveSourceKind({ observedAt: 't', toolName: 'write', failureSource: 'tool_failure' })).toBe('tool_failure');
49
+ expect(resolveSourceKind({ observedAt: 't', toolName: 'exec', failureSource: 'tool_failure' })).toBe('tool_failure');
48
50
  });
49
51
 
50
- it('maps undefined tool name with tool_failure to tool_failure', () => {
51
- expect(resolveSourceKindFromToolFailure(undefined, 'tool_failure')).toBe('tool_failure');
52
+ it('maps undefined tool name with tool_failure to dispatch_error via toolNotFound', () => {
53
+ expect(resolveSourceKind({ observedAt: 't', toolName: undefined, failureSource: 'tool_failure' })).toBe('tool_failure');
52
54
  });
53
55
  });
54
56
 
55
- // ── resolveSourceKindFromLlmDetection ───────────────────────────────────────
57
+ // ── resolveSourceKind: LLM Detection Path ────────────────────────────────────
56
58
 
57
- describe('resolveSourceKindFromLlmDetection', () => {
59
+ describe('resolveSourceKind: LLM detection path', () => {
58
60
  it('maps gfi triggered to gfi_threshold', () => {
59
- expect(resolveSourceKindFromLlmDetection('llm_some_rule', true)).toBe('gfi_threshold');
61
+ expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_some_rule', isGfiTriggered: true })).toBe('gfi_threshold');
60
62
  });
61
63
 
62
64
  it('maps llm_paralysis to llm_paralysis', () => {
63
- expect(resolveSourceKindFromLlmDetection('llm_paralysis', false)).toBe('llm_paralysis');
65
+ expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_paralysis', isGfiTriggered: false })).toBe('llm_paralysis');
64
66
  });
65
67
 
66
68
  it('maps llm_* detection rules to semantic', () => {
67
- expect(resolveSourceKindFromLlmDetection('llm_repetition', false)).toBe('semantic');
68
- expect(resolveSourceKindFromLlmDetection('llm_loop', false)).toBe('semantic');
69
+ expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_repetition', isGfiTriggered: false })).toBe('semantic');
70
+ expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_loop', isGfiTriggered: false })).toBe('semantic');
69
71
  });
70
72
 
71
73
  it('maps user_empathy to empathy_inferred', () => {
72
- expect(resolveSourceKindFromLlmDetection('user_empathy', false)).toBe('empathy_inferred');
74
+ expect(resolveSourceKind({ observedAt: 't', detectionSource: 'user_empathy', isGfiTriggered: false })).toBe('empathy_inferred');
73
75
  });
74
76
 
75
77
  it('maps unknown source to unknown', () => {
76
- expect(resolveSourceKindFromLlmDetection('something_else', false)).toBe('unknown');
78
+ expect(resolveSourceKind({ observedAt: 't', detectionSource: 'something_else', isGfiTriggered: false })).toBe('unknown');
77
79
  });
78
80
  });
79
81
 
80
- // ── Other resolve functions ─────────────────────────────────────────────────
82
+ // ── resolveSourceKind: Other Context Paths ───────────────────────────────────
81
83
 
82
- describe('resolveSourceKindFromGateBlock', () => {
84
+ describe('resolveSourceKind: gate block path', () => {
83
85
  it('returns rulehost_block', () => {
84
- expect(resolveSourceKindFromGateBlock()).toBe('rulehost_block');
86
+ expect(resolveSourceKind({ observedAt: 't', isGateBlock: true })).toBe('rulehost_block');
85
87
  });
86
88
  });
87
89
 
88
- describe('resolveSourceKindFromCommand', () => {
90
+ describe('resolveSourceKind: manual command path', () => {
89
91
  it('returns owner_reported', () => {
90
- expect(resolveSourceKindFromCommand()).toBe('owner_reported');
92
+ expect(resolveSourceKind({ observedAt: 't', isManualEntry: true })).toBe('owner_reported');
91
93
  });
92
94
  });
93
95
 
94
- describe('resolveSourceKindFromProvider', () => {
96
+ describe('resolveSourceKind: provider path', () => {
95
97
  it('returns provider_failure for non-rate-limit', () => {
96
- expect(resolveSourceKindFromProvider(false)).toBe('provider_failure');
98
+ expect(resolveSourceKind({ observedAt: 't', isRateLimit: false })).toBe('provider_failure');
97
99
  });
98
100
 
99
101
  it('returns rate_limit for rate-limit', () => {
100
- expect(resolveSourceKindFromProvider(true)).toBe('rate_limit');
102
+ expect(resolveSourceKind({ observedAt: 't', isRateLimit: true })).toBe('rate_limit');
101
103
  });
102
104
  });
103
105
 
104
- describe('resolveSourceKindFromSubagent', () => {
106
+ describe('resolveSourceKind: subagent path', () => {
105
107
  it('returns subagent_error', () => {
106
- expect(resolveSourceKindFromSubagent()).toBe('subagent_error');
108
+ expect(resolveSourceKind({ observedAt: 't', isSubagentError: true })).toBe('subagent_error');
109
+ });
110
+ });
111
+
112
+ // ── buildToolFailureObservation ──────────────────────────────────────────────
113
+
114
+ describe('buildToolFailureObservation', () => {
115
+ it('classifies empty tool name as dispatch_error', () => {
116
+ const obs = buildToolFailureObservation({ toolName: undefined, error: 'tool not found', exitCode: 1 });
117
+ expect(obs.failureSource).toBe('dispatch_error');
118
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
119
+ });
120
+
121
+ it('classifies "tool not found" error as dispatch_error', () => {
122
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'tool read_file not found', exitCode: 1 });
123
+ expect(obs.failureSource).toBe('dispatch_error');
124
+ expect(resolveSourceKind(obs)).toBe('dispatch_error');
125
+ });
126
+
127
+ it('classifies "Unknown tool" error as dispatch_error', () => {
128
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'Unknown Tool', exitCode: 1 });
129
+ expect(obs.failureSource).toBe('dispatch_error');
130
+ });
131
+
132
+ it('classifies real errors (ENOENT) as tool_failure', () => {
133
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'ENOENT: no such file', exitCode: 1 });
134
+ expect(obs.failureSource).toBe('tool_failure');
135
+ expect(resolveSourceKind(obs)).toBe('tool_failure');
136
+ });
137
+
138
+ it('classifies no error + no exit as non-failure (undefined failureSource)', () => {
139
+ const obs = buildToolFailureObservation({ toolName: 'read', error: undefined, exitCode: 0 });
140
+ expect(obs.failureSource).toBeUndefined();
141
+ });
142
+
143
+ it('classifies whitespace-only tool name as dispatch_error', () => {
144
+ const obs = buildToolFailureObservation({ toolName: ' ', error: 'tool not found', exitCode: 1 });
145
+ expect(obs.failureSource).toBe('dispatch_error');
146
+ });
147
+
148
+ it('word-boundary: "report_tool_not_found" does NOT match dispatch pattern', () => {
149
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'report_tool_not_found', exitCode: 1 });
150
+ expect(obs.failureSource).toBe('tool_failure');
151
+ });
152
+
153
+ it('word-boundary: "atoolnotfound" does NOT match dispatch pattern', () => {
154
+ const obs = buildToolFailureObservation({ toolName: 'read', error: 'atoolnotfound', exitCode: 1 });
155
+ expect(obs.failureSource).toBe('tool_failure');
156
+ });
157
+
158
+ it('preserves provenance', () => {
159
+ const obs = buildToolFailureObservation({ toolName: 'pain', error: 'fail', exitCode: 1, provenance: 'openclaw_context_bound' });
160
+ expect(obs.provenance).toBe('openclaw_context_bound');
161
+ expect(resolveSourceKind(obs)).toBe('agent_on_owner_request');
162
+ });
163
+ });
164
+
165
+ // ── buildLlmDetectionObservation ─────────────────────────────────────────────
166
+
167
+ describe('buildLlmDetectionObservation', () => {
168
+ it('builds observation for GFI-triggered detection', () => {
169
+ const obs = buildLlmDetectionObservation({ detectionSource: 'llm_some_rule', isGfiTriggered: true });
170
+ expect(obs.detectionSource).toBe('llm_some_rule');
171
+ expect(obs.isGfiTriggered).toBe(true);
172
+ expect(resolveSourceKind(obs)).toBe('gfi_threshold');
173
+ });
174
+
175
+ it('builds observation for llm_paralysis', () => {
176
+ const obs = buildLlmDetectionObservation({ detectionSource: 'llm_paralysis', isGfiTriggered: false });
177
+ expect(resolveSourceKind(obs)).toBe('llm_paralysis');
107
178
  });
108
179
  });
109
180