principles-disciple 1.144.0 → 1.145.1

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.
@@ -38,7 +38,6 @@ function buildEnglishOutput(workspaceDir, sessionId, warnings, stats, summary, r
38
38
  'Control Plane',
39
39
  `- Session GFI: current ${formatNumber(summary.gfi.current)}, peak ${formatNumber(summary.gfi.peak)} (${summary.gfi.dataQuality})`,
40
40
  `- GFI Sources: ${formatSources(summary.gfi.sources)}`,
41
- `- Pain Flag: ${summary.pain.activeFlag ? 'active' : 'inactive'}${summary.pain.activeFlagSource ? ` (${summary.pain.activeFlagSource})` : ''}`,
42
41
  `- Last Pain Signal: ${summary.pain.lastSignal ? `${summary.pain.lastSignal.source}${summary.pain.lastSignal.reason ? ` - ${summary.pain.lastSignal.reason}` : ''}` : '--'}`,
43
42
  `- Gate Events: blocks ${formatNumber(summary.gate.recentBlocks)}, bypasses ${formatNumber(summary.gate.recentBypasses)} (${summary.gate.dataQuality})`,
44
43
  '',
@@ -97,7 +96,6 @@ function buildChineseOutput(workspaceDir, sessionId, warnings, stats, summary, r
97
96
  '控制面',
98
97
  `- 会话 GFI: 当前 ${formatNumber(summary.gfi.current)},峰值 ${formatNumber(summary.gfi.peak)} (${summary.gfi.dataQuality})`,
99
98
  `- GFI 来源: ${formatSources(summary.gfi.sources)}`,
100
- `- Pain Flag: ${summary.pain.activeFlag ? 'active' : 'inactive'}${summary.pain.activeFlagSource ? ` (${summary.pain.activeFlagSource})` : ''}`,
101
99
  `- 最近 Pain 信号: ${summary.pain.lastSignal ? `${summary.pain.lastSignal.source}${summary.pain.lastSignal.reason ? ` - ${summary.pain.lastSignal.reason}` : ''}` : '--'}`,
102
100
  `- Gate 事件: block ${formatNumber(summary.gate.recentBlocks)},bypass ${formatNumber(summary.gate.recentBypasses)} (${summary.gate.dataQuality})`,
103
101
  '',
@@ -1,67 +1,4 @@
1
- /**
2
- * Required fields — every pain flag MUST have these.
3
- */
4
- export interface PainFlagData {
5
- /** What triggered this pain signal (e.g., tool_failure, human_intervention, intercept_extraction) */
6
- source: string;
7
- /** Pain score 0-100 */
8
- score: string;
9
- /** ISO 8601 timestamp */
10
- time: string;
11
- /** Human-readable reason / error description */
12
- reason: string;
13
- /** Session ID — identifies which conversation this happened in */
14
- session_id: string;
15
- /** Agent ID — identifies which agent (main, builder, diagnostician, etc.) */
16
- agent_id: string;
17
- /** Whether this involves risky operation ('true' / 'false') */
18
- is_risky: string;
19
- /** Correlation trace ID (for linking events across the pipeline) */
20
- trace_id: string;
21
- /** Preview of the text that triggered this pain */
22
- trigger_text_preview: string;
23
- /** Trajectory pain_events row ID (set by recordPainEvent) */
24
- pain_event_id?: string;
25
- }
26
- export interface PainFlagContractResult {
27
- status: 'missing' | 'valid' | 'invalid';
28
- format: 'missing' | 'empty' | 'kv' | 'json' | 'invalid_json';
29
- data: Record<string, string>;
30
- missingFields: string[];
31
- }
32
- /**
33
- * Builds legacy pain flag data for compatibility tests and historical readers.
34
- * Do not use this to create new Runtime V2 diagnosis requests.
35
- */
36
- export declare function buildPainFlag(input: {
37
- source: string;
38
- score: string;
39
- time?: string;
40
- reason: string;
41
- session_id?: string;
42
- agent_id?: string;
43
- is_risky?: boolean;
44
- trace_id?: string;
45
- trigger_text_preview?: string;
46
- pain_event_id?: string;
47
- }): PainFlagData;
48
- /**
49
- * Validates a pain flag read from disk.
50
- * Returns list of missing required fields — empty string means all present.
51
- */
52
- export declare function validatePainFlag(data: Record<string, string>): string[];
53
1
  export declare function computePainScore(rc: number, isSpiral: boolean, missingTestCommand: boolean, softScore: number, projectDir?: string): number;
54
- export declare function painSeverityLabel(painScore: number, isSpiral?: boolean, projectDir?: string): string;
55
- /**
56
- * Reads and validates the legacy pain flag file.
57
- *
58
- * - If file doesn't exist → returns {}
59
- * - If file is JSON format → converts to KV in memory only
60
- * - If file is KV format → validates required fields, logs warning if missing
61
- * - If file has unknown fields → silently ignores them (forward-compatible)
62
- */
63
- export declare function readPainFlagData(projectDir: string): Record<string, string>;
64
- export declare function readPainFlagContract(projectDir: string): PainFlagContractResult;
65
2
  /**
66
3
  * Track principle value metrics when a pain signal is written.
67
4
  * This is observation-only — it does NOT affect the pain flag write flow.
package/dist/core/pain.js CHANGED
@@ -1,44 +1,5 @@
1
- import * as fs from 'fs';
2
- import { parseKvLines } from '../utils/io.js';
3
1
  import { resolvePdPath } from './paths.js';
4
2
  import { ConfigService } from './config-service.js';
5
- import { SystemLogger } from './system-logger.js';
6
- /**
7
- * Builds legacy pain flag data for compatibility tests and historical readers.
8
- * Do not use this to create new Runtime V2 diagnosis requests.
9
- */
10
- export function buildPainFlag(input) {
11
- // Omit optional fields when not provided — prevents writing empty lines to disk
12
- // which causes agent confusion (SKILL.md vs reality drift)
13
- return {
14
- source: input.source,
15
- score: input.score,
16
- time: input.time || new Date().toISOString(),
17
- reason: input.reason,
18
- session_id: input.session_id ?? '',
19
- agent_id: input.agent_id ?? '',
20
- is_risky: input.is_risky ? 'true' : 'false',
21
- trace_id: input.trace_id ?? '',
22
- trigger_text_preview: input.trigger_text_preview ?? '',
23
- pain_event_id: input.pain_event_id,
24
- };
25
- }
26
- /**
27
- * Validates a pain flag read from disk.
28
- * Returns list of missing required fields — empty string means all present.
29
- */
30
- export function validatePainFlag(data) {
31
- const missing = [];
32
- // Only source/score/time/reason are truly required — session_id/agent_id
33
- // may be empty in automated contexts (heartbeat, background workers)
34
- const required = ['source', 'score', 'time', 'reason'];
35
- for (const field of required) {
36
- if (!data[field] || data[field].trim() === '') {
37
- missing.push(field);
38
- }
39
- }
40
- return missing;
41
- }
42
3
  export function computePainScore(rc, isSpiral, missingTestCommand, softScore, projectDir) {
43
4
  let score = Math.max(0, softScore || 0);
44
5
  const stateDir = projectDir ? resolvePdPath(projectDir, 'STATE_DIR') : undefined;
@@ -59,134 +20,6 @@ export function computePainScore(rc, isSpiral, missingTestCommand, softScore, pr
59
20
  }
60
21
  return Math.min(100, score);
61
22
  }
62
- export function painSeverityLabel(painScore, isSpiral = false, projectDir) {
63
- if (isSpiral) {
64
- return "critical";
65
- }
66
- const stateDir = projectDir ? resolvePdPath(projectDir, 'STATE_DIR') : undefined;
67
- const config = stateDir ? ConfigService.get(stateDir) : null;
68
- const thresholds = config ? config.get('severity_thresholds') : {
69
- high: 70,
70
- medium: 40,
71
- low: 20
72
- };
73
- if (painScore >= thresholds.high) {
74
- return "high";
75
- }
76
- else if (painScore >= thresholds.medium) {
77
- return "medium";
78
- }
79
- else if (painScore >= thresholds.low) {
80
- return "low";
81
- }
82
- else {
83
- return "info";
84
- }
85
- }
86
- /**
87
- * Converts a JSON pain flag object to KV format.
88
- */
89
- function convertJsonToKv(json) {
90
- const kvData = {};
91
- const fieldMap = {
92
- source: 'source',
93
- score: 'score',
94
- time: 'time',
95
- timestamp: 'time',
96
- reason: 'reason',
97
- session_id: 'session_id',
98
- sessionId: 'session_id',
99
- agent_id: 'agent_id',
100
- agentId: 'agent_id',
101
- is_risky: 'is_risky',
102
- isRisky: 'is_risky',
103
- severity: 'severity',
104
- painId: 'pain_id',
105
- };
106
- for (const [jsonKey, kvKey] of Object.entries(fieldMap)) {
107
- if (json[jsonKey] !== undefined) {
108
- kvData[kvKey] = String(json[jsonKey]);
109
- }
110
- }
111
- for (const [key, value] of Object.entries(json)) {
112
- if (fieldMap[key] === undefined && value !== undefined && value !== null) {
113
- kvData[key] = String(value);
114
- }
115
- }
116
- return kvData;
117
- }
118
- /**
119
- * Reads and validates the legacy pain flag file.
120
- *
121
- * - If file doesn't exist → returns {}
122
- * - If file is JSON format → converts to KV in memory only
123
- * - If file is KV format → validates required fields, logs warning if missing
124
- * - If file has unknown fields → silently ignores them (forward-compatible)
125
- */
126
- export function readPainFlagData(projectDir) {
127
- const painFlagPath = resolvePdPath(projectDir, 'PAIN_FLAG');
128
- try {
129
- if (!fs.existsSync(painFlagPath)) {
130
- return {};
131
- }
132
- const content = fs.readFileSync(painFlagPath, "utf-8").trim();
133
- if (!content) {
134
- return {};
135
- }
136
- // Detect JSON format. Legacy compatibility only: parse in memory and do not
137
- // rewrite .pain_flag, because Runtime V2 must not create or repair this file.
138
- if (content.startsWith('{')) {
139
- let json;
140
- try {
141
- json = JSON.parse(content);
142
- }
143
- catch {
144
- SystemLogger.log(projectDir, 'PAIN_FLAG_CORRUPT', 'Pain flag file contains invalid JSON');
145
- return {};
146
- }
147
- const kvData = convertJsonToKv(json);
148
- SystemLogger.log(projectDir, 'PAIN_FLAG_LEGACY_JSON_READ', `Read legacy JSON pain flag in memory (${Object.keys(json).length} fields)`);
149
- return kvData;
150
- }
151
- // KV format — parse and validate
152
- const data = parseKvLines(content);
153
- const missing = validatePainFlag(data);
154
- if (missing.length > 0) {
155
- SystemLogger.log(projectDir, 'PAIN_FLAG_INCOMPLETE', `Pain flag missing required fields: ${missing.join(', ')}`);
156
- }
157
- return data;
158
- }
159
- catch (e) {
160
- SystemLogger.log(projectDir, 'PAIN_FLAG_READ_ERROR', `Failed to read pain flag: ${String(e)}`);
161
- return {};
162
- }
163
- }
164
- export function readPainFlagContract(projectDir) {
165
- const data = readPainFlagData(projectDir);
166
- if (Object.keys(data).length === 0) {
167
- const painFlagPath = resolvePdPath(projectDir, 'PAIN_FLAG');
168
- if (!fs.existsSync(painFlagPath)) {
169
- return { status: 'missing', format: 'missing', data: {}, missingFields: [] };
170
- }
171
- const raw = fs.readFileSync(painFlagPath, 'utf-8').trim();
172
- if (!raw) {
173
- return { status: 'missing', format: 'empty', data: {}, missingFields: [] };
174
- }
175
- return {
176
- status: 'invalid',
177
- format: raw.startsWith('{') ? 'invalid_json' : 'kv',
178
- data: {},
179
- missingFields: ['unparseable'],
180
- };
181
- }
182
- const missing = validatePainFlag(data);
183
- return {
184
- status: missing.length > 0 ? 'invalid' : 'valid',
185
- format: 'kv',
186
- data,
187
- missingFields: missing,
188
- };
189
- }
190
23
  /**
191
24
  * Track principle value metrics when a pain signal is written.
192
25
  * This is observation-only — it does NOT affect the pain flag write flow.
@@ -86,7 +86,9 @@ export declare function evaluatePainAdmissionForToolCall(event: PluginHookAfterT
86
86
  *
87
87
  * Only called when the admission decision is 'admitted'.
88
88
  */
89
- export declare function emitPainIfAdmitted(wctx: WorkspaceContext, event: PluginHookAfterToolCallEvent, observation: ToolCallObservation, outcome: ToolCallOutcome, admission: PainAdmissionDecision, sessionId: string, agentId: string | undefined, workspaceDir: string, emitPainDetectedEvent: (wctx: WorkspaceContext, event: import('../core/evolution-types.js').EvolutionLoopEvent) => Promise<void>): void;
89
+ export declare function emitPainIfAdmitted(wctx: WorkspaceContext, event: PluginHookAfterToolCallEvent, observation: ToolCallObservation, outcome: ToolCallOutcome, admission: PainAdmissionDecision, sessionId: string, agentId: string | undefined, workspaceDir: string, emitPainDetectedEvent: (wctx: WorkspaceContext, event: import('../core/evolution-types.js').EvolutionLoopEvent, options?: {
90
+ recordObservability?: boolean;
91
+ }) => Promise<void>): void;
90
92
  export { buildTrajectoryEvidence } from './trajectory-evidence.js';
91
93
  /**
92
94
  * Reset trigger cooldown state (for tests).
@@ -417,6 +417,9 @@ export function emitPainIfAdmitted(wctx, event, observation, outcome, admission,
417
417
  if (!admission.admitted)
418
418
  return;
419
419
  const failureSource = outcome.failureSource ?? 'tool_failure';
420
+ // PRI-453: Generate painId early so it can be passed as canonicalPainId to
421
+ // recordPainEvent, enabling dedup between legacy trajectory write and SDK path.
422
+ const painId = `pain_${Date.now()}_${observation.errorHash.slice(0, 8)}`;
420
423
  // Record to trajectory before Runtime V2 diagnosis
421
424
  wctx.trajectory?.recordPainEvent({
422
425
  sessionId,
@@ -426,6 +429,7 @@ export function emitPainIfAdmitted(wctx, event, observation, outcome, admission,
426
429
  severity: observation.painScore >= 70 ? 'severe' : observation.painScore >= 40 ? 'moderate' : 'mild',
427
430
  origin: 'system_infer',
428
431
  text: sanitizeForEvidence(observation.params.text ?? observation.params.content, workspaceDir) || undefined,
432
+ canonicalPainId: painId,
429
433
  });
430
434
  // Observe: track which principles would have prevented this pain (observation-only)
431
435
  try {
@@ -481,7 +485,7 @@ export function emitPainIfAdmitted(wctx, event, observation, outcome, admission,
481
485
  sessionId,
482
486
  });
483
487
  // Create painId inline (matches original createPainId)
484
- const painId = `pain_${Date.now()}_${observation.errorHash.slice(0, 8)}`;
488
+ // PRI-453: painId is now generated early (above) to pass as canonicalPainId.
485
489
  emitPainDetectedEvent(wctx, {
486
490
  ts: new Date().toISOString(),
487
491
  type: 'pain_detected',
@@ -497,7 +501,7 @@ export function emitPainIfAdmitted(wctx, event, observation, outcome, admission,
497
501
  provenance: 'automatic_hook',
498
502
  evidence: buildTrajectoryEvidence(wctx, sessionId),
499
503
  },
500
- });
504
+ }, { recordObservability: false });
501
505
  }
502
506
  // ── Shared Helpers ──────────────────────────────────────────────────────────
503
507
  export { buildTrajectoryEvidence } from './trajectory-evidence.js';
package/dist/hooks/llm.js CHANGED
@@ -266,6 +266,18 @@ export function handleLlmOutput(event, ctx) {
266
266
  reason: matchedReason,
267
267
  isRisky: false
268
268
  });
269
+ // PRI-453: Generate painId early and write to trajectory.db via legacy
270
+ // recordPainEvent so that disabling SDK observability path does not lose
271
+ // trajectory coverage. canonicalPainId enables dedup.
272
+ const painId = `llm_${Date.now()}`;
273
+ wctx.trajectory?.recordPainEvent?.({
274
+ sessionId: ctx.sessionId || 'unknown',
275
+ source,
276
+ score: painScore,
277
+ reason: matchedReason,
278
+ origin: 'system_infer',
279
+ canonicalPainId: painId,
280
+ });
269
281
  if (triageAdmitted) {
270
282
  const gate = evaluatePainDiagnosticGate({
271
283
  source: source === 'llm_paralysis' ? 'llm_paralysis' : 'semantic',
@@ -286,7 +298,7 @@ export function handleLlmOutput(event, ctx) {
286
298
  ts: new Date().toISOString(),
287
299
  type: 'pain_detected',
288
300
  data: {
289
- painId: `llm_${Date.now()}`,
301
+ painId,
290
302
  painType: 'user_frustration',
291
303
  source,
292
304
  reason: `${matchedReason}; diagnosticGate=${gate.reason}`,
@@ -296,7 +308,7 @@ export function handleLlmOutput(event, ctx) {
296
308
  provenance: 'openclaw_context_bound',
297
309
  evidence,
298
310
  },
299
- });
311
+ }, { recordObservability: false });
300
312
  }
301
313
  else {
302
314
  ctx.logger?.info?.(`[PD:LLM] Pain signal recorded without Runtime V2 diagnosis: ${gate.detail}`);
@@ -19,7 +19,9 @@ import type { EvolutionLoopEvent } from '../core/evolution-types.js';
19
19
  import type { PluginHookAfterToolCallEvent, PluginHookToolContext, OpenClawPluginApi } from '../openclaw-sdk.js';
20
20
  import { buildTrajectoryEvidence } from './trajectory-evidence.js';
21
21
  export { buildTrajectoryEvidence };
22
- export declare function emitPainDetectedEvent(wctx: WorkspaceContext, event: EvolutionLoopEvent): Promise<void>;
22
+ export declare function emitPainDetectedEvent(wctx: WorkspaceContext, event: EvolutionLoopEvent, options?: {
23
+ recordObservability?: boolean;
24
+ }): Promise<void>;
23
25
  /**
24
26
  * Handle after_tool_call hook — decomposed into pipeline stages.
25
27
  *
@@ -44,7 +44,7 @@ function createPainToPrincipleService(wctx) {
44
44
  }
45
45
  // buildTrajectoryEvidence is in ./trajectory-evidence.ts (re-exported above)
46
46
  // ── Pain Event Emission ─────────────────────────────────────────────────────
47
- export async function emitPainDetectedEvent(wctx, event) {
47
+ export async function emitPainDetectedEvent(wctx, event, options) {
48
48
  try {
49
49
  wctx.evolutionReducer.emitSync(event);
50
50
  }
@@ -68,6 +68,10 @@ export async function emitPainDetectedEvent(wctx, event) {
68
68
  score: painData.score,
69
69
  }));
70
70
  }
71
+ // PRI-453: Hook paths that already write events_*.jsonl + trajectory.db
72
+ // via legacy writers pass recordObservability: false to avoid triple-write.
73
+ // CLI pd pain record and paths without legacy writers keep the default true.
74
+ const recordObs = options?.recordObservability ?? true;
71
75
  const result = await service.recordPain({
72
76
  painId: painData.painId,
73
77
  painType: painData.painType,
@@ -80,7 +84,7 @@ export async function emitPainDetectedEvent(wctx, event) {
80
84
  traceId: painData.traceId,
81
85
  provenance: painData.provenance,
82
86
  evidence: painData.evidence,
83
- recordObservability: true,
87
+ recordObservability: recordObs,
84
88
  });
85
89
  if (result.status === 'failed' && result.failureCategory) {
86
90
  SystemLogger.log(wctx.workspaceDir, 'PAIN_SERVICE_FAILED', JSON.stringify({
@@ -216,6 +220,8 @@ function handleManualPain(event, ctx, wctx, workspaceDir, sessionId) {
216
220
  reason: `User intervention: ${reason}`,
217
221
  isRisky: true
218
222
  });
223
+ // PRI-453: Generate painId early to pass as canonicalPainId for dedup.
224
+ const painId = createPainId(sessionId);
219
225
  wctx.trajectory?.recordPainEvent?.({
220
226
  sessionId,
221
227
  source: 'manual',
@@ -223,6 +229,7 @@ function handleManualPain(event, ctx, wctx, workspaceDir, sessionId) {
223
229
  reason: `User intervention: ${reason}`,
224
230
  origin: 'user_manual',
225
231
  text: reason,
232
+ canonicalPainId: painId,
226
233
  });
227
234
  // Log to EvolutionLogger
228
235
  const evoLogger = getEvolutionLogger(workspaceDir, wctx.trajectory);
@@ -279,7 +286,7 @@ function handleManualPain(event, ctx, wctx, workspaceDir, sessionId) {
279
286
  ts: new Date().toISOString(),
280
287
  type: 'pain_detected',
281
288
  data: {
282
- painId: createPainId(sessionId),
289
+ painId,
283
290
  painType: 'user_frustration',
284
291
  source: event.toolName,
285
292
  reason: `User intervention: ${reason}`,
@@ -290,5 +297,5 @@ function handleManualPain(event, ctx, wctx, workspaceDir, sessionId) {
290
297
  provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
291
298
  evidence: buildTrajectoryEvidence(wctx, sessionId),
292
299
  },
293
- });
300
+ }, { recordObservability: false });
294
301
  }
@@ -328,6 +328,27 @@ export async function handleBeforePromptBuild(event, ctx) {
328
328
  source: 'user_empathy',
329
329
  reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
330
330
  isRisky: false,
331
+ origin: 'system_infer',
332
+ severity: gfiPainScore >= 40 ? 'moderate' : 'mild',
333
+ confidence: 0.5,
334
+ detection_mode: 'structured',
335
+ deduped: false,
336
+ trigger_text_excerpt: sanitizeForEvidence(latestUserMessage, workspaceDir).substring(0, 120),
337
+ raw_score: gfiPainScore,
338
+ calibrated_score: gfiPainScore,
339
+ eventId: `empathy_gfi_${Date.now()}`,
340
+ });
341
+ // PRI-453: Generate painId early and write to trajectory.db via legacy
342
+ // recordPainEvent so that disabling SDK observability path does not lose
343
+ // trajectory coverage. canonicalPainId enables dedup.
344
+ const gfiPainId = `empathy_gfi_${Date.now()}`;
345
+ wctx.trajectory?.recordPainEvent?.({
346
+ sessionId,
347
+ source: 'user_empathy',
348
+ score: gfiPainScore,
349
+ reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
350
+ origin: 'system_infer',
351
+ canonicalPainId: gfiPainId,
331
352
  });
332
353
  if (gate.shouldDiagnose) {
333
354
  logger?.info?.(`[PD:Empathy] Gate approved, calling emitPainDetectedEvent...`);
@@ -337,7 +358,7 @@ export async function handleBeforePromptBuild(event, ctx) {
337
358
  ts: new Date().toISOString(),
338
359
  type: 'pain_detected',
339
360
  data: {
340
- painId: `empathy_gfi_${Date.now()}`,
361
+ painId: gfiPainId,
341
362
  painType: 'user_frustration',
342
363
  source: 'user_empathy',
343
364
  reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
@@ -347,7 +368,7 @@ export async function handleBeforePromptBuild(event, ctx) {
347
368
  provenance: 'openclaw_context_bound',
348
369
  evidence,
349
370
  },
350
- });
371
+ }, { recordObservability: false });
351
372
  logger?.info?.(`[PD:Empathy] emitPainDetectedEvent completed (GFI-triggered)`);
352
373
  }
353
374
  catch (emitErr) {
@@ -392,6 +413,10 @@ export async function handleBeforePromptBuild(event, ctx) {
392
413
  calibrated_score: painScore,
393
414
  eventId,
394
415
  });
416
+ // PRI-453: Generate painId early to pass as canonicalPainId for dedup.
417
+ // Declared outside try block so it's accessible to emitPainDetectedEvent
418
+ // later (lineage consistency: same id for trajectory + emitted event).
419
+ const observerPainId = `empathy_gfi_${Date.now()}`;
395
420
  try {
396
421
  wctx.trajectory?.recordPainEvent?.({
397
422
  sessionId,
@@ -402,6 +427,7 @@ export async function handleBeforePromptBuild(event, ctx) {
402
427
  origin: 'system_infer',
403
428
  confidence: result.confidence,
404
429
  text: sanitizeForEvidence(latestUserMessage, workspaceDir),
430
+ canonicalPainId: observerPainId,
405
431
  });
406
432
  }
407
433
  catch (error) {
@@ -429,11 +455,15 @@ export async function handleBeforePromptBuild(event, ctx) {
429
455
  logger?.info?.(`[PD:Empathy] GFI threshold crossed after background observer. Emitting pain signal...`);
430
456
  try {
431
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.
432
462
  await emitPainDetectedEvent(wctx, {
433
463
  ts: new Date().toISOString(),
434
464
  type: 'pain_detected',
435
465
  data: {
436
- painId: `empathy_gfi_${Date.now()}`,
466
+ painId: observerPainId,
437
467
  painType: 'user_frustration',
438
468
  source: 'user_empathy',
439
469
  reason: `Accumulated GFI (${freshGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Verified by Empathy Observer.`,
@@ -443,7 +473,7 @@ export async function handleBeforePromptBuild(event, ctx) {
443
473
  provenance: 'openclaw_context_bound',
444
474
  evidence,
445
475
  },
446
- });
476
+ }, { recordObservability: false });
447
477
  }
448
478
  catch (emitErr) {
449
479
  logger?.error?.(`[PD:Empathy] FAILED to emit observer-triggered pain event: ${String(emitErr)}`);
@@ -82,8 +82,6 @@ export interface RuntimeSummary {
82
82
  eligibilitySource: 'runtime_truth';
83
83
  };
84
84
  pain: {
85
- activeFlag: boolean;
86
- activeFlagSource: string | null;
87
85
  candidates: number | null;
88
86
  lastSignal: RuntimePainSignal | null;
89
87
  };
@@ -1,7 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import Database from 'better-sqlite3';
3
3
  import * as path from 'path';
4
- import { readPainFlagData } from '../core/pain.js';
5
4
  import { listSessions } from '../core/session-tracker.js';
6
5
  import { WorkspaceContext } from '../core/workspace-context.js';
7
6
  import { evaluatePhase3Inputs } from './phase3-input-filter.js';
@@ -130,7 +129,6 @@ export class RuntimeSummaryService {
130
129
  const directive = this.readJsonFile(wctx.resolve('EVOLUTION_DIRECTIVE'), warnings, false);
131
130
  const queueStats = this.buildQueueStats(queue);
132
131
  const directiveSummary = this.buildDirectiveSummary(queue, directive, generatedAt, warnings);
133
- const painFlag = readPainFlagData(workspaceDir);
134
132
  const painCandidates = this.readJsonFile(wctx.resolve('PAIN_CANDIDATES'), warnings, false);
135
133
  const phase3Inputs = evaluatePhase3Inputs(queue ?? []);
136
134
  const lastPainSignal = this.findLastPainSignal(events, selectedSessionId);
@@ -239,8 +237,6 @@ export class RuntimeSummaryService {
239
237
  eligibilitySource: 'runtime_truth',
240
238
  },
241
239
  pain: {
242
- activeFlag: Object.keys(painFlag).length > 0,
243
- activeFlagSource: painFlag.source || null,
244
240
  candidates: painCandidates?.candidates && typeof painCandidates.candidates === 'object'
245
241
  ? Object.keys(painCandidates.candidates).length
246
242
  : null,
@@ -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.144.0",
5
+ "version": "1.145.1",
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.144.0",
3
+ "version": "1.145.1",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -37,7 +37,6 @@ const STATE_DIR = path.join(WORKSPACE_DIR, '.state');
37
37
  const QUEUE_PATH = path.join(STATE_DIR, 'EVOLUTION_QUEUE');
38
38
  const LEDGER_PATH = path.join(STATE_DIR, 'principle_training_state.json');
39
39
  const DB_PATH = path.join(STATE_DIR, 'subagent_workflows.db');
40
- const PAIN_FLAG_PATH = path.join(STATE_DIR, '.pain_flag');
41
40
  const SAMPLES_DIR = path.join(STATE_DIR, 'nocturnal', 'samples');
42
41
 
43
42
  // ─── Helpers ─────────────────────────────────────────────────────────────
@@ -310,11 +309,6 @@ async function main() {
310
309
  logStep('BASELINE', 'Capturing current state before validation');
311
310
  const queueBefore = safeReadJson(QUEUE_PATH) as QueueItem[] | null;
312
311
  logData('EVOLUTION_QUEUE (before)', queueBefore?.length ?? 0);
313
- if (fs.existsSync(PAIN_FLAG_PATH)) {
314
- logData('.pain_flag', 'EXISTS — ' + fs.readFileSync(PAIN_FLAG_PATH, 'utf8').slice(0, 100));
315
- } else {
316
- logData('.pain_flag', 'not present');
317
- }
318
312
  if (fs.existsSync(SAMPLES_DIR)) {
319
313
  const samplesBefore = fs.readdirSync(SAMPLES_DIR).length;
320
314
  logData('nocturnal/samples/', `${samplesBefore} files`);
@@ -432,18 +426,6 @@ async function main() {
432
426
  }
433
427
  }
434
428
 
435
- // Check pain_flag cleanup
436
- if (fs.existsSync(PAIN_FLAG_PATH)) {
437
- const flagContent = fs.readFileSync(PAIN_FLAG_PATH, 'utf8');
438
- if (flagContent.includes('[object Object]')) {
439
- logStep('⚠️ WARNING', 'pain_flag is corrupted ([object Object])');
440
- } else {
441
- logData('.pain_flag (after)', `still exists, ${flagContent.length} bytes`);
442
- }
443
- } else {
444
- logData('.pain_flag (after)', 'cleaned up (file removed)');
445
- }
446
-
447
429
  if (result.resolution === 'MISSING' || result.resolution === 'expired') {
448
430
  console.error('FAIL: resolution not explicit');
449
431
  process.exit(1);
@@ -1,40 +0,0 @@
1
- export interface ObservabilityBaselines {
2
- /** ISO 8601 timestamp when baselines were calculated */
3
- calculatedAt: string;
4
- /** Principle Stock: total count of principles in the ledger */
5
- principleStock: number;
6
- /** Total rules across all principles */
7
- totalRules: number;
8
- /** Total implementations across all rules */
9
- totalImplementations: number;
10
- /** Structure: average rules per principle (0 if no principles) */
11
- avgRulesPerPrinciple: number;
12
- /** Structure: average implementations per rule (0 if no rules) */
13
- avgImplementationsPerRule: number;
14
- /** Total pain events from trajectory DB (0 if DB unavailable) */
15
- totalPainEvents: number;
16
- /** Association Rate: principles / total pain events (0 if no pain events) */
17
- associationRate: number;
18
- /** Count of principles with internalizationStatus = 'internalized' */
19
- internalizedCount: number;
20
- /** Internalization Rate: internalized / total principles (0 if no principles) */
21
- internalizationRate: number;
22
- /** Distribution of principle statuses */
23
- statusDistribution: Record<string, number>;
24
- /** Distribution of principle priorities */
25
- priorityDistribution: Record<string, number>;
26
- /** Distribution of internalization statuses from training store */
27
- internalizationDistribution: Record<string, number>;
28
- }
29
- /**
30
- * Calculate observability baselines for the principle evolution system.
31
- *
32
- * Reads the principle ledger from stateDir, computes metrics across four
33
- * dimensions (Stock, Structure, Association, Internalization), logs a summary
34
- * via SystemLogger, and persists results to .state/baselines.json.
35
- *
36
- * @param stateDir - The .state directory containing the principle ledger
37
- * @param workspaceDir - Optional workspace dir for SystemLogger routing
38
- * @returns The computed baselines
39
- */
40
- export declare function calculateBaselines(stateDir: string, workspaceDir?: string): ObservabilityBaselines;
@@ -1,164 +0,0 @@
1
- /**
2
- * Observability Baselines for the Evolution SDK.
3
- *
4
- * Provides calculateBaselines() which measures the current state of the
5
- * principle evolution system across four dimensions:
6
- *
7
- * 1. Principle Stock: total count of principles in the ledger
8
- * 2. Structure: average sub-principles (rules) and implementations per principle
9
- * 3. Association Rate: principles created / total pain events recorded
10
- * 4. Internalization Rate: internalized principles / total principles
11
- *
12
- * Results are logged via SystemLogger and persisted to .state/baselines.json.
13
- */
14
- import * as fs from 'fs';
15
- import * as path from 'path';
16
- import { loadLedger } from './principle-tree-ledger.js';
17
- import { SystemLogger } from './system-logger.js';
18
- import { atomicWriteFileSync } from '../utils/io.js';
19
- // ---------------------------------------------------------------------------
20
- // Constants
21
- // ---------------------------------------------------------------------------
22
- const BASELINES_FILE = 'baselines.json';
23
- // ---------------------------------------------------------------------------
24
- // Implementation
25
- // ---------------------------------------------------------------------------
26
- /**
27
- * Calculate observability baselines for the principle evolution system.
28
- *
29
- * Reads the principle ledger from stateDir, computes metrics across four
30
- * dimensions (Stock, Structure, Association, Internalization), logs a summary
31
- * via SystemLogger, and persists results to .state/baselines.json.
32
- *
33
- * @param stateDir - The .state directory containing the principle ledger
34
- * @param workspaceDir - Optional workspace dir for SystemLogger routing
35
- * @returns The computed baselines
36
- */
37
- export function calculateBaselines(stateDir, workspaceDir) {
38
- const ledger = loadLedger(stateDir);
39
- const { tree, trainingStore } = ledger;
40
- const principles = Object.values(tree.principles);
41
- const rules = Object.values(tree.rules);
42
- const implementations = Object.values(tree.implementations);
43
- const principleStock = principles.length;
44
- const totalRules = rules.length;
45
- const totalImplementations = implementations.length;
46
- // Structure metrics
47
- const avgRulesPerPrinciple = principleStock > 0
48
- ? totalRules / principleStock
49
- : 0;
50
- const avgImplementationsPerRule = totalRules > 0
51
- ? totalImplementations / totalRules
52
- : 0;
53
- // Count pain events from trajectory DB
54
- const totalPainEvents = countPainEvents(stateDir);
55
- // Association Rate: how many principles were created per pain event
56
- const associationRate = totalPainEvents > 0
57
- ? principleStock / totalPainEvents
58
- : 0;
59
- // Internalization Rate from training store
60
- // Filter to only entries whose principleId still exists in the ledger tree
61
- // to avoid orphaned/deleted entries inflating the ratio
62
- const trainingEntries = Object.values(trainingStore);
63
- const activePrincipleIds = new Set(Object.keys(tree.principles));
64
- const activeEntries = trainingEntries.filter((entry) => activePrincipleIds.has(entry.principleId));
65
- const internalizedCount = activeEntries.filter((entry) => entry.internalizationStatus === 'internalized').length;
66
- const internalizationRate = principleStock > 0
67
- ? internalizedCount / principleStock
68
- : 0;
69
- // Status distribution
70
- const statusDistribution = {};
71
- for (const p of principles) {
72
- statusDistribution[p.status] = (statusDistribution[p.status] ?? 0) + 1;
73
- }
74
- // Priority distribution
75
- const priorityDistribution = {};
76
- for (const p of principles) {
77
- priorityDistribution[p.priority] = (priorityDistribution[p.priority] ?? 0) + 1;
78
- }
79
- // Internalization status distribution from training store
80
- const internalizationDistribution = {};
81
- for (const entry of trainingEntries) {
82
- internalizationDistribution[entry.internalizationStatus] =
83
- (internalizationDistribution[entry.internalizationStatus] ?? 0) + 1;
84
- }
85
- const baselines = {
86
- calculatedAt: new Date().toISOString(),
87
- principleStock,
88
- totalRules,
89
- totalImplementations,
90
- avgRulesPerPrinciple: roundTo3(avgRulesPerPrinciple),
91
- avgImplementationsPerRule: roundTo3(avgImplementationsPerRule),
92
- totalPainEvents,
93
- associationRate: roundTo3(associationRate),
94
- internalizedCount,
95
- internalizationRate: roundTo3(internalizationRate),
96
- statusDistribution,
97
- priorityDistribution,
98
- internalizationDistribution,
99
- };
100
- // Log summary
101
- SystemLogger.log(workspaceDir, 'OBSERVABILITY_BASELINES', formatBaselineSummary(baselines));
102
- // Persist to .state/baselines.json
103
- persistBaselines(stateDir, baselines);
104
- return baselines;
105
- }
106
- // ---------------------------------------------------------------------------
107
- // Internal helpers
108
- // ---------------------------------------------------------------------------
109
- function roundTo3(n) {
110
- return Math.round(n * 1000) / 1000;
111
- }
112
- function formatBaselineSummary(b) {
113
- return [
114
- `Principle Stock: ${b.principleStock}`,
115
- `Structure: ${b.avgRulesPerPrinciple} rules/principle, ${b.avgImplementationsPerRule} impls/rule`,
116
- `Association Rate: ${b.associationRate} (${b.principleStock} principles / ${b.totalPainEvents} pain events)`,
117
- `Internalization Rate: ${b.internalizationRate} (${b.internalizedCount}/${b.principleStock})`,
118
- ].join(' | ');
119
- }
120
- /**
121
- * Count pain events from the trajectory SQLite database.
122
- * Returns 0 if the database is unavailable or the table doesn't exist.
123
- */
124
- function countPainEvents(stateDir) {
125
- const dbPath = path.join(stateDir, 'trajectory.db');
126
- if (!fs.existsSync(dbPath)) {
127
- return 0;
128
- }
129
- try {
130
- // Use dynamic import for better-sqlite3 to avoid hard dependency
131
- // at module load time. If not available, return 0.
132
- const Database = require('better-sqlite3');
133
- const db = new Database(dbPath, { readonly: true });
134
- try {
135
- const row = db.prepare('SELECT COUNT(*) as count FROM pain_events').get();
136
- return row?.count ?? 0;
137
- }
138
- finally {
139
- db.close();
140
- }
141
- }
142
- catch (err) {
143
- // better-sqlite3 not available, or table doesn't exist — log and return 0
144
- SystemLogger.log(stateDir, 'OBSERVABILITY_SQL_ERROR', `countPainEvents failed: ${String(err)}`);
145
- return 0;
146
- }
147
- }
148
- /**
149
- * Persist baselines to .state/baselines.json atomically.
150
- */
151
- function persistBaselines(stateDir, baselines) {
152
- try {
153
- const filePath = path.join(stateDir, BASELINES_FILE);
154
- const dir = path.dirname(filePath);
155
- if (!fs.existsSync(dir)) {
156
- fs.mkdirSync(dir, { recursive: true });
157
- }
158
- atomicWriteFileSync(filePath, JSON.stringify(baselines, null, 2));
159
- }
160
- catch {
161
- // Baselines persistence is best-effort — don't crash the caller
162
- // (the SystemLogger call above already logged the values)
163
- }
164
- }