principles-disciple 1.145.0 → 1.145.2

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,5 +1,4 @@
1
1
  export declare function computePainScore(rc: number, isSpiral: boolean, missingTestCommand: boolean, softScore: number, projectDir?: string): number;
2
- export declare function painSeverityLabel(painScore: number, isSpiral?: boolean, projectDir?: string): string;
3
2
  /**
4
3
  * Track principle value metrics when a pain signal is written.
5
4
  * This is observation-only — it does NOT affect the pain flag write flow.
package/dist/core/pain.js CHANGED
@@ -20,30 +20,6 @@ export function computePainScore(rc, isSpiral, missingTestCommand, softScore, pr
20
20
  }
21
21
  return Math.min(100, score);
22
22
  }
23
- export function painSeverityLabel(painScore, isSpiral = false, projectDir) {
24
- if (isSpiral) {
25
- return "critical";
26
- }
27
- const stateDir = projectDir ? resolvePdPath(projectDir, 'STATE_DIR') : undefined;
28
- const config = stateDir ? ConfigService.get(stateDir) : null;
29
- const thresholds = config ? config.get('severity_thresholds') : {
30
- high: 70,
31
- medium: 40,
32
- low: 20
33
- };
34
- if (painScore >= thresholds.high) {
35
- return "high";
36
- }
37
- else if (painScore >= thresholds.medium) {
38
- return "medium";
39
- }
40
- else if (painScore >= thresholds.low) {
41
- return "low";
42
- }
43
- else {
44
- return "info";
45
- }
46
- }
47
23
  /**
48
24
  * Track principle value metrics when a pain signal is written.
49
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';
@@ -78,15 +78,11 @@ export function recordGateBlockAndReturn(wctx, blockCtx, logger) {
78
78
  // so one mild block does not start a long diagnostician run.
79
79
  if (sessionId) {
80
80
  const GATE_BLOCK_PAIN_SCORE = 45; // Must be >= pain_trigger (40) so single gate block can trigger diagnosis (PRI-274)
81
- // Record to trajectory (fire-and-forget, no .pain_flag file needed)
82
- wctx.trajectory?.recordPainEvent?.({
83
- sessionId,
84
- source: 'gate_blocked',
85
- score: GATE_BLOCK_PAIN_SCORE,
86
- reason: `Gate blocked ${toolName} on ${filePath}: ${reason}`,
87
- severity: 'mild',
88
- origin: 'system_infer',
89
- });
81
+ // PRI-453: Generate painId early. SDK observability path (emitPainDetectedEvent
82
+ // with default recordObservability: true) handles all writes: events_*.jsonl +
83
+ // evolution.jsonl + trajectory.db (with canonicalPainId for dedup). No separate
84
+ // legacy recordPainEvent call needed — avoids double-write to trajectory.db.
85
+ const gatePainId = `gate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
90
86
  // PEAT-B1: Evidence triage (feature-flagged)
91
87
  let triageAdmitted = true;
92
88
  const gateBlockTriageFlag = loadFeatureFlagFromConfig(wctx.workspaceDir, 'painEvidenceAdmission');
@@ -142,7 +138,7 @@ export function recordGateBlockAndReturn(wctx, blockCtx, logger) {
142
138
  ts: new Date().toISOString(),
143
139
  type: 'pain_detected',
144
140
  data: {
145
- painId: `gate_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
141
+ painId: gatePainId,
146
142
  painType: 'user_frustration',
147
143
  source: 'gate_blocked',
148
144
  reason: `Gate blocked ${toolName} on ${filePath}: ${reason}`,
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)}`);
@@ -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.0",
5
+ "version": "1.145.2",
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.0",
3
+ "version": "1.145.2",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -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
- }