principles-disciple 1.145.3 → 1.147.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.
@@ -1,6 +1,17 @@
1
1
  /**
2
2
  * Pain Diagnostic Gate — PRI-446 thin adapter
3
3
  *
4
+ * @deprecated PRI-454 — Gate A (PainDiagnosticGate) is superseded by Gate B
5
+ * (TriggerController + EvidenceTriage). This module remains as the rollback
6
+ * path when `painEvidenceAdmission` or `painEvidenceAdmissionDefault` flags
7
+ * are OFF. Do not add new callers. New admission logic must use
8
+ * `evaluateTriggerController` from runtime-v2/evidence-triage.
9
+ *
10
+ * Disposition: Archive (do not delete) per PRI-454 plan step 6.
11
+ * Removal conditions: Both flags confirmed ON in production for 30 days,
12
+ * and all 5 MVP paths verified on Gate B. See
13
+ * docs/plans/2026-06-pain-evidence-admission-track.md.
14
+ *
4
15
  * The pure decision logic (threshold tree, cooldown comparison, episode-key
5
16
  * construction) now lives in principles-core
6
17
  * (runtime-v2/pain-gate/pain-diagnostic-gate-policy.ts). This file is the
@@ -1,6 +1,17 @@
1
1
  /**
2
2
  * Pain Diagnostic Gate — PRI-446 thin adapter
3
3
  *
4
+ * @deprecated PRI-454 — Gate A (PainDiagnosticGate) is superseded by Gate B
5
+ * (TriggerController + EvidenceTriage). This module remains as the rollback
6
+ * path when `painEvidenceAdmission` or `painEvidenceAdmissionDefault` flags
7
+ * are OFF. Do not add new callers. New admission logic must use
8
+ * `evaluateTriggerController` from runtime-v2/evidence-triage.
9
+ *
10
+ * Disposition: Archive (do not delete) per PRI-454 plan step 6.
11
+ * Removal conditions: Both flags confirmed ON in production for 30 days,
12
+ * and all 5 MVP paths verified on Gate B. See
13
+ * docs/plans/2026-06-pain-evidence-admission-track.md.
14
+ *
4
15
  * The pure decision logic (threshold tree, cooldown comparison, episode-key
5
16
  * construction) now lives in principles-core
6
17
  * (runtime-v2/pain-gate/pain-diagnostic-gate-policy.ts). This file is the
@@ -92,5 +92,6 @@ export declare function emitPainIfAdmitted(wctx: WorkspaceContext, event: Plugin
92
92
  export { buildTrajectoryEvidence } from './trajectory-evidence.js';
93
93
  /**
94
94
  * Reset trigger cooldown state (for tests).
95
+ * PRI-454: Delegates to shared cooldown Map reset.
95
96
  */
96
97
  export declare function resetTriggerCooldownForTest(): void;
@@ -19,7 +19,7 @@ import { denoiseError, computeHash } from '../utils/hashing.js';
19
19
  import { SystemLogger } from '../core/system-logger.js';
20
20
  import { getEvolutionLogger, createTraceId } from '../core/evolution-logger.js';
21
21
  import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolution-engine.js';
22
- import { isCooldownActive as isTriggerCooldownActive, markEpisodeAsDiagnosed, clearCooldownState } from './trigger-cooldown-tracker.js';
22
+ import { isSharedCooldownActive, markSharedEpisodeAsDiagnosed, resetSharedCooldownForTest } from './trigger-cooldown-tracker.js';
23
23
  import { sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
24
24
  import { resolveSourceKind, buildToolFailureObservation } from './raw-observation-adapter.js';
25
25
  import { evaluateEvidenceTriage } from './triage-adapter.js';
@@ -313,15 +313,6 @@ export function handleProbationFeedback(sessionId, toolName, workspaceDir, wctx,
313
313
  }
314
314
  // ── Stage 6: Pain Admission ─────────────────────────────────────────────────
315
315
  const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'edit_file', 'replace'];
316
- /**
317
- * Cooldown map for trigger controller decisions.
318
- *
319
- * PRI-363: This replaces the hidden map in PainDiagnosticGate.
320
- * Core trigger-controller is stateless; plugin layer owns cooldown state.
321
- *
322
- * EP-05: Loop state freshness — each check reads fresh state from this map.
323
- */
324
- const TRIGGER_COOLDOWN_MAP = new Map();
325
316
  /**
326
317
  * Evaluate whether a tool failure should trigger pain diagnosis.
327
318
  *
@@ -346,8 +337,8 @@ export function evaluatePainAdmissionForToolCall(event, observation, outcome, la
346
337
  };
347
338
  }
348
339
  const failureSource = outcome.failureSource ?? 'tool_failure';
349
- // Check cooldown before calling trigger controller
350
- const cooldownActive = isTriggerCooldownActive(failureSource, sessionId, latestFailureState?.lastErrorHash, TRIGGER_COOLDOWN_MAP);
340
+ // Check cooldown before calling trigger controller (PRI-454: shared Map)
341
+ const cooldownActive = isSharedCooldownActive(failureSource, sessionId, latestFailureState?.lastErrorHash);
351
342
  // PRI-360 S1: Build RawObservation for unified source mapping
352
343
  const rawObs = {
353
344
  observedAt: new Date().toISOString(),
@@ -386,9 +377,9 @@ export function evaluatePainAdmissionForToolCall(event, observation, outcome, la
386
377
  tool: event.toolName,
387
378
  path: observation.relPath,
388
379
  }));
389
- // If trigger controller says yes, mark cooldown and admit
380
+ // If trigger controller says yes, mark cooldown and admit (PRI-454: shared Map)
390
381
  if (triggerDecision.shouldCreateDiagnosticTask) {
391
- markEpisodeAsDiagnosed(failureSource, sessionId, latestFailureState?.lastErrorHash, TRIGGER_COOLDOWN_MAP);
382
+ markSharedEpisodeAsDiagnosed(failureSource, sessionId, latestFailureState?.lastErrorHash);
392
383
  return {
393
384
  admitted: true,
394
385
  stage: 'trigger_admitted',
@@ -507,9 +498,10 @@ export function emitPainIfAdmitted(wctx, event, observation, outcome, admission,
507
498
  export { buildTrajectoryEvidence } from './trajectory-evidence.js';
508
499
  /**
509
500
  * Reset trigger cooldown state (for tests).
501
+ * PRI-454: Delegates to shared cooldown Map reset.
510
502
  */
511
503
  export function resetTriggerCooldownForTest() {
512
- clearCooldownState(TRIGGER_COOLDOWN_MAP);
504
+ resetSharedCooldownForTest();
513
505
  }
514
506
  // ── Source Classification ────────────────────────────────────────────────────
515
507
  // classifyToolFailureSource logic is now in resolveSourceKind (PRI-360 S1)
@@ -15,6 +15,8 @@ import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
15
15
  import { emitPainDetectedEvent } from './pain.js';
16
16
  import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
17
17
  import { evaluateEvidenceTriage } from './triage-adapter.js';
18
+ import { evaluateTriggerController } from '@principles/core/runtime-v2';
19
+ import { isSharedCooldownActive, markSharedEpisodeAsDiagnosed } from './trigger-cooldown-tracker.js';
18
20
  import { isRisky } from '../utils/io.js';
19
21
  import { normalizeProfile } from '../core/profile.js';
20
22
  import { SystemLogger } from '../core/system-logger.js';
@@ -83,10 +85,14 @@ export function recordGateBlockAndReturn(wctx, blockCtx, logger) {
83
85
  // evolution.jsonl + trajectory.db (with canonicalPainId for dedup). No separate
84
86
  // legacy recordPainEvent call needed — avoids double-write to trajectory.db.
85
87
  const gatePainId = `gate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
86
- // PEAT-B1: Evidence triage (feature-flagged)
87
- let triageAdmitted = true;
88
- const gateBlockTriageFlag = loadFeatureFlagFromConfig(wctx.workspaceDir, 'painEvidenceAdmission');
89
- if (gateBlockTriageFlag.enabled) {
88
+ // PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
89
+ // When either OFF → Gate A (PainDiagnosticGate, rollback).
90
+ const triageFlag = loadFeatureFlagFromConfig(wctx.workspaceDir, 'painEvidenceAdmission');
91
+ const defaultFlag = loadFeatureFlagFromConfig(wctx.workspaceDir, 'painEvidenceAdmissionDefault');
92
+ const useGateB = triageFlag.enabled && defaultFlag.enabled;
93
+ // PEAT-B1: Evidence triage (runs when Gate B is active)
94
+ const session = getSession(sessionId);
95
+ if (useGateB) {
90
96
  // Load profile with 1MB size guard, matching pain.ts pattern
91
97
  const profilePath = wctx.resolve('PROFILE');
92
98
  let profile = normalizeProfile({});
@@ -111,16 +117,54 @@ export function recordGateBlockAndReturn(wctx, blockCtx, logger) {
111
117
  // weight, NOT the action risk severity. The rulehost principle already determined
112
118
  // this action was important enough to block — that is the real signal.
113
119
  const isUnsafe = isRisky(filePath, profile.risk_paths);
120
+ // PRI-454 P2-1: Pass consecutiveErrors and isRisky to match Gate A's
121
+ // upgrade logic. Rule 3 (consecutiveErrors >= 4 → admit) was being
122
+ // dropped, so non-risky repeated gate blocks never triggered diagnosis.
114
123
  const triage = evaluateEvidenceTriage('rulehost_block', GATE_BLOCK_PAIN_SCORE, {
115
124
  isUnsafeHighConfidence: isUnsafe,
125
+ isRisky: isUnsafe,
126
+ consecutiveErrors: session?.consecutiveErrors,
116
127
  });
117
128
  if (triage.decision !== 'admit') {
118
- triageAdmitted = false;
119
129
  logger.info?.(`[PD_GATE] Triage ${triage.decision}: ${triage.reason}`);
120
130
  }
131
+ else {
132
+ // PRI-454: Gate B path — TriggerController owns admission
133
+ const errorHash = `${toolName}:${filePath}:${reason}`;
134
+ const cooldownActive = isSharedCooldownActive('rulehost_block', sessionId, errorHash);
135
+ const triggerDecision = evaluateTriggerController({
136
+ triageResult: triage,
137
+ isOwnerManual: false,
138
+ isCooldownActive: cooldownActive,
139
+ isValid: true,
140
+ score: GATE_BLOCK_PAIN_SCORE,
141
+ sessionId,
142
+ });
143
+ if (triggerDecision.shouldCreateDiagnosticTask) {
144
+ markSharedEpisodeAsDiagnosed('rulehost_block', sessionId, errorHash);
145
+ void emitPainDetectedEvent(wctx, {
146
+ ts: new Date().toISOString(),
147
+ type: 'pain_detected',
148
+ data: {
149
+ painId: gatePainId,
150
+ painType: 'user_frustration',
151
+ source: 'gate_blocked',
152
+ reason: `Gate blocked ${toolName} on ${filePath}: ${reason}`,
153
+ score: GATE_BLOCK_PAIN_SCORE,
154
+ sessionId,
155
+ agentId: 'main',
156
+ },
157
+ }).catch((emitErr) => {
158
+ logWarn(`[PD_GATE] Failed to emit gate block pain event: ${String(emitErr)}`);
159
+ });
160
+ }
161
+ else {
162
+ logger.info?.(`[PD_GATE] Gate B skipped: ${triggerDecision.reason}`);
163
+ }
164
+ }
121
165
  }
122
- const session = getSession(sessionId);
123
- if (triageAdmitted) {
166
+ else {
167
+ // PRI-454: Gate A path (rollback when either flag is OFF)
124
168
  const gate = evaluatePainDiagnosticGate({
125
169
  source: 'gate_blocked',
126
170
  score: GATE_BLOCK_PAIN_SCORE,
package/dist/hooks/llm.js CHANGED
@@ -13,6 +13,8 @@ import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
13
13
  import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
14
14
  import { resolveSourceKind } from './raw-observation-adapter.js';
15
15
  import { evaluateEvidenceTriage } from './triage-adapter.js';
16
+ import { evaluateTriggerController } from '@principles/core/runtime-v2';
17
+ import { isSharedCooldownActive, markSharedEpisodeAsDiagnosed } from './trigger-cooldown-tracker.js';
16
18
  function clamp(value, min, max) {
17
19
  return Math.max(min, Math.min(max, value));
18
20
  }
@@ -241,25 +243,11 @@ export function handleLlmOutput(event, ctx) {
241
243
  matchedReason = `Accumulated GFI (${state.currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Source: empathy keyword friction.`;
242
244
  }
243
245
  if (painScore >= painTriggerThreshold) {
244
- // PEAT-B1: Evidence triage (feature-flagged)
245
- let triageAdmitted = true;
246
- const llmTriageFlag = loadFeatureFlagFromConfig(ctx.workspaceDir, 'painEvidenceAdmission');
247
- if (llmTriageFlag.enabled) {
248
- // PRI-360 S1: Build RawObservation for unified source mapping
249
- const rawObs = {
250
- observedAt: new Date().toISOString(),
251
- workspaceId: ctx.workspaceDir,
252
- sessionId: ctx.sessionId,
253
- detectionSource: source,
254
- isGfiTriggered,
255
- };
256
- const sourceKind = resolveSourceKind(rawObs);
257
- const triage = evaluateEvidenceTriage(sourceKind, painScore);
258
- if (triage.decision !== 'admit') {
259
- triageAdmitted = false;
260
- ctx.logger?.info?.(`[PD:LLM] Triage ${triage.decision}: ${triage.reason}`);
261
- }
262
- }
246
+ // PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
247
+ // When either OFF → Gate A (PainDiagnosticGate, rollback).
248
+ const triageFlag = loadFeatureFlagFromConfig(ctx.workspaceDir, 'painEvidenceAdmission');
249
+ const defaultFlag = loadFeatureFlagFromConfig(ctx.workspaceDir, 'painEvidenceAdmissionDefault');
250
+ const useGateB = triageFlag.enabled && defaultFlag.enabled;
263
251
  eventLog.recordPainSignal(ctx.sessionId, {
264
252
  score: painScore,
265
253
  source: source,
@@ -278,7 +266,56 @@ export function handleLlmOutput(event, ctx) {
278
266
  origin: 'system_infer',
279
267
  canonicalPainId: painId,
280
268
  });
281
- if (triageAdmitted) {
269
+ if (useGateB) {
270
+ // PRI-454: Gate B path — TriggerController owns admission
271
+ const rawObs = {
272
+ observedAt: new Date().toISOString(),
273
+ workspaceId: ctx.workspaceDir,
274
+ sessionId: ctx.sessionId,
275
+ detectionSource: source,
276
+ isGfiTriggered,
277
+ };
278
+ const sourceKind = resolveSourceKind(rawObs);
279
+ const triage = evaluateEvidenceTriage(sourceKind, painScore);
280
+ if (triage.decision !== 'admit') {
281
+ ctx.logger?.info?.(`[PD:LLM] Triage ${triage.decision}: ${triage.reason}`);
282
+ }
283
+ else {
284
+ const cooldownActive = isSharedCooldownActive(sourceKind, ctx.sessionId, source);
285
+ const triggerDecision = evaluateTriggerController({
286
+ triageResult: triage,
287
+ isOwnerManual: false,
288
+ isCooldownActive: cooldownActive,
289
+ isValid: true,
290
+ score: painScore,
291
+ sessionId: ctx.sessionId,
292
+ });
293
+ if (triggerDecision.shouldCreateDiagnosticTask) {
294
+ markSharedEpisodeAsDiagnosed(sourceKind, ctx.sessionId, source);
295
+ const evidence = buildTrajectoryEvidence(wctx, ctx.sessionId || 'unknown');
296
+ emitPainDetectedEvent(wctx, {
297
+ ts: new Date().toISOString(),
298
+ type: 'pain_detected',
299
+ data: {
300
+ painId,
301
+ painType: 'user_frustration',
302
+ source,
303
+ reason: `${matchedReason}; trigger=${triggerDecision.outcome}`,
304
+ score: painScore,
305
+ sessionId: ctx.sessionId || 'unknown',
306
+ agentId: ctx.agentId,
307
+ provenance: 'openclaw_context_bound',
308
+ evidence,
309
+ },
310
+ }, { recordObservability: false });
311
+ }
312
+ else {
313
+ ctx.logger?.info?.(`[PD:LLM] Gate B skipped: ${triggerDecision.reason}`);
314
+ }
315
+ }
316
+ }
317
+ else {
318
+ // PRI-454: Gate A path (rollback when either flag is OFF)
282
319
  const gate = evaluatePainDiagnosticGate({
283
320
  source: source === 'llm_paralysis' ? 'llm_paralysis' : 'semantic',
284
321
  score: painScore,
@@ -314,9 +351,6 @@ export function handleLlmOutput(event, ctx) {
314
351
  ctx.logger?.info?.(`[PD:LLM] Pain signal recorded without Runtime V2 diagnosis: ${gate.detail}`);
315
352
  }
316
353
  }
317
- else {
318
- ctx.logger?.info?.(`[PD:LLM] Triage evidence-only: pain signal recorded without diagnosis or gate evaluation`);
319
- }
320
354
  }
321
355
  // ═══ Thinking OS: Mental Model Usage Tracking ═══
322
356
  trackThinkingModelUsage({
@@ -24,7 +24,11 @@ import { getEvolutionLogger, createTraceId } from '../core/evolution-logger.js';
24
24
  import { resolveWorkspaceDirForRuntimeV2 } from '../utils/workspace-resolver.js';
25
25
  import { PainToPrincipleService, PrincipleTreeLedgerAdapter } from '@principles/core/runtime-v2';
26
26
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
27
- import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
27
+ import { loadPdConfigForPlugin, loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
28
+ import { evaluateTriggerController } from '@principles/core/runtime-v2';
29
+ import { isSharedCooldownActive, markSharedEpisodeAsDiagnosed } from './trigger-cooldown-tracker.js';
30
+ import { buildManualPainObservation, resolveSourceKind } from './raw-observation-adapter.js';
31
+ import { evaluateEvidenceTriage } from './triage-adapter.js';
28
32
  import { classifyToolCallOutcome, buildToolCallObservation, handleFrictionTrackingForFailure, handleFrictionTrackingForSuccess, recordHygieneTracking, handleProbationFeedback, evaluatePainAdmissionForToolCall, emitPainIfAdmitted, } from './after-tool-call-helpers.js';
29
33
  import { buildTrajectoryEvidence } from './trajectory-evidence.js';
30
34
  export { buildTrajectoryEvidence };
@@ -241,61 +245,120 @@ function handleManualPain(event, ctx, wctx, workspaceDir, sessionId) {
241
245
  toolName: event.toolName,
242
246
  sessionId,
243
247
  });
244
- // Apply PainDiagnosticGate with cooldown
245
- const session = getSession(sessionId);
246
- const gate = evaluatePainDiagnosticGate({
247
- source: 'manual',
248
- score: 100,
249
- currentGfi: session?.currentGfi ?? 0,
250
- sessionId,
251
- });
252
- if (!gate.shouldDiagnose) {
253
- SystemLogger.log(workspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
254
- let payload;
255
- try {
256
- payload = JSON.stringify({
257
- reason: gate.reason,
258
- detail: gate.detail,
259
- source: 'manual',
260
- sessionId,
261
- gfi: 0,
262
- score: 100,
263
- });
264
- }
265
- catch (e) {
266
- SystemLogger.log(workspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
267
- payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
268
- }
269
- SystemLogger.log(workspaceDir, 'PAIN_GATE_REJECTED', payload);
270
- // PEAT-B2: Record trigger decision even when cooldown blocks manual pain
248
+ // PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
249
+ // When either OFF → Gate A (PainDiagnosticGate, rollback).
250
+ const triageFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmission');
251
+ const defaultFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmissionDefault');
252
+ const useGateB = triageFlag.enabled && defaultFlag.enabled;
253
+ if (useGateB) {
254
+ // PRI-454: Gate B path — manual pain is owner-reported.
255
+ // isOwnerManual: true → evaluateTriggerController returns manual_owner_admitted
256
+ // (bypasses triage and cooldown per PRODUCT_IDENTITY: owner-governed).
257
+ const rawObs = buildManualPainObservation({ sessionId });
258
+ const sourceKind = resolveSourceKind(rawObs); // → 'owner_reported'
259
+ const triage = evaluateEvidenceTriage(sourceKind, 100); // → 'admit'
260
+ const cooldownActive = isSharedCooldownActive(sourceKind, sessionId, 'manual_pain');
261
+ const triggerDecision = evaluateTriggerController({
262
+ triageResult: triage,
263
+ isOwnerManual: true,
264
+ isCooldownActive: cooldownActive,
265
+ isValid: true,
266
+ score: 100,
267
+ sessionId,
268
+ });
269
+ // PEAT-B2: Record trigger decision
271
270
  const painTriageFlag = loadPdConfigForPlugin(workspaceDir);
272
271
  if (painTriageFlag.effective) {
273
272
  SystemLogger.log(workspaceDir, 'TRIGGER_DECISION', JSON.stringify({
274
- outcome: 'cooldown_skipped',
275
- sourceKind: 'owner_reported',
276
- reason: `Manual pain within cooldown: ${gate.detail}`,
277
- nextAction: 'wait_for_cooldown_or_manual_retrigger',
273
+ outcome: triggerDecision.outcome,
274
+ sourceKind: triggerDecision.sourceKind,
275
+ reason: triggerDecision.reason,
276
+ nextAction: triggerDecision.nextAction,
278
277
  isOwnerManual: true,
279
278
  sessionId,
280
279
  score: 100,
281
280
  }));
282
281
  }
283
- return;
282
+ if (triggerDecision.shouldCreateDiagnosticTask) {
283
+ markSharedEpisodeAsDiagnosed(sourceKind, sessionId, 'manual_pain');
284
+ emitPainDetectedEvent(wctx, {
285
+ ts: new Date().toISOString(),
286
+ type: 'pain_detected',
287
+ data: {
288
+ painId,
289
+ painType: 'user_frustration',
290
+ source: event.toolName,
291
+ reason: `User intervention: ${reason}`,
292
+ score: 100,
293
+ sessionId,
294
+ traceId,
295
+ agentId: ctx.agentId,
296
+ provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
297
+ evidence: buildTrajectoryEvidence(wctx, sessionId),
298
+ },
299
+ }, { recordObservability: false });
300
+ }
301
+ else {
302
+ SystemLogger.log(workspaceDir, 'MANUAL_PAIN_SKIPPED', triggerDecision.reason);
303
+ }
284
304
  }
285
- emitPainDetectedEvent(wctx, {
286
- ts: new Date().toISOString(),
287
- type: 'pain_detected',
288
- data: {
289
- painId,
290
- painType: 'user_frustration',
291
- source: event.toolName,
292
- reason: `User intervention: ${reason}`,
305
+ else {
306
+ // PRI-454: Gate A path (rollback when either flag is OFF)
307
+ const session = getSession(sessionId);
308
+ const gate = evaluatePainDiagnosticGate({
309
+ source: 'manual',
293
310
  score: 100,
311
+ currentGfi: session?.currentGfi ?? 0,
294
312
  sessionId,
295
- traceId,
296
- agentId: ctx.agentId,
297
- provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
298
- evidence: buildTrajectoryEvidence(wctx, sessionId),
299
- },
300
- }, { recordObservability: false });
313
+ });
314
+ if (!gate.shouldDiagnose) {
315
+ SystemLogger.log(workspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
316
+ let payload;
317
+ try {
318
+ payload = JSON.stringify({
319
+ reason: gate.reason,
320
+ detail: gate.detail,
321
+ source: 'manual',
322
+ sessionId,
323
+ gfi: 0,
324
+ score: 100,
325
+ });
326
+ }
327
+ catch (e) {
328
+ SystemLogger.log(workspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
329
+ payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
330
+ }
331
+ SystemLogger.log(workspaceDir, 'PAIN_GATE_REJECTED', payload);
332
+ // PEAT-B2: Record trigger decision even when cooldown blocks manual pain
333
+ const painTriageFlag = loadPdConfigForPlugin(workspaceDir);
334
+ if (painTriageFlag.effective) {
335
+ SystemLogger.log(workspaceDir, 'TRIGGER_DECISION', JSON.stringify({
336
+ outcome: 'cooldown_skipped',
337
+ sourceKind: 'owner_reported',
338
+ reason: `Manual pain within cooldown: ${gate.detail}`,
339
+ nextAction: 'wait_for_cooldown_or_manual_retrigger',
340
+ isOwnerManual: true,
341
+ sessionId,
342
+ score: 100,
343
+ }));
344
+ }
345
+ return;
346
+ }
347
+ emitPainDetectedEvent(wctx, {
348
+ ts: new Date().toISOString(),
349
+ type: 'pain_detected',
350
+ data: {
351
+ painId,
352
+ painType: 'user_frustration',
353
+ source: event.toolName,
354
+ reason: `User intervention: ${reason}`,
355
+ score: 100,
356
+ sessionId,
357
+ traceId,
358
+ agentId: ctx.agentId,
359
+ provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
360
+ evidence: buildTrajectoryEvidence(wctx, sessionId),
361
+ },
362
+ }, { recordObservability: false });
363
+ }
301
364
  }
@@ -15,6 +15,11 @@ import { matchEmpathyKeywords, loadKeywordStore, saveKeywordStore, getKeywordSto
15
15
  import { severityToPenalty, DEFAULT_EMPATHY_KEYWORD_CONFIG } from '../core/empathy-types.js';
16
16
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
17
17
  import { emitPainDetectedEvent, buildTrajectoryEvidence } from './pain.js';
18
+ import { evaluateTriggerController } from '@principles/core/runtime-v2';
19
+ import { isSharedCooldownActive, markSharedEpisodeAsDiagnosed } from './trigger-cooldown-tracker.js';
20
+ import { buildEmpathyObservation, resolveSourceKind } from './raw-observation-adapter.js';
21
+ import { evaluateEvidenceTriage } from './triage-adapter.js';
22
+ import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
18
23
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
19
24
  import { detectCorrectionCue as coreDetectCorrectionCue, escapeXml, extractMessageContent, isMinimalTrigger, } from '@principles/core/prompt-builder';
20
25
  import { sanitizeForEvidence } from './message-sanitize.js';
@@ -310,19 +315,11 @@ export async function handleBeforePromptBuild(event, ctx) {
310
315
  if (currentGfi >= highGfiThreshold) {
311
316
  const gfiPainScore = Math.min(Math.round(currentGfi), 60);
312
317
  logger?.info?.(`[PD:Empathy] GFI-TRIGGERED: currentGfi=${currentGfi.toFixed(1)} >= highGfi=${highGfiThreshold}, emitting pain signal (score=${gfiPainScore})`);
313
- const gate = evaluatePainDiagnosticGate({
314
- source: 'user_empathy',
315
- score: gfiPainScore,
316
- currentGfi,
317
- consecutiveErrors: currentSession?.consecutiveErrors ?? 0,
318
- sessionId,
319
- errorHash: 'empathy_gfi_threshold',
320
- thresholds: {
321
- painTrigger,
322
- highSeverity: wctx.config.get('severity_thresholds.high') || 70,
323
- semanticPain: Math.max(painTrigger, 60),
324
- },
325
- });
318
+ // PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
319
+ // When either OFF → Gate A (PainDiagnosticGate, rollback).
320
+ const triageFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmission');
321
+ const defaultFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmissionDefault');
322
+ const useGateB = triageFlag.enabled && defaultFlag.enabled;
326
323
  wctx.eventLog.recordPainSignal(sessionId, {
327
324
  score: gfiPainScore,
328
325
  source: 'user_empathy',
@@ -350,34 +347,104 @@ export async function handleBeforePromptBuild(event, ctx) {
350
347
  origin: 'system_infer',
351
348
  canonicalPainId: gfiPainId,
352
349
  });
353
- if (gate.shouldDiagnose) {
354
- logger?.info?.(`[PD:Empathy] Gate approved, calling emitPainDetectedEvent...`);
355
- try {
356
- const evidence = buildTrajectoryEvidence(wctx, sessionId);
357
- await emitPainDetectedEvent(wctx, {
358
- ts: new Date().toISOString(),
359
- type: 'pain_detected',
360
- data: {
361
- painId: gfiPainId,
362
- painType: 'user_frustration',
363
- source: 'user_empathy',
364
- reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
365
- score: gfiPainScore,
366
- sessionId,
367
- agentId: 'main',
368
- provenance: 'openclaw_context_bound',
369
- evidence,
370
- },
371
- }, { recordObservability: false });
372
- logger?.info?.(`[PD:Empathy] emitPainDetectedEvent completed (GFI-triggered)`);
350
+ if (useGateB) {
351
+ // PRI-454: Gate B path — TriggerController owns admission
352
+ const rawObs = buildEmpathyObservation({
353
+ detectionSource: 'user_empathy',
354
+ isGfiTriggered: true,
355
+ sessionId,
356
+ });
357
+ const sourceKind = resolveSourceKind(rawObs);
358
+ const triage = evaluateEvidenceTriage(sourceKind, gfiPainScore);
359
+ if (triage.decision !== 'admit') {
360
+ logger?.info?.(`[PD:Empathy] Triage ${triage.decision}: ${triage.reason}`);
373
361
  }
374
- catch (emitErr) {
375
- console.error(`[PD:Empathy] FAILED to emit GFI-triggered pain event: ${String(emitErr)}`);
376
- logger?.warn?.(`[PD:Empathy] Failed to emit GFI-triggered pain event: ${String(emitErr)}`);
362
+ else {
363
+ const cooldownActive = isSharedCooldownActive(sourceKind, sessionId, 'empathy_gfi_threshold');
364
+ const triggerDecision = evaluateTriggerController({
365
+ triageResult: triage,
366
+ isOwnerManual: false,
367
+ isCooldownActive: cooldownActive,
368
+ isValid: true,
369
+ score: gfiPainScore,
370
+ sessionId,
371
+ });
372
+ if (triggerDecision.shouldCreateDiagnosticTask) {
373
+ markSharedEpisodeAsDiagnosed(sourceKind, sessionId, 'empathy_gfi_threshold');
374
+ logger?.info?.(`[PD:Empathy] Gate B approved, calling emitPainDetectedEvent...`);
375
+ try {
376
+ const evidence = buildTrajectoryEvidence(wctx, sessionId);
377
+ await emitPainDetectedEvent(wctx, {
378
+ ts: new Date().toISOString(),
379
+ type: 'pain_detected',
380
+ data: {
381
+ painId: gfiPainId,
382
+ painType: 'user_frustration',
383
+ source: 'user_empathy',
384
+ reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
385
+ score: gfiPainScore,
386
+ sessionId,
387
+ agentId: 'main',
388
+ provenance: 'openclaw_context_bound',
389
+ evidence,
390
+ },
391
+ }, { recordObservability: false });
392
+ logger?.info?.(`[PD:Empathy] emitPainDetectedEvent completed (GFI-triggered)`);
393
+ }
394
+ catch (emitErr) {
395
+ console.error(`[PD:Empathy] FAILED to emit GFI-triggered pain event: ${String(emitErr)}`);
396
+ logger?.warn?.(`[PD:Empathy] Failed to emit GFI-triggered pain event: ${String(emitErr)}`);
397
+ }
398
+ }
399
+ else {
400
+ logger?.info?.(`[PD:Empathy] Gate B skipped: ${triggerDecision.reason}`);
401
+ }
377
402
  }
378
403
  }
379
404
  else {
380
- logger?.info?.(`[PD:Empathy] GFI-triggered gate rejected: ${gate.detail}`);
405
+ // PRI-454: Gate A path (rollback when either flag is OFF)
406
+ const gate = evaluatePainDiagnosticGate({
407
+ source: 'user_empathy',
408
+ score: gfiPainScore,
409
+ currentGfi,
410
+ consecutiveErrors: currentSession?.consecutiveErrors ?? 0,
411
+ sessionId,
412
+ errorHash: 'empathy_gfi_threshold',
413
+ thresholds: {
414
+ painTrigger,
415
+ highSeverity: wctx.config.get('severity_thresholds.high') || 70,
416
+ semanticPain: Math.max(painTrigger, 60),
417
+ },
418
+ });
419
+ if (gate.shouldDiagnose) {
420
+ logger?.info?.(`[PD:Empathy] Gate approved, calling emitPainDetectedEvent...`);
421
+ try {
422
+ const evidence = buildTrajectoryEvidence(wctx, sessionId);
423
+ await emitPainDetectedEvent(wctx, {
424
+ ts: new Date().toISOString(),
425
+ type: 'pain_detected',
426
+ data: {
427
+ painId: gfiPainId,
428
+ painType: 'user_frustration',
429
+ source: 'user_empathy',
430
+ reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
431
+ score: gfiPainScore,
432
+ sessionId,
433
+ agentId: 'main',
434
+ provenance: 'openclaw_context_bound',
435
+ evidence,
436
+ },
437
+ }, { recordObservability: false });
438
+ logger?.info?.(`[PD:Empathy] emitPainDetectedEvent completed (GFI-triggered)`);
439
+ }
440
+ catch (emitErr) {
441
+ console.error(`[PD:Empathy] FAILED to emit GFI-triggered pain event: ${String(emitErr)}`);
442
+ logger?.warn?.(`[PD:Empathy] Failed to emit GFI-triggered pain event: ${String(emitErr)}`);
443
+ }
444
+ }
445
+ else {
446
+ logger?.info?.(`[PD:Empathy] GFI-triggered gate rejected: ${gate.detail}`);
447
+ }
381
448
  }
382
449
  }
383
450
  // Trigger asynchronous background Empathy Observer deep analysis (Zero Latency)
@@ -438,45 +505,109 @@ export async function handleBeforePromptBuild(event, ctx) {
438
505
  const freshGfi = freshSession?.currentGfi ?? 0;
439
506
  if (freshGfi >= highGfiThreshold) {
440
507
  const freshGfiPainScore = Math.min(Math.round(freshGfi), 60);
441
- const gate = evaluatePainDiagnosticGate({
442
- source: 'user_empathy',
443
- score: freshGfiPainScore,
444
- currentGfi: freshGfi,
445
- consecutiveErrors: freshSession?.consecutiveErrors ?? 0,
446
- sessionId,
447
- errorHash: 'empathy_gfi_threshold',
448
- thresholds: {
449
- painTrigger,
450
- highSeverity: wctx.config.get('severity_thresholds.high') || 70,
451
- semanticPain: Math.max(painTrigger, 60),
452
- },
453
- });
454
- if (gate.shouldDiagnose) {
455
- logger?.info?.(`[PD:Empathy] GFI threshold crossed after background observer. Emitting pain signal...`);
456
- try {
457
- const evidence = buildTrajectoryEvidence(wctx, sessionId);
458
- // PRI-453: Reuse observerPainId for lineage consistency —
459
- // the same id is used as canonicalPainId in recordPainEvent
460
- // and as painId in the emitted event, so EvidenceChainConsoleModel
461
- // can JOIN pain_events.canonical_pain_id = tasks.input_ref.
462
- await emitPainDetectedEvent(wctx, {
463
- ts: new Date().toISOString(),
464
- type: 'pain_detected',
465
- data: {
466
- painId: observerPainId,
467
- painType: 'user_frustration',
468
- source: 'user_empathy',
469
- reason: `Accumulated GFI (${freshGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Verified by Empathy Observer.`,
470
- score: freshGfiPainScore,
471
- sessionId,
472
- agentId: 'main',
473
- provenance: 'openclaw_context_bound',
474
- evidence,
475
- },
476
- }, { recordObservability: false });
508
+ // PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
509
+ // When either OFF → Gate A (PainDiagnosticGate, rollback).
510
+ const triageFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmission');
511
+ const defaultFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmissionDefault');
512
+ const useGateB = triageFlag.enabled && defaultFlag.enabled;
513
+ if (useGateB) {
514
+ // PRI-454: Gate B path — TriggerController owns admission
515
+ const rawObs = buildEmpathyObservation({
516
+ detectionSource: 'user_empathy',
517
+ isGfiTriggered: true,
518
+ sessionId,
519
+ });
520
+ const sourceKind = resolveSourceKind(rawObs);
521
+ const triage = evaluateEvidenceTriage(sourceKind, freshGfiPainScore);
522
+ if (triage.decision !== 'admit') {
523
+ logger?.info?.(`[PD:Empathy] Observer triage ${triage.decision}: ${triage.reason}`);
524
+ }
525
+ else {
526
+ const cooldownActive = isSharedCooldownActive(sourceKind, sessionId, 'empathy_gfi_observer');
527
+ const triggerDecision = evaluateTriggerController({
528
+ triageResult: triage,
529
+ isOwnerManual: false,
530
+ isCooldownActive: cooldownActive,
531
+ isValid: true,
532
+ score: freshGfiPainScore,
533
+ sessionId,
534
+ });
535
+ if (triggerDecision.shouldCreateDiagnosticTask) {
536
+ markSharedEpisodeAsDiagnosed(sourceKind, sessionId, 'empathy_gfi_observer');
537
+ logger?.info?.(`[PD:Empathy] Gate B approved (observer), calling emitPainDetectedEvent...`);
538
+ try {
539
+ const evidence = buildTrajectoryEvidence(wctx, sessionId);
540
+ // PRI-453: Reuse observerPainId for lineage consistency —
541
+ // the same id is used as canonicalPainId in recordPainEvent
542
+ // and as painId in the emitted event, so EvidenceChainConsoleModel
543
+ // can JOIN pain_events.canonical_pain_id = tasks.input_ref.
544
+ await emitPainDetectedEvent(wctx, {
545
+ ts: new Date().toISOString(),
546
+ type: 'pain_detected',
547
+ data: {
548
+ painId: observerPainId,
549
+ painType: 'user_frustration',
550
+ source: 'user_empathy',
551
+ reason: `Accumulated GFI (${freshGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Verified by Empathy Observer.`,
552
+ score: freshGfiPainScore,
553
+ sessionId,
554
+ agentId: 'main',
555
+ provenance: 'openclaw_context_bound',
556
+ evidence,
557
+ },
558
+ }, { recordObservability: false });
559
+ }
560
+ catch (emitErr) {
561
+ logger?.error?.(`[PD:Empathy] FAILED to emit observer-triggered pain event: ${String(emitErr)}`);
562
+ }
563
+ }
564
+ else {
565
+ logger?.info?.(`[PD:Empathy] Gate B skipped (observer): ${triggerDecision.reason}`);
566
+ }
477
567
  }
478
- catch (emitErr) {
479
- logger?.error?.(`[PD:Empathy] FAILED to emit observer-triggered pain event: ${String(emitErr)}`);
568
+ }
569
+ else {
570
+ // PRI-454: Gate A path (rollback when either flag is OFF)
571
+ const gate = evaluatePainDiagnosticGate({
572
+ source: 'user_empathy',
573
+ score: freshGfiPainScore,
574
+ currentGfi: freshGfi,
575
+ consecutiveErrors: freshSession?.consecutiveErrors ?? 0,
576
+ sessionId,
577
+ errorHash: 'empathy_gfi_threshold',
578
+ thresholds: {
579
+ painTrigger,
580
+ highSeverity: wctx.config.get('severity_thresholds.high') || 70,
581
+ semanticPain: Math.max(painTrigger, 60),
582
+ },
583
+ });
584
+ if (gate.shouldDiagnose) {
585
+ logger?.info?.(`[PD:Empathy] GFI threshold crossed after background observer. Emitting pain signal...`);
586
+ try {
587
+ const evidence = buildTrajectoryEvidence(wctx, sessionId);
588
+ // PRI-453: Reuse observerPainId for lineage consistency —
589
+ // the same id is used as canonicalPainId in recordPainEvent
590
+ // and as painId in the emitted event, so EvidenceChainConsoleModel
591
+ // can JOIN pain_events.canonical_pain_id = tasks.input_ref.
592
+ await emitPainDetectedEvent(wctx, {
593
+ ts: new Date().toISOString(),
594
+ type: 'pain_detected',
595
+ data: {
596
+ painId: observerPainId,
597
+ painType: 'user_frustration',
598
+ source: 'user_empathy',
599
+ reason: `Accumulated GFI (${freshGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Verified by Empathy Observer.`,
600
+ score: freshGfiPainScore,
601
+ sessionId,
602
+ agentId: 'main',
603
+ provenance: 'openclaw_context_bound',
604
+ evidence,
605
+ },
606
+ }, { recordObservability: false });
607
+ }
608
+ catch (emitErr) {
609
+ logger?.error?.(`[PD:Empathy] FAILED to emit observer-triggered pain event: ${String(emitErr)}`);
610
+ }
480
611
  }
481
612
  }
482
613
  }
@@ -12,5 +12,5 @@
12
12
  * ERR checklist:
13
13
  * - ERR-011: re-export adapter, not a local re-definition of migrated logic.
14
14
  */
15
- export { resolveSourceKind, buildToolFailureObservation, buildLlmDetectionObservation, } from '@principles/core/runtime-v2';
15
+ export { resolveSourceKind, buildToolFailureObservation, buildLlmDetectionObservation, buildEmpathyObservation, buildManualPainObservation, } from '@principles/core/runtime-v2';
16
16
  export type { RawObservation } from './raw-observation-types.js';
@@ -12,4 +12,4 @@
12
12
  * ERR checklist:
13
13
  * - ERR-011: re-export adapter, not a local re-definition of migrated logic.
14
14
  */
15
- export { resolveSourceKind, buildToolFailureObservation, buildLlmDetectionObservation, } from '@principles/core/runtime-v2';
15
+ export { resolveSourceKind, buildToolFailureObservation, buildLlmDetectionObservation, buildEmpathyObservation, buildManualPainObservation, } from '@principles/core/runtime-v2';
@@ -28,3 +28,15 @@ export declare function clearCooldownState(cooldownMap: Map<string, number>): vo
28
28
  * Get the cooldown timestamp for a given episode (for tests).
29
29
  */
30
30
  export declare function getCooldownTimestamp(source: string, sessionId: string | undefined, errorHash: string | undefined, cooldownMap: ReadonlyMap<string, number>): number | undefined;
31
+ /**
32
+ * Check whether cooldown is currently active using the shared Map.
33
+ */
34
+ export declare function isSharedCooldownActive(source: string, sessionId: string | undefined, errorHash: string | undefined): boolean;
35
+ /**
36
+ * Mark an episode as diagnosed using the shared Map.
37
+ */
38
+ export declare function markSharedEpisodeAsDiagnosed(source: string, sessionId: string | undefined, errorHash: string | undefined): void;
39
+ /**
40
+ * Clear all shared cooldown state (for tests).
41
+ */
42
+ export declare function resetSharedCooldownForTest(): void;
@@ -53,3 +53,28 @@ export function getCooldownTimestamp(source, sessionId, errorHash, cooldownMap)
53
53
  const episodeKey = buildEpisodeKey(source, sessionId, errorHash);
54
54
  return cooldownMap.get(episodeKey);
55
55
  }
56
+ // ── Shared Cooldown Map (PRI-454) ───────────────────────────────────────────
57
+ //
58
+ // Module-level singleton Map shared across all Gate B paths.
59
+ // Replaces the per-file TRIGGER_COOLDOWN_MAP in after-tool-call-helpers.ts.
60
+ // All paths (gate-block, llm, empathy, manual pain) use this single Map
61
+ // to ensure unified cooldown state.
62
+ const SHARED_COOLDOWN_MAP = new Map();
63
+ /**
64
+ * Check whether cooldown is currently active using the shared Map.
65
+ */
66
+ export function isSharedCooldownActive(source, sessionId, errorHash) {
67
+ return isCooldownActive(source, sessionId, errorHash, SHARED_COOLDOWN_MAP);
68
+ }
69
+ /**
70
+ * Mark an episode as diagnosed using the shared Map.
71
+ */
72
+ export function markSharedEpisodeAsDiagnosed(source, sessionId, errorHash) {
73
+ markEpisodeAsDiagnosed(source, sessionId, errorHash, SHARED_COOLDOWN_MAP);
74
+ }
75
+ /**
76
+ * Clear all shared cooldown state (for tests).
77
+ */
78
+ export function resetSharedCooldownForTest() {
79
+ SHARED_COOLDOWN_MAP.clear();
80
+ }
@@ -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.145.3",
5
+ "version": "1.147.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.145.3",
3
+ "version": "1.147.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",