principles-disciple 1.93.0 → 1.95.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/src/hooks/pain.ts CHANGED
@@ -1,43 +1,52 @@
1
+ /**
2
+ * Pain Hook — PRI-326 decomposed
3
+ *
4
+ * After-tool-call hook that captures tool failures and emits pain signals.
5
+ *
6
+ * Pipeline stages (delegated to after-tool-call-helpers):
7
+ * 1. classifyToolCallOutcome — determine failure/success + source
8
+ * 2. buildToolCallObservation — normalize event into observation
9
+ * 3. handleFrictionTracking — GFI, event log, trajectory recording
10
+ * 4. handleProbationFeedback — probation attribution + cleanup
11
+ * 5. evaluatePainAdmission — triage + gate evaluation
12
+ * 6. emitPainIfAdmitted — pain signal emission
13
+ *
14
+ * The manual pain path (toolName === 'pain') remains inline because it
15
+ * has a different control flow (early return, no triage, no gate on cooldown).
16
+ */
17
+
1
18
  import * as fs from 'fs';
2
- import { isRisky, normalizePath } from '../utils/io.js';
3
19
  import { normalizeProfile } from '../core/profile.js';
4
- import { computePainScore, trackPrincipleValue } from '../core/pain.js';
5
- import { getSession, trackFriction, resetFriction, getInjectedProbationIds, clearInjectedProbationIds, type SessionState } from '../core/session-tracker.js';
6
- import { denoiseError, computeHash } from '../utils/hashing.js';
20
+ import { getSession, trackFriction } from '../core/session-tracker.js';
21
+ import { computeHash } from '../utils/hashing.js';
7
22
  import { SystemLogger } from '../core/system-logger.js';
8
23
  import { WorkspaceContext } from '../core/workspace-context.js';
9
24
  import { getEvolutionLogger, createTraceId } from '../core/evolution-logger.js';
10
- import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolution-engine.js';
11
25
  import type { EvolutionLoopEvent } from '../core/evolution-types.js';
12
26
  import type { PluginHookAfterToolCallEvent, PluginHookToolContext, OpenClawPluginApi } from '../openclaw-sdk.js';
13
27
  import { resolveWorkspaceDirForRuntimeV2 } from '../utils/workspace-resolver.js';
14
- import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData, type PainEvidenceEntry, MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_NOTE_CHARS } from '@principles/core/runtime-v2';
28
+ import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData } from '@principles/core/runtime-v2';
15
29
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.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';
30
+ import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
19
31
 
20
- /**
21
- * Interface for tool parameters to avoid 'any'
22
- */
23
- interface ToolParams {
24
- file_path?: string;
25
- path?: string;
26
- file?: string;
27
- content?: string;
28
- new_string?: string;
29
- text?: string;
30
- query?: string;
31
- input?: string;
32
- arguments?: string;
33
- }
32
+ import {
33
+ classifyToolCallOutcome,
34
+ buildToolCallObservation,
35
+ handleFrictionTrackingForFailure,
36
+ handleFrictionTrackingForSuccess,
37
+ recordHygieneTracking,
38
+ handleProbationFeedback,
39
+ evaluatePainAdmissionForToolCall,
40
+ emitPainIfAdmitted,
41
+ } from './after-tool-call-helpers.js';
34
42
 
35
- const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'edit_file', 'replace'];
43
+ import { buildTrajectoryEvidence } from './trajectory-evidence.js';
44
+ export { buildTrajectoryEvidence };
45
+
46
+ // ── Service Factory ─────────────────────────────────────────────────────────
36
47
 
37
48
  function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleService {
38
49
  const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir: wctx.stateDir });
39
- // PRI-306: Load .pd/config.yaml and pass effectiveConfig to PainToPrincipleService
40
- // so config-driven runtime binding resolution is used.
41
50
  const configResult = loadPdConfigForPlugin(wctx.workspaceDir);
42
51
  return new PainToPrincipleService({
43
52
  workspaceDir: wctx.workspaceDir,
@@ -50,64 +59,9 @@ function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleSe
50
59
  });
51
60
  }
52
61
 
53
- export function buildTrajectoryEvidence(wctx: WorkspaceContext, sessionId: string): PainEvidenceEntry[] {
54
- const evidence: PainEvidenceEntry[] = [];
62
+ // buildTrajectoryEvidence is in ./trajectory-evidence.ts (re-exported above)
55
63
 
56
- if (!wctx.trajectory || sessionId === 'unknown') {
57
- evidence.push({
58
- sourceRef: 'owner_message:unavailable',
59
- note: `trajectory_unavailable: ${!wctx.trajectory ? 'no_trajectory_db' : 'unknown_session'}`,
60
- });
61
- return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
62
- }
63
-
64
- try {
65
- const userTurns = wctx.trajectory.listUserTurnsForSession(sessionId) ?? [];
66
- const lastCorrectionTurn = [...userTurns].reverse().find(t => t.correctionDetected);
67
- if (lastCorrectionTurn) {
68
- const sanitizedOwnerMessage = sanitizeAssistantText(
69
- (lastCorrectionTurn.rawExcerpt ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
70
- );
71
- evidence.push({
72
- sourceRef: `owner_message:${lastCorrectionTurn.createdAt}`,
73
- note: sanitizedOwnerMessage,
74
- });
75
- }
76
- } catch (e) {
77
- evidence.push({
78
- sourceRef: 'owner_message:unavailable',
79
- note: `trajectory_user_turns_unavailable: ${String(e).slice(0, 100)}`,
80
- });
81
- }
82
-
83
- try {
84
- const assistantTurns = wctx.trajectory.listAssistantTurns(sessionId) ?? [];
85
- const recentAssistant = assistantTurns.slice(-3);
86
- for (const turn of recentAssistant) {
87
- if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
88
- const sanitizedNote = sanitizeAssistantText(
89
- (turn.sanitizedText ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
90
- );
91
- evidence.push({
92
- sourceRef: `agent_turn:${turn.createdAt}`,
93
- note: sanitizedNote,
94
- });
95
- }
96
- } catch (e) {
97
- if (evidence.length < MAX_EVIDENCE_ENTRIES) {
98
- evidence.push({
99
- sourceRef: 'agent_turn:unavailable',
100
- note: `trajectory_assistant_turns_unavailable: ${String(e).slice(0, 100)}`,
101
- });
102
- }
103
- }
104
-
105
- return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
106
- }
107
-
108
- function shouldAttributePrincipleToTool(principle: { contextTags: string[]; trigger: string; }, toolName: string): boolean {
109
- return principle.contextTags.includes(toolName) || principle.trigger.includes(toolName);
110
- }
64
+ // ── Pain Event Emission ─────────────────────────────────────────────────────
111
65
 
112
66
  export async function emitPainDetectedEvent(wctx: WorkspaceContext, event: EvolutionLoopEvent): Promise<void> {
113
67
  try {
@@ -160,39 +114,51 @@ function createPainId(sessionId: string): string {
160
114
  return `pain_${Date.now()}_${computeHash(sessionId).slice(0, 8)}`;
161
115
  }
162
116
 
163
- export function classifyToolFailureSource(toolName: string | undefined, error: unknown): 'dispatch_error' | 'tool_failure' {
164
- if (!toolName || toolName.trim() === '') return 'dispatch_error';
165
- const msg = String(error ?? '');
166
- // Dropped "error:" prefix to catch "failed: unknown tool read_file" style messages.
167
- // Catches: "tool not found", "tool <name> not found", "unknown tool".
168
- // Word-boundary anchors prevent "report_tool_not_found" from matching.
169
- if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg)) return 'dispatch_error';
170
- if (/\bunknown\s+tool\b/i.test(msg)) return 'dispatch_error';
171
- return 'tool_failure';
172
- }
117
+ // ── Source Classification (re-exported from helpers) ────────────────────────
118
+
119
+ export { classifyToolFailureSource } from './after-tool-call-helpers.js';
120
+
121
+ // ── Main Hook ───────────────────────────────────────────────────────────────
173
122
 
123
+ /**
124
+ * Handle after_tool_call hook — decomposed into pipeline stages.
125
+ *
126
+ * Pipeline: classify → record → triage → gate → emit
127
+ *
128
+ * Manual pain (toolName === 'pain') is handled inline with early return.
129
+ */
174
130
  export function handleAfterToolCall(
175
131
  event: PluginHookAfterToolCallEvent,
176
132
  ctx: PluginHookToolContext & { workspaceDir?: string; pluginConfig?: Record<string, unknown> },
177
133
  api?: OpenClawPluginApi
178
134
  ): void {
135
+ // ── Workspace Resolution ──
179
136
  let effectiveWorkspaceDir: string;
180
137
  try {
181
138
  effectiveWorkspaceDir = resolveWorkspaceDirForRuntimeV2(ctx, api, 'after_tool_call');
182
- } catch {
139
+ } catch (error) {
140
+ SystemLogger.log(
141
+ (ctx as any).workspaceDir ?? 'unknown',
142
+ 'WORKSPACE_RESOLUTION_FAILED',
143
+ JSON.stringify({
144
+ hook: 'after_tool_call',
145
+ sessionId: ctx.sessionId ?? 'unknown',
146
+ toolName: event.toolName,
147
+ reason: 'workspace_resolution_failed',
148
+ nextAction: 'check_plugin_config_workspace_resolution',
149
+ error: String(error).slice(0, 200),
150
+ }),
151
+ );
183
152
  return;
184
153
  }
185
154
 
186
155
  const wctx = WorkspaceContext.fromHookContextExplicit({ ...ctx, workspaceDir: effectiveWorkspaceDir });
187
- const {config} = wctx;
188
- const {eventLog} = wctx;
156
+ const { config } = wctx;
189
157
  const sessionId = ctx.sessionId || 'unknown';
190
158
  const sessionState = ctx.sessionId ? getSession(ctx.sessionId) : undefined;
191
159
  const gfiBefore = sessionState?.currentGfi ?? 0;
192
- let latestFailureState: SessionState | undefined;
193
- const params = event.params as ToolParams;
194
160
 
195
- // Load profile once (with 1MB size guard) — used by both failure and legacy risky-write paths
161
+ // ── Profile Loading (once per call) ──
196
162
  const profilePath = wctx.resolve('PROFILE');
197
163
  let profile = normalizeProfile({});
198
164
  if (fs.existsSync(profilePath)) {
@@ -208,428 +174,156 @@ export function handleAfterToolCall(
208
174
  }
209
175
  }
210
176
 
211
- // ── Track A: Empirical Friction (GFI) ──
212
-
213
- // 0. Special Case: Manual Pain Intervention
177
+ // ── Manual Pain Early Return ──
214
178
  if (event.toolName === 'pain' || event.toolName === 'skill:pain') {
215
- const reason = params.input || params.arguments || 'Manual intervention';
216
- const traceId = createTraceId();
217
- trackFriction(sessionId, 100, 'manual_pain', effectiveWorkspaceDir, { source: 'manual_pain' });
218
- SystemLogger.log(effectiveWorkspaceDir, 'MANUAL_PAIN', `User manually triggered pain: ${reason}`);
219
- eventLog.recordPainSignal(sessionId, {
220
- score: 100,
221
- source: 'manual',
222
- reason: `User intervention: ${reason}`,
223
- isRisky: true
224
- });
225
- wctx.trajectory?.recordPainEvent?.({
226
- sessionId,
227
- source: 'manual',
228
- score: 100,
229
- reason: `User intervention: ${reason}`,
230
- origin: 'user_manual',
231
- text: reason, // Store the intervention reason as text
232
- });
179
+ handleManualPain(event, ctx, wctx, effectiveWorkspaceDir, sessionId);
180
+ return;
181
+ }
233
182
 
234
- // Log to EvolutionLogger
235
- const evoLogger = getEvolutionLogger(effectiveWorkspaceDir, wctx.trajectory);
236
- evoLogger.logPainDetected({
237
- traceId,
238
- source: 'manual',
239
- reason: `User intervention: ${reason}`,
240
- score: 100,
241
- toolName: event.toolName,
242
- sessionId,
243
- });
183
+ // ── Stage 1: Classify ──
184
+ const outcome = classifyToolCallOutcome(event);
244
185
 
245
- // Apply PainDiagnosticGate with cooldown to prevent duplicate diagnoses
246
- const session = getSession(sessionId);
247
- const gate = evaluatePainDiagnosticGate({
248
- source: 'manual',
249
- score: 100,
250
- currentGfi: session?.currentGfi ?? 0,
251
- sessionId,
252
- });
253
- if (!gate.shouldDiagnose) {
254
- SystemLogger.log(effectiveWorkspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
255
- let payload: string;
256
- try {
257
- payload = JSON.stringify({
258
- reason: gate.reason,
259
- detail: gate.detail,
260
- source: 'manual',
261
- sessionId,
262
- gfi: 0,
263
- score: 100,
264
- });
265
- } catch (e) {
266
- SystemLogger.log(effectiveWorkspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
267
- payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
268
- }
269
- SystemLogger.log(effectiveWorkspaceDir, 'PAIN_GATE_REJECTED', payload);
270
- return;
271
- }
186
+ // ── Stage 2: Build Observation ──
187
+ const observation = buildToolCallObservation(event, outcome, effectiveWorkspaceDir, profile);
272
188
 
273
- emitPainDetectedEvent(wctx, {
274
- ts: new Date().toISOString(),
275
- type: 'pain_detected',
276
- data: {
277
- painId: createPainId(sessionId),
278
- painType: 'user_frustration',
279
- source: event.toolName,
280
- reason: `User intervention: ${reason}`,
281
- score: 100,
282
- sessionId,
283
- traceId,
284
- agentId: ctx.agentId,
285
- provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
286
- evidence: buildTrajectoryEvidence(wctx, sessionId),
287
- },
288
- });
289
- return;
290
- }
189
+ let latestFailureState: import('../core/session-tracker.js').SessionState | undefined;
291
190
 
292
- // 1. Determine if this was a failure
293
- // Support nested details structure where OpenClaw exec tool stores exitCode in result.details.exitCode
294
- // Prefer the first *numeric* exit code: if result.exitCode is non-numeric, fall back to details.exitCode
295
- const resultObj = (event.result && typeof event.result === 'object') ? event.result as Record<string, unknown> : null;
296
- const details = resultObj?.details && typeof resultObj.details === 'object' ? resultObj.details as Record<string, unknown> : null;
297
- const topExitCode = resultObj?.exitCode;
298
- const detailExitCode = details?.exitCode;
299
- const exitCode = typeof topExitCode === 'number' ? topExitCode
300
- : typeof detailExitCode === 'number' ? detailExitCode
301
- : 0;
302
- const isFailure = !!event.error || exitCode !== 0;
303
-
304
- if (isFailure) {
305
- const failureSource = classifyToolFailureSource(event.toolName, event.error);
306
- const errorText = String(event.error ?? (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)));
307
- const denoised = denoiseError(errorText);
308
- const hash = computeHash(denoised);
309
-
310
- const deltaF = config.get('scores.tool_failure_friction') || 30;
311
- const updatedState = trackFriction(sessionId, deltaF, hash, effectiveWorkspaceDir, { source: failureSource });
312
- latestFailureState = updatedState;
313
-
314
- // ── Trust Engine: Record failure ──
315
-
316
-
317
- const errorType = extractErrorType(event.error || errorText);
318
- const filePath = params.file_path || params.path || params.file;
319
- const relPath = typeof filePath === 'string' ? normalizePath(filePath, effectiveWorkspaceDir) : 'unknown';
320
-
321
- // Use profile loaded at function scope (1MB guard already applied)
322
- const isRisk = isRisky(relPath, profile.risk_paths);
323
-
324
- recordEvolutionFailure(effectiveWorkspaceDir, event.toolName, {
325
- filePath: relPath,
326
- reason: isRisk ? 'risky' : 'tool',
327
- sessionId,
328
- });
329
-
330
- // Record tool call failure event
331
- eventLog.recordToolCall(sessionId, {
332
- toolName: event.toolName,
333
- filePath: typeof filePath === 'string' ? filePath : undefined,
334
- error: event.error ? String(event.error).substring(0, 200) : undefined,
335
- errorType,
336
- gfi: updatedState.currentGfi,
337
- consecutiveErrors: updatedState.consecutiveErrors,
338
- exitCode: exitCode as number | undefined,
339
- gfiBefore,
340
- gfiAfter: updatedState.currentGfi,
341
- });
342
- wctx.trajectory?.recordToolCall?.({
343
- sessionId,
344
- toolName: event.toolName,
345
- outcome: 'failure',
346
- durationMs: event.durationMs,
347
- exitCode: exitCode as number | undefined,
348
- errorType,
349
- errorMessage: event.error ? String(event.error) : undefined,
350
- gfiBefore,
351
- gfiAfter: updatedState.currentGfi,
352
- paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
353
- });
354
-
355
- const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
356
- for (const id of injectedProbationIds) {
357
- const principle = wctx.evolutionReducer.getPrincipleById(id);
358
- const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
359
- if (shouldAttribute) {
360
- wctx.evolutionReducer.recordProbationFeedback(id, false);
361
- }
362
- }
363
- clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
191
+ if (outcome.isFailure) {
192
+ // ── Stage 3a: Friction + Recording (Failure) ──
193
+ latestFailureState = handleFrictionTrackingForFailure(
194
+ sessionId, event, outcome, observation, gfiBefore, effectiveWorkspaceDir, config, wctx
195
+ );
196
+
197
+ // ── Stage 4: Probation Feedback (Failure) ──
198
+ handleProbationFeedback(sessionId, event.toolName, effectiveWorkspaceDir, wctx, false);
364
199
  } else {
365
- // ── SUCCESS BRANCH ──
366
- // PRI-80: Relieve both dispatch_error and tool_failure on success.
367
- // This prevents "read file success" from wiping dispatch error signals.
368
- const session = getSession(sessionId);
369
- const toolFailureGfi = session?.gfiBySource?.tool_failure || 0;
370
- const dispatchErrorGfi = session?.gfiBySource?.dispatch_error || 0;
371
-
372
- let resetState: SessionState = session || resetFriction(sessionId, effectiveWorkspaceDir);
373
- if (toolFailureGfi > 0 || dispatchErrorGfi > 0) {
374
- // Relieve both sources proportionally (50% relief each)
375
- if (toolFailureGfi > 0) {
376
- const reliefAmount = toolFailureGfi * 0.5;
377
- resetState = resetFriction(sessionId, effectiveWorkspaceDir, {
378
- source: 'tool_failure',
379
- amount: reliefAmount,
380
- });
381
- }
382
- if (dispatchErrorGfi > 0) {
383
- const reliefAmount = dispatchErrorGfi * 0.5;
384
- resetState = resetFriction(sessionId, effectiveWorkspaceDir, {
385
- source: 'dispatch_error',
386
- amount: reliefAmount,
387
- });
388
- }
389
- }
390
-
391
- recordEvolutionSuccess(effectiveWorkspaceDir, event.toolName, {
392
- sessionId,
393
- reason: 'tool_success',
394
- });
395
-
396
- const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
397
- for (const id of injectedProbationIds) {
398
- const principle = wctx.evolutionReducer.getPrincipleById(id);
399
- const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
400
- if (shouldAttribute) {
401
- wctx.evolutionReducer.recordProbationFeedback(id, true);
402
- }
403
- }
404
- clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
405
- wctx.trajectory?.recordToolCall?.({
406
- sessionId,
407
- toolName: event.toolName,
408
- outcome: 'success',
409
- durationMs: event.durationMs,
410
- exitCode,
411
- gfiBefore,
412
- gfiAfter: resetState.currentGfi,
413
- paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
414
- });
415
-
416
- const filePath = params.file_path || params.path || params.file;
417
- eventLog.recordToolCall(sessionId, {
418
- toolName: event.toolName,
419
- filePath: typeof filePath === 'string' ? filePath : undefined,
420
- gfi: resetState.currentGfi,
421
- gfiBefore,
422
- gfiAfter: resetState.currentGfi,
423
- });
424
-
425
- // ── Hygiene Tracking: Record persistence actions ──
426
- const normalized = typeof filePath === 'string' ? filePath.replace(/\\/g, '/') : '';
427
- const isMemory = /(?:^|\/)memory\//.test(normalized) || normalized.endsWith('/MEMORY.md') || normalized === 'MEMORY.md';
428
- const isPlan = normalized === 'PLAN.md' || normalized.endsWith('/PLAN.md');
429
-
430
- if (isMemory || isPlan) {
431
- const content = params.content || params.new_string || '';
432
- wctx.hygiene.recordPersistence({
433
- ts: new Date().toISOString(),
434
- tool: event.toolName,
435
- path: typeof filePath === 'string' ? filePath : 'unknown',
436
- type: isMemory ? 'memory' : 'plan',
437
- contentLength: content.length,
438
- });
439
- }
200
+ // ── Stage 3b: Friction + Recording (Success) ──
201
+ handleFrictionTrackingForSuccess(
202
+ sessionId, event, outcome, observation, gfiBefore, effectiveWorkspaceDir, wctx
203
+ );
440
204
 
441
- // Special case for memory_store tool (Success only)
442
- if (event.toolName === 'memory_store') {
443
- const text = params.text || '';
444
- wctx.hygiene.recordPersistence({
445
- ts: new Date().toISOString(),
446
- tool: event.toolName,
447
- path: 'DATABASE',
448
- type: 'memory',
449
- contentLength: text.length,
450
- });
451
- }
205
+ // ── Stage 4: Probation Feedback (Success) ──
206
+ handleProbationFeedback(sessionId, event.toolName, effectiveWorkspaceDir, wctx, true);
207
+
208
+ // ── Stage 5b: Hygiene Tracking (Success only) ──
209
+ recordHygieneTracking(event, observation, wctx);
452
210
  }
453
211
 
454
- // ── Legacy/Risky Write Pain Logic (Unified WRITE_TOOLS) ──
455
- if (!WRITE_TOOLS.includes(event.toolName) || !isFailure) {
212
+ // ── Stage 6: Pain Admission ──
213
+ const admission = evaluatePainAdmissionForToolCall(
214
+ event, observation, outcome, latestFailureState, sessionState, sessionId, effectiveWorkspaceDir, config
215
+ );
216
+
217
+ if (admission.stage === 'not_applicable') {
456
218
  return;
457
219
  }
458
220
 
459
- const failureSource = classifyToolFailureSource(event.toolName, event.error);
221
+ // ── Stage 7: Emit Pain (only if admitted) ──
222
+ emitPainIfAdmitted(
223
+ wctx, event, observation, outcome, admission, sessionId, ctx.agentId, effectiveWorkspaceDir, emitPainDetectedEvent
224
+ );
225
+ }
460
226
 
461
- const filePath = params.file_path || params.path || params.file;
462
- const relPath = typeof filePath === 'string' ? normalizePath(filePath, effectiveWorkspaceDir) : 'unknown';
227
+ // ── Manual Pain Handler ─────────────────────────────────────────────────────
463
228
 
464
- const isRisk = isRisky(relPath, profile.risk_paths);
465
- const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
229
+ /**
230
+ * Handle manual pain intervention (toolName === 'pain' or 'skill:pain').
231
+ *
232
+ * This path is separate because:
233
+ * - It always records pain at score 100
234
+ * - It uses a different GFI track (manual_pain)
235
+ * - It has its own cooldown via PainDiagnosticGate
236
+ * - It does NOT go through evidence triage
237
+ */
238
+ function handleManualPain(
239
+ event: PluginHookAfterToolCallEvent,
240
+ ctx: PluginHookToolContext & { workspaceDir?: string },
241
+ wctx: WorkspaceContext,
242
+ workspaceDir: string,
243
+ sessionId: string,
244
+ ): void {
245
+ const rawParams = event.params;
246
+ const params: { input?: string; arguments?: string } =
247
+ (rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams))
248
+ ? rawParams as { input?: string; arguments?: string }
249
+ : {};
250
+ const reason = params.input || params.arguments || 'Manual intervention';
466
251
  const traceId = createTraceId();
467
252
 
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
- }
253
+ // Track friction at max score
254
+ trackFriction(sessionId, 100, 'manual_pain', workspaceDir, { source: 'manual_pain' });
255
+ SystemLogger.log(workspaceDir, 'MANUAL_PAIN', `User manually triggered pain: ${reason}`);
485
256
 
486
- const diagnosticGate = evaluatePainDiagnosticGate({
487
- source: failureSource,
488
- score: painScore,
489
- currentGfi: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.currentGfi ?? 0,
490
- consecutiveErrors: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.consecutiveErrors ?? 0,
491
- isRisky: isRisk,
492
- errorHash: latestFailureState?.lastErrorHash,
493
- sessionId,
494
- thresholds: {
495
- painTrigger: config.get('thresholds.pain_trigger') || 40,
496
- highSeverity: config.get('severity_thresholds.high') || 70,
497
- repeatedFailure: config.get('thresholds.stuck_loops_trigger') || 4,
498
- },
257
+ wctx.eventLog.recordPainSignal(sessionId, {
258
+ score: 100,
259
+ source: 'manual',
260
+ reason: `User intervention: ${reason}`,
261
+ isRisky: true
499
262
  });
500
263
 
501
- if (!diagnosticGate.shouldDiagnose) {
502
- SystemLogger.log(
503
- effectiveWorkspaceDir,
504
- 'PAIN_DIAGNOSE_SKIPPED',
505
- `Tool failure recorded as friction only: ${diagnosticGate.detail}; tool=${event.toolName}; path=${relPath}`,
506
- );
507
- // Structured gate rejection event for traceability
508
- let rejectPayload: string;
509
- try {
510
- rejectPayload = JSON.stringify({
511
- reason: diagnosticGate.reason,
512
- detail: diagnosticGate.detail,
513
- source: failureSource,
514
- sessionId: sessionId,
515
- gfi: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.currentGfi ?? 0,
516
- score: painScore,
517
- });
518
- } catch (e) {
519
- SystemLogger.log(effectiveWorkspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
520
- rejectPayload = JSON.stringify({ reason: diagnosticGate.reason, detail: '(log serialization failed)' });
521
- }
522
- SystemLogger.log(effectiveWorkspaceDir, 'PAIN_GATE_REJECTED', rejectPayload);
523
- return;
524
- }
525
-
526
- // Record to trajectory before Runtime V2 diagnosis so the compiler can later
527
- // resolve derivedFromPainIds to the originating failed action.
528
- wctx.trajectory?.recordPainEvent({
264
+ wctx.trajectory?.recordPainEvent?.({
529
265
  sessionId,
530
- source: failureSource,
531
- score: painScore,
532
- reason: `Tool ${event.toolName} failed on ${relPath}`,
533
- severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
534
- origin: 'system_infer',
535
- text: sanitizeForEvidence(params.text ?? params.content, effectiveWorkspaceDir) || undefined,
536
- });
537
-
538
- // Pain signal emitted via emitPainDetectedEvent below — no .pain_flag file written (M8: single-path chain)
539
-
540
- // Observe: track which principles would have prevented this pain (Phase 1, observation-only)
541
- try {
542
- trackPrincipleValue(
543
- effectiveWorkspaceDir,
544
- {
545
- reason: `Tool ${event.toolName} failed on ${relPath}. Error: ${event.error ?? 'Non-zero exit code'}`,
546
- source: failureSource,
547
- score: String(painScore),
548
- },
549
- () => wctx.evolutionReducer.getActivePrinciples().map((p) => ({
550
- id: p.id,
551
- trigger: p.trigger,
552
- valueMetrics: p.valueMetrics,
553
- })),
554
- (id, metrics) => {
555
- const principle = wctx.evolutionReducer.getPrincipleById(id);
556
- if (principle) {
557
- principle.valueMetrics = metrics;
558
- // Persist to training state (best-effort, non-critical)
559
- try {
560
- wctx.principleTreeLedger.updatePrincipleValueMetrics(id, {
561
- principleId: id,
562
- painPreventedCount: metrics.painPreventedCount,
563
- lastPainPreventedAt: metrics.lastPainPreventedAt,
564
- calculatedAt: metrics.calculatedAt,
565
- avgPainSeverityPrevented: 0,
566
- totalOpportunities: 0,
567
- adheredCount: 0,
568
- violatedCount: 0,
569
- implementationCost: 0,
570
- benefitScore: 0,
571
- });
572
- } catch (e) {
573
- // Non-critical — metrics tracked in memory
574
- SystemLogger.log(effectiveWorkspaceDir, 'METRICS_UPDATE_SKIP', String(e));
575
- }
576
- }
577
- },
578
- );
579
- } catch (e) {
580
- // Observation only — never disrupt the pain pipeline
581
- SystemLogger.log(effectiveWorkspaceDir, ' PRINCIPLE_TRACK_SKIP', String(e));
582
- }
583
-
584
- eventLog.recordPainSignal(sessionId, {
585
- score: painScore,
586
- source: failureSource,
587
- reason: `Tool ${event.toolName} failed on ${relPath}`,
588
- isRisky: isRisk,
266
+ source: 'manual',
267
+ score: 100,
268
+ reason: `User intervention: ${reason}`,
269
+ origin: 'user_manual',
270
+ text: reason,
589
271
  });
590
272
 
591
273
  // Log to EvolutionLogger
592
- const evoLogger = getEvolutionLogger(effectiveWorkspaceDir, wctx.trajectory);
274
+ const evoLogger = getEvolutionLogger(workspaceDir, wctx.trajectory);
593
275
  evoLogger.logPainDetected({
594
276
  traceId,
595
- source: failureSource,
596
- reason: `Tool ${event.toolName} failed on ${relPath}`,
597
- score: painScore,
277
+ source: 'manual',
278
+ reason: `User intervention: ${reason}`,
279
+ score: 100,
598
280
  toolName: event.toolName,
599
- filePath: relPath,
600
281
  sessionId,
601
282
  });
602
283
 
284
+ // Apply PainDiagnosticGate with cooldown
285
+ const session = getSession(sessionId);
286
+ const gate = evaluatePainDiagnosticGate({
287
+ source: 'manual',
288
+ score: 100,
289
+ currentGfi: session?.currentGfi ?? 0,
290
+ sessionId,
291
+ });
292
+
293
+ if (!gate.shouldDiagnose) {
294
+ SystemLogger.log(workspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
295
+ let payload: string;
296
+ try {
297
+ payload = JSON.stringify({
298
+ reason: gate.reason,
299
+ detail: gate.detail,
300
+ source: 'manual',
301
+ sessionId,
302
+ gfi: 0,
303
+ score: 100,
304
+ });
305
+ } catch (e) {
306
+ SystemLogger.log(workspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
307
+ payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
308
+ }
309
+ SystemLogger.log(workspaceDir, 'PAIN_GATE_REJECTED', payload);
310
+ return;
311
+ }
312
+
603
313
  emitPainDetectedEvent(wctx, {
604
314
  ts: new Date().toISOString(),
605
315
  type: 'pain_detected',
606
316
  data: {
607
317
  painId: createPainId(sessionId),
608
- painType: failureSource,
318
+ painType: 'user_frustration',
609
319
  source: event.toolName,
610
- reason: `Tool ${event.toolName} failed on ${relPath}; diagnosticGate=${diagnosticGate.reason}`,
611
- score: painScore,
320
+ reason: `User intervention: ${reason}`,
321
+ score: 100,
612
322
  sessionId,
613
323
  traceId,
614
324
  agentId: ctx.agentId,
615
- provenance: 'automatic_hook',
325
+ provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
616
326
  evidence: buildTrajectoryEvidence(wctx, sessionId),
617
327
  },
618
328
  });
619
329
  }
620
-
621
-
622
- function extractErrorType(error: unknown): string {
623
- if (!error) return 'Unknown';
624
- const msg = String(error);
625
- if (msg.includes('EACCES') || msg.includes('permission denied')) return 'EACCES';
626
- if (msg.includes('ENOENT') || msg.includes('no such file')) return 'ENOENT';
627
- if (msg.includes('EISDIR')) return 'EISDIR';
628
- if (msg.includes('ENOSPC')) return 'ENOSPC';
629
- if (msg.includes('SyntaxError')) return 'SyntaxError';
630
- if (msg.includes('TypeError')) return 'TypeError';
631
- if (msg.includes('ReferenceError')) return 'ReferenceError';
632
- if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) return 'Timeout';
633
- if (msg.includes('network') || msg.includes('ECONNREFUSED')) return 'Network';
634
- return 'Other';
635
- }