principles-disciple 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.45.0",
5
+ "version": "1.47.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -76,8 +76,8 @@
76
76
  }
77
77
  },
78
78
  "buildFingerprint": {
79
- "gitSha": "cab0dbd8e6e7",
80
- "bundleMd5": "1505a7119addd2ee24059f2473cdb1ca",
81
- "builtAt": "2026-04-14T10:58:07.896Z"
79
+ "gitSha": "70500e1475ef",
80
+ "bundleMd5": "607cfbcb4534d2cfdc45cb0cd019cd0b",
81
+ "builtAt": "2026-04-16T03:41:17.317Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -21,9 +21,23 @@ import type {
21
21
  PrincipleSuggestedRule,
22
22
  } from './evolution-types.js';
23
23
  import { isCompleteDetectorMetadata } from './evolution-types.js';
24
- import { updateTrainingStore } from './principle-tree-ledger.js';
24
+ import { updateTrainingStore, addPrincipleToLedger, updatePrinciple, type LedgerPrinciple } from './principle-tree-ledger.js';
25
+ import { PrincipleCompiler } from './principle-compiler/index.js';
26
+
27
+ /**
28
+ * Wrapper for updatePrinciple calls in the compilation retry path.
29
+ * If updatePrinciple throws, logs the error instead of propagating —
30
+ * compilation retry state is best-effort and should not crash principle creation.
31
+ */
32
+ function updateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number): void {
33
+ try {
34
+ updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
35
+ } catch (err) {
36
+ SystemLogger.log(workspaceDir, 'RETRY_COUNT_UPDATE_FAILED',
37
+ `Failed to update compilationRetryCount for ${principleId}: ${String(err)}`);
38
+ }
39
+ }
25
40
 
26
-
27
41
  export interface EvolutionReducer {
28
42
 
29
43
  emit(_event: EvolutionLoopEvent): void;
@@ -381,6 +395,80 @@ export class EvolutionReducerImpl implements EvolutionReducer {
381
395
  SystemLogger.log(this.workspaceDir, 'PRINCIPLE_SYNC_WARN', `Principle ${principleId} created in memory but failed to sync to PRINCIPLES.md — manual file check required`);
382
396
  }
383
397
 
398
+ // Add to ledger tree so the compiler can find this principle.
399
+ // Without this, newly diagnosed principles are invisible to the compiler
400
+ // (which reads tree.principles, not trainingStore or PRINCIPLES.md).
401
+ if (this.stateDir) {
402
+ try {
403
+ // Build a LedgerPrinciple (tree schema) from the evolution-types Principle.
404
+ // Tree schema Principle does NOT have: source, guardrails, contextTags, validation,
405
+ // feedbackScore, usageCount, activatedAt, abstractedPrinciple, valueMetrics.
406
+ // Pain source info is stored via derivedFromPainIds (which the compiler uses).
407
+ const ledgerPrinciple: LedgerPrinciple = {
408
+ id: principle.id,
409
+ version: principle.version,
410
+ text: principle.text,
411
+ triggerPattern: principle.trigger,
412
+ action: principle.action,
413
+ status: principle.status,
414
+ evaluability: principle.evaluability,
415
+ coreAxiomId: principle.coreAxiomId,
416
+ priority: principle.priority ?? 'P1',
417
+ scope: principle.scope ?? 'general',
418
+ domain: principle.domain,
419
+ suggestedRules: principle.suggestedRules?.map((r) => r.name),
420
+ detectorMetadata: principle.detectorMetadata,
421
+ deprecatedAt: principle.deprecatedAt,
422
+ deprecatedReason: undefined,
423
+ createdAt: principle.createdAt,
424
+ updatedAt: now,
425
+ // Ledger-only fields (derived from evolution-types Principle where applicable):
426
+ valueScore: 0,
427
+ adherenceRate: 0,
428
+ painPreventedCount: 0,
429
+ lastPainPreventedAt: undefined,
430
+ derivedFromPainIds: [params.painId],
431
+ ruleIds: [],
432
+ conflictsWithPrincipleIds: [],
433
+ supersedesPrincipleId: undefined,
434
+ };
435
+ addPrincipleToLedger(this.stateDir, ledgerPrinciple);
436
+ SystemLogger.log(this.workspaceDir, 'LEDGER_PRINCIPLE_ADDED', `Principle ${principleId} added to ledger tree`);
437
+
438
+ // Sync compile: attempt to compile immediately unless evaluability is manual_only.
439
+ // Failures are not fatal — heartbeat backfill will retry automatically.
440
+ if (evaluability !== 'manual_only' && this.stateDir) {
441
+ const trajectory = TrajectoryRegistry.get(this.workspaceDir);
442
+ const compiler = new PrincipleCompiler(this.stateDir, trajectory);
443
+ try {
444
+ const result = compiler.compileOne(principleId);
445
+ if (result.success) {
446
+ // Reset retry count on success
447
+ updatePrinciple(this.stateDir, principleId, { compilationRetryCount: undefined });
448
+ SystemLogger.log(this.workspaceDir, 'COMPILE_SUCCESS', `Principle ${principleId} compiled successfully`);
449
+ } else {
450
+ // Compile returned failure — queue for backfill retry (count=0 means "queued", Phase 2 will pick it up).
451
+ // This gives exactly 5 total attempts before exhaustion (backfill: 0-4, sync: 0-4).
452
+ updateRetryCount(this.stateDir, this.workspaceDir, principleId, 0);
453
+ SystemLogger.log(
454
+ this.workspaceDir, 'COMPILE_FAILED',
455
+ `Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt 1/5)`
456
+ );
457
+ }
458
+ } catch (compileErr) {
459
+ // Unexpected error during compilation — queue for backfill retry
460
+ updateRetryCount(this.stateDir, this.workspaceDir, principleId, 0);
461
+ SystemLogger.log(
462
+ this.workspaceDir, 'COMPILE_FAILED',
463
+ `Principle ${principleId} compile threw: ${String(compileErr)} (attempt 1/5)`
464
+ );
465
+ }
466
+ }
467
+ } catch (err) {
468
+ SystemLogger.log(this.workspaceDir, 'LEDGER_PRINCIPLE_ADD_FAILED', `Failed to add ${principleId} to ledger tree: ${String(err)}`);
469
+ }
470
+ }
471
+
384
472
  // #204: Write to training store so listEvaluablePrinciples() can find this principle
385
473
  if (this.stateDir) {
386
474
  try {
package/src/core/pain.ts CHANGED
@@ -32,9 +32,11 @@ export interface PainFlagData {
32
32
  /** Whether this involves risky operation ('true' / 'false') */
33
33
  is_risky: string;
34
34
  /** Correlation trace ID (for linking events across the pipeline) */
35
- trace_id?: string;
36
- /** Short preview of text that triggered this pain signal */
37
- trigger_text_preview?: string;
35
+ trace_id: string;
36
+ /** Preview of the text that triggered this pain */
37
+ trigger_text_preview: string;
38
+ /** Trajectory pain_events row ID (set by recordPainEvent) */
39
+ pain_event_id?: string;
38
40
  }
39
41
 
40
42
  export interface PainFlagContractResult {
@@ -65,6 +67,7 @@ export function buildPainFlag(input: {
65
67
  is_risky?: boolean;
66
68
  trace_id?: string;
67
69
  trigger_text_preview?: string;
70
+ pain_event_id?: string;
68
71
  }): PainFlagData {
69
72
  // Omit optional fields when not provided — prevents writing empty lines to disk
70
73
  // which causes agent confusion (SKILL.md vs reality drift)
@@ -78,6 +81,7 @@ export function buildPainFlag(input: {
78
81
  is_risky: input.is_risky ? 'true' : 'false',
79
82
  trace_id: input.trace_id ?? '',
80
83
  trigger_text_preview: input.trigger_text_preview ?? '',
84
+ pain_event_id: input.pain_event_id,
81
85
  };
82
86
  }
83
87
 
@@ -368,7 +368,7 @@ export async function saveLedgerAsync(stateDir: string, store: HybridLedgerStore
368
368
 
369
369
  export function updateTrainingStore(
370
370
  stateDir: string,
371
-
371
+
372
372
  mutate: (store: LegacyPrincipleTrainingStore) => void,
373
373
  ): void {
374
374
  mutateLedger(stateDir, (store) => {
@@ -376,6 +376,24 @@ export function updateTrainingStore(
376
376
  });
377
377
  }
378
378
 
379
+ /**
380
+ * Add a new principle directly to the ledger tree.
381
+ * This is the companion to updatePrinciple() — use this when creating a NEW
382
+ * principle so the compiler can find it in tree.principles.
383
+ *
384
+ * Idempotent: if the principle already exists, overwrites (update semantics).
385
+ */
386
+ export function addPrincipleToLedger(
387
+ stateDir: string,
388
+ principle: LedgerPrinciple,
389
+ ): LedgerPrinciple {
390
+ return mutateLedger(stateDir, (store) => {
391
+ store.tree.principles[principle.id] = principle;
392
+ store.tree.lastUpdated = new Date().toISOString();
393
+ return principle;
394
+ });
395
+ }
396
+
379
397
  export function createRule(stateDir: string, rule: LedgerRule): LedgerRule {
380
398
  return mutateLedger(stateDir, (store) => {
381
399
  const principle = store.tree.principles[rule.principleId];
@@ -237,10 +237,11 @@ export class TrajectoryDatabase {
237
237
  return rowId;
238
238
  }
239
239
 
240
- recordPainEvent(input: TrajectoryPainEventInput): void {
240
+ recordPainEvent(input: TrajectoryPainEventInput): number {
241
241
  this.recordSession({ sessionId: input.sessionId, startedAt: input.createdAt });
242
+ let insertedId = -1;
242
243
  this.withWrite(() => {
243
- this.db.prepare(`
244
+ const runResult = this.db.prepare(`
244
245
  INSERT INTO pain_events (
245
246
  session_id, source, score, reason, severity, origin, confidence, text, created_at
246
247
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -255,15 +256,22 @@ export class TrajectoryDatabase {
255
256
  input.text ?? null,
256
257
  input.createdAt ?? nowIso(),
257
258
  );
258
-
259
- // Maintain FTS5 index: insert text into pain_events_fts if text is provided (MEM-03, MEM-04)
260
- if (input.text) {
261
- const lastId = this.db.prepare('SELECT last_insert_rowid() as id').get() as { id: number };
259
+ insertedId = runResult.lastInsertRowid as number;
260
+ });
261
+ // FTS indexing is best-effort — run outside the transaction so it cannot
262
+ // roll back the committed pain event row (MEM-03, MEM-04).
263
+ if (input.text && insertedId > 0) {
264
+ try {
262
265
  this.db.prepare(`
263
266
  INSERT INTO pain_events_fts (text, pain_event_id) VALUES (?, ?)
264
- `).run(input.text, lastId.id);
267
+ `).run(input.text, insertedId);
268
+ } catch (err) {
269
+ // Non-fatal: FTS index is for search convenience, not correctness.
270
+ // Log but do not re-throw — the pain event itself is already committed.
271
+ console.warn(`[trajectory] FTS index insert failed for pain_event ${insertedId}: ${String(err)}`);
265
272
  }
266
- });
273
+ }
274
+ return insertedId;
267
275
  }
268
276
 
269
277
  /**
package/src/hooks/pain.ts CHANGED
@@ -295,6 +295,19 @@ export function handleAfterToolCall(
295
295
  const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
296
296
  const traceId = createTraceId();
297
297
 
298
+ // Record to trajectory FIRST so we get the real auto-increment ID.
299
+ // This ID propagates through the pain flag → evolution task → principle,
300
+ // so the compiler can later resolve derivedFromPainIds correctly.
301
+ const trajectoryPainId = wctx.trajectory?.recordPainEvent({
302
+ sessionId,
303
+ source: 'tool_failure',
304
+ score: painScore,
305
+ reason: `Tool ${event.toolName} failed on ${relPath}`,
306
+ severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
307
+ origin: 'system_infer',
308
+ text: params.text ?? params.content ?? undefined,
309
+ });
310
+
298
311
  const painData = buildPainFlag({
299
312
  source: 'tool_failure',
300
313
  score: String(painScore),
@@ -303,6 +316,7 @@ export function handleAfterToolCall(
303
316
  trace_id: traceId,
304
317
  session_id: sessionId,
305
318
  agent_id: ctx.agentId || '',
319
+ pain_event_id: trajectoryPainId !== undefined && trajectoryPainId >= 0 ? String(trajectoryPainId) : undefined,
306
320
  });
307
321
 
308
322
  try {
@@ -355,15 +369,6 @@ export function handleAfterToolCall(
355
369
  reason: `Tool ${event.toolName} failed on ${relPath}`,
356
370
  isRisky: isRisk,
357
371
  });
358
- wctx.trajectory?.recordPainEvent?.({
359
- sessionId,
360
- source: 'tool_failure',
361
- score: painScore,
362
- reason: `Tool ${event.toolName} failed on ${relPath}`,
363
- severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
364
- origin: 'system_infer',
365
- text: params.text ?? params.content ?? undefined, // Store original text/content that failed
366
- });
367
372
 
368
373
  // Log to EvolutionLogger
369
374
  const evoLogger = getEvolutionLogger(effectiveWorkspaceDir, wctx.trajectory);
@@ -15,6 +15,7 @@ import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
15
15
  import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
16
16
  import { getEvolutionLogger } from '../core/evolution-logger.js';
17
17
  import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
18
+ import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
18
19
  export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
19
20
  import { atomicWriteFileSync } from '../utils/io.js';
20
21
 
@@ -37,6 +38,8 @@ import {
37
38
  type NocturnalSessionSnapshot,
38
39
  } from '../core/nocturnal-trajectory-extractor.js';
39
40
  import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
41
+ import { PrincipleCompiler } from '../core/principle-compiler/index.js';
42
+ import { loadLedger, updatePrinciple } from '../core/principle-tree-ledger.js';
40
43
  import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
41
44
  import { readPainFlagContract } from '../core/pain.js';
42
45
  import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
@@ -141,6 +144,9 @@ export interface EvolutionQueueItem {
141
144
  // Attaches explicit recent pain signal without merging task kinds.
142
145
  // Used by target selector for ranking bias and context enrichment.
143
146
  recentPainContext?: RecentPainContext;
147
+
148
+ /** Trajectory pain_events row ID — set when pain flag includes pain_event_id */
149
+ painEventId?: number;
144
150
  }
145
151
 
146
152
  // ── Queue Migration (extracted to queue-migration.ts) ────────────────────────
@@ -305,6 +311,7 @@ export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<st
305
311
  interface ParsedPainValues {
306
312
  score: number; source: string; reason: string; preview: string;
307
313
  traceId: string; sessionId: string; agentId: string;
314
+ painEventId?: number;
308
315
  }
309
316
 
310
317
 
@@ -352,6 +359,7 @@ async function doEnqueuePainTask(
352
359
  status: 'pending', session_id: v.sessionId || undefined,
353
360
  agent_id: v.agentId || undefined, traceId: effectiveTraceId,
354
361
  retryCount: 0, maxRetries: 3,
362
+ painEventId: v.painEventId,
355
363
  });
356
364
 
357
365
  saveEvolutionQueue(queuePath, queue);
@@ -400,6 +408,8 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
400
408
  const traceId = contract.data.trace_id ?? '';
401
409
  const sessionId = contract.data.session_id ?? '';
402
410
  const agentId = contract.data.agent_id ?? '';
411
+ const painEventIdRaw = contract.data.pain_event_id;
412
+ const painEventId = painEventIdRaw ? parseInt(painEventIdRaw, 10) : undefined;
403
413
 
404
414
  result.exists = true;
405
415
  result.score = score;
@@ -414,7 +424,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
414
424
 
415
425
  if (logger) logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
416
426
  return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
417
- score, source, reason, preview, traceId, sessionId, agentId,
427
+ score, source, reason, preview, traceId, sessionId, agentId, painEventId,
418
428
  });
419
429
  }
420
430
 
@@ -470,6 +480,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
470
480
  preview: jsonPreview, traceId: '',
471
481
  sessionId: jsonPain.session_id || '',
472
482
  agentId: jsonPain.agent_id || '',
483
+ painEventId: jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined,
473
484
  });
474
485
  }
475
486
  } catch { /* Not JSON — fall through to KV/Markdown parsing */ }
@@ -492,6 +503,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
492
503
  let traceId = '';
493
504
  let sessionId = '';
494
505
  let agentId = '';
506
+ let painEventId: number | undefined;
495
507
 
496
508
  for (const line of lines) {
497
509
  // KV format: "key: value"
@@ -503,6 +515,10 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
503
515
  if (line.startsWith('trace_id:')) traceId = line.split(':', 2)[1].trim();
504
516
  if (line.startsWith('session_id:')) sessionId = line.slice('session_id:'.length).trim();
505
517
  if (line.startsWith('agent_id:')) agentId = line.slice('agent_id:'.length).trim();
518
+ if (line.startsWith('pain_event_id:')) {
519
+ const raw = line.slice('pain_event_id:'.length).trim();
520
+ painEventId = parseInt(raw, 10) || undefined;
521
+ }
506
522
 
507
523
  // Key=Value fallback format: "key=value" (pain skill manual output)
508
524
  // Handles both uppercase (Source=X) and lowercase (source=x) variants
@@ -544,7 +560,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
544
560
 
545
561
  return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
546
562
  score, source, reason, preview,
547
- traceId, sessionId, agentId,
563
+ traceId, sessionId, agentId, painEventId,
548
564
  });
549
565
 
550
566
  } catch (err) {
@@ -554,8 +570,147 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
554
570
  return result;
555
571
  }
556
572
 
557
-
558
-
573
+ /**
574
+ * Process compilation backfill and retry loop.
575
+ * Phase 1 — Backfill: on first call, scan for old principles (compilationRetryCount === undefined)
576
+ * with evaluability !== 'manual_only' and no active implementation, queue them (set to 0).
577
+ * Phase 2 — Retry: compile all principles with compilationRetryCount >= 0.
578
+ * After 5 consecutive failures, downgrades to manual_only and logs COMPILE_EXHAUSTED.
579
+ */
580
+ export async function processCompilationBackfill(
581
+ wctx: WorkspaceContext,
582
+ logger: PluginLogger,
583
+ ): Promise<void> {
584
+ if (!wctx.stateDir) return;
585
+
586
+ let ledger: ReturnType<typeof loadLedger>;
587
+ try {
588
+ ledger = loadLedger(wctx.stateDir);
589
+ } catch (err) {
590
+ logger?.warn?.(`[PD:EvolutionWorker] CompilationBackfill: failed to load ledger: ${String(err)}`);
591
+ return;
592
+ }
593
+
594
+ // ── Phase 1: Backfill old principles (runs once per process) ─────────────────
595
+ const backfillMarkerPath = path.join(wctx.stateDir, 'COMPILATION_BACKFILL_DONE');
596
+ const hasBackfillRun = fs.existsSync(backfillMarkerPath);
597
+ if (!hasBackfillRun) {
598
+ let backfillQueued = 0;
599
+ for (const [principleId, principle] of Object.entries(ledger.tree.principles)) {
600
+ if (principle.compilationRetryCount !== undefined) continue; // already processed
601
+ if (principle.evaluability === 'manual_only') continue;
602
+ // Check if already has active implementation
603
+ const hasActiveImpl = Object.values(ledger.tree.implementations).some(
604
+ (impl) => impl.lifecycleState === 'active' && (
605
+ ledger.tree.rules[impl.ruleId]?.principleId === principleId
606
+ )
607
+ );
608
+ if (hasActiveImpl) {
609
+ // Already compiled — mark as done
610
+ updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: undefined });
611
+ } else {
612
+ // Needs compilation — queue it
613
+ updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: 0 });
614
+ backfillQueued++;
615
+ }
616
+ }
617
+ if (backfillQueued > 0) {
618
+ SystemLogger.log(wctx.workspaceDir, 'COMPILE_BACKFILL_QUEUED',
619
+ `Queued ${backfillQueued} old principles for compilation`);
620
+ }
621
+ // Write marker so we don't backfill again in this process
622
+ atomicWriteFileSync(backfillMarkerPath, new Date().toISOString());
623
+ }
624
+
625
+ // ── Phase 2: Retry pending compilations ───────────────────────────────────
626
+ const trajectory = TrajectoryRegistry.get(wctx.workspaceDir);
627
+ const compiler = new PrincipleCompiler(wctx.stateDir, trajectory);
628
+
629
+ // Re-load ledger after potential backfill updates
630
+ ledger = loadLedger(wctx.stateDir);
631
+
632
+ for (const [principleId, principle] of Object.entries(ledger.tree.principles)) {
633
+ const count = principle.compilationRetryCount;
634
+
635
+ // Skip: not in retry queue (undefined = done/succeeded)
636
+ if (count === undefined) continue;
637
+
638
+ // Skip: already exhausted (count >= 5 means 5 attempts already made)
639
+ if (count >= 5) continue;
640
+
641
+ // Error-isolate each principle so one failure doesn't stop all other retries
642
+ try {
643
+ const result = compiler.compileOne(principleId);
644
+ if (result.success) {
645
+ safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, undefined);
646
+ SystemLogger.log(wctx.workspaceDir, 'COMPILE_SUCCESS',
647
+ `Principle ${principleId} compiled successfully (attempt ${count + 1})`);
648
+ } else {
649
+ const nextCount = count + 1;
650
+ if (nextCount >= 5) {
651
+ // Exhausted: single write to set manual_only (no intermediate count write)
652
+ safeUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
653
+ evaluability: 'manual_only',
654
+ compilationRetryCount: undefined,
655
+ });
656
+ SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
657
+ `Principle ${principleId} compilation exhausted after 5 attempts: ${result.reason ?? 'unknown'}`);
658
+ } else {
659
+ safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
660
+ SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
661
+ `Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt ${nextCount}/5)`);
662
+ }
663
+ }
664
+ } catch (compileErr) {
665
+ const nextCount = count + 1;
666
+ if (nextCount >= 5) {
667
+ // Exhausted: single write to set manual_only (no intermediate count write)
668
+ safeUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
669
+ evaluability: 'manual_only',
670
+ compilationRetryCount: undefined,
671
+ });
672
+ SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
673
+ `Principle ${principleId} compilation exhausted after 5 attempts: threw ${String(compileErr)}`);
674
+ } else {
675
+ safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
676
+ SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
677
+ `Principle ${principleId} compile threw: ${String(compileErr)} (attempt ${nextCount}/5)`);
678
+ }
679
+ }
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Wrapper for updatePrinciple in the retry loop — logs but does not propagate errors.
685
+ * If update fails, the principle stays in its current retry state and will be
686
+ * picked up again on the next heartbeat.
687
+ */
688
+ function safeUpdateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number | undefined): void {
689
+ try {
690
+ updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
691
+ } catch (err) {
692
+ SystemLogger.log(workspaceDir, 'RETRY_COUNT_UPDATE_FAILED',
693
+ `Failed to update retry count for ${principleId}: ${String(err)}`);
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Wrapper for updatePrinciple with multiple fields — logs but does not propagate errors.
699
+ */
700
+ function safeUpdatePrinciple(
701
+ stateDir: string,
702
+ workspaceDir: string,
703
+ principleId: string,
704
+ updates: { evaluability?: PrincipleEvaluability; compilationRetryCount?: number },
705
+ ): void {
706
+ try {
707
+ updatePrinciple(stateDir, principleId, updates);
708
+ } catch (err) {
709
+ SystemLogger.log(workspaceDir, 'RETRY_PRINCIPLE_UPDATE_FAILED',
710
+ `Failed to update principle ${principleId}: ${String(err)}`);
711
+ }
712
+ }
713
+
559
714
  async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
560
715
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
561
716
  if (!fs.existsSync(queuePath)) {
@@ -1944,6 +2099,12 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1944
2099
  const mergedConfig = loadNocturnalConfigMerged(wctx.stateDir);
1945
2100
  const { sleepReflection: sleepConfig, keywordOptimization: kwOptConfig } = mergedConfig;
1946
2101
 
2102
+ // Compilation backfill: runs on every heartbeat to retry failed compilations.
2103
+ // Fire-and-forget — errors are logged within the function.
2104
+ processCompilationBackfill(wctx, logger).catch((err) => {
2105
+ logger?.error?.(`[PD:EvolutionWorker] CompilationBackfill threw: ${String(err)}`);
2106
+ });
2107
+
1947
2108
  const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
1948
2109
  logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt} triggerMode=${sleepConfig.trigger_mode}`);
1949
2110
 
@@ -68,6 +68,8 @@ export interface RecentPainContext {
68
68
  reason: string;
69
69
  timestamp: string;
70
70
  sessionId: string;
71
+ /** Trajectory pain_events row ID — set when pain flag includes pain_event_id */
72
+ painEventId?: number;
71
73
  } | null;
72
74
  recentPainCount: number;
73
75
  recentMaxPainScore: number;
@@ -150,10 +152,12 @@ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext
150
152
  const reason = contract.data.reason ?? '';
151
153
  const timestamp = contract.data.time ?? '';
152
154
  const sessionId = contract.data.session_id ?? '';
155
+ const painEventIdRaw = contract.data.pain_event_id;
156
+ const painEventId = painEventIdRaw ? parseInt(painEventIdRaw, 10) : undefined;
153
157
 
154
158
  if (score > 0) {
155
159
  return {
156
- mostRecent: { score, source, reason, timestamp, sessionId },
160
+ mostRecent: { score, source, reason, timestamp, sessionId, painEventId },
157
161
  recentPainCount: 1,
158
162
  recentMaxPainScore: score,
159
163
  };
@@ -76,6 +76,10 @@ export interface Principle {
76
76
 
77
77
  // Detector metadata (for auto-training eligibility)
78
78
  detectorMetadata?: PrincipleDetectorSpec;
79
+
80
+ // Compilation retry tracking (for runtime auto-trigger)
81
+ // undefined = not yet attempted or succeeded; 0 = queued; n >= 1 = retry attempt n
82
+ compilationRetryCount?: number;
79
83
  }
80
84
 
81
85
  // =========================================================================
@@ -0,0 +1,188 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
6
+ import { loadLedger } from '../../src/core/principle-tree-ledger.js';
7
+
8
+ const tempDirs: string[] = [];
9
+
10
+ function makeTempDir(): string {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-evolution-compile-'));
12
+ tempDirs.push(dir);
13
+ return dir;
14
+ }
15
+
16
+ // Minimal state dir structure
17
+ function makeStateDir(workspace: string): string {
18
+ const stateDir = path.join(workspace, '.state');
19
+ fs.mkdirSync(stateDir, { recursive: true });
20
+ fs.writeFileSync(path.join(stateDir, 'EVOLUTION_STREAM'), '', 'utf8');
21
+ fs.writeFileSync(path.join(stateDir, 'PRINCIPLES'), '', 'utf8');
22
+ fs.writeFileSync(path.join(stateDir, 'evolution_queue.json'), '[]', 'utf8');
23
+ fs.writeFileSync(path.join(stateDir, 'ledger.json'), JSON.stringify({
24
+ trainingStore: {},
25
+ tree: { principles: {}, rules: {}, implementations: {}, metrics: {}, lastUpdated: new Date().toISOString() },
26
+ }), 'utf8');
27
+ return stateDir;
28
+ }
29
+
30
+ afterEach(() => {
31
+ for (const dir of tempDirs.splice(0)) {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // createPrincipleFromDiagnosis — compilationRetryCount initialization
38
+ // ---------------------------------------------------------------------------
39
+
40
+ describe('createPrincipleFromDiagnosis — compilationRetryCount initialization', () => {
41
+ it('sets compilationRetryCount=0 when evaluability is weak_heuristic (queued for compilation)', () => {
42
+ const workspace = makeTempDir();
43
+ const stateDir = makeStateDir(workspace);
44
+ const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
45
+
46
+ const id = reducer.createPrincipleFromDiagnosis({
47
+ painId: `pain-weak-heuristic-${Date.now()}`,
48
+ painType: 'tool_failure',
49
+ triggerPattern: 'bash rm fails',
50
+ action: 'verify file exists before rm',
51
+ source: 'test-compilation-retry',
52
+ evaluability: 'weak_heuristic',
53
+ });
54
+
55
+ expect(id).not.toBeNull();
56
+ const ledger = loadLedger(stateDir);
57
+ const principle = ledger.tree.principles[id as string];
58
+ expect(principle).toBeDefined();
59
+ // Compilation queued: count >= 0 means queued
60
+ expect(typeof principle?.compilationRetryCount).toBe('number');
61
+ expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
62
+ });
63
+
64
+ it('sets compilationRetryCount=0 when evaluability is deterministic', () => {
65
+ const workspace = makeTempDir();
66
+ const stateDir = makeStateDir(workspace);
67
+ const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
68
+
69
+ const id = reducer.createPrincipleFromDiagnosis({
70
+ painId: `pain-deterministic-${Date.now()}`,
71
+ painType: 'tool_failure',
72
+ triggerPattern: 'edit without read',
73
+ action: 'always read before edit',
74
+ source: 'test-compilation-retry',
75
+ evaluability: 'deterministic',
76
+ });
77
+
78
+ expect(id).not.toBeNull();
79
+ const ledger = loadLedger(stateDir);
80
+ const principle = ledger.tree.principles[id as string];
81
+ expect(principle).toBeDefined();
82
+ expect(typeof principle?.compilationRetryCount).toBe('number');
83
+ expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
84
+ });
85
+
86
+ it('does NOT set compilationRetryCount when evaluability is manual_only', () => {
87
+ const workspace = makeTempDir();
88
+ const stateDir = makeStateDir(workspace);
89
+ const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
90
+
91
+ const id = reducer.createPrincipleFromDiagnosis({
92
+ painId: `pain-manual-only-${Date.now()}`,
93
+ painType: 'tool_failure',
94
+ triggerPattern: 'generic pain',
95
+ action: 'be more careful',
96
+ source: 'test-compilation-retry',
97
+ evaluability: 'manual_only',
98
+ });
99
+
100
+ expect(id).not.toBeNull();
101
+ const ledger = loadLedger(stateDir);
102
+ const principle = ledger.tree.principles[id as string];
103
+ expect(principle).toBeDefined();
104
+ // manual_only principles should NOT be queued for compilation
105
+ expect(principle?.compilationRetryCount).toBeUndefined();
106
+ expect(principle?.evaluability).toBe('manual_only');
107
+ });
108
+
109
+ it('defaults to weak_heuristic and queues for compilation when no evaluability provided', () => {
110
+ const workspace = makeTempDir();
111
+ const stateDir = makeStateDir(workspace);
112
+ const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
113
+
114
+ const id = reducer.createPrincipleFromDiagnosis({
115
+ painId: `pain-default-${Date.now()}`,
116
+ painType: 'tool_failure',
117
+ triggerPattern: 'some pattern',
118
+ action: 'some action',
119
+ source: 'test-compilation-retry',
120
+ });
121
+
122
+ expect(id).not.toBeNull();
123
+ const ledger = loadLedger(stateDir);
124
+ const principle = ledger.tree.principles[id as string];
125
+ expect(principle).toBeDefined();
126
+ // default evaluability is weak_heuristic, which should queue for compilation
127
+ expect(typeof principle?.compilationRetryCount).toBe('number');
128
+ expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
129
+ });
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // createPrincipleFromDiagnosis — compilationRetryCount increments on compile failure
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('createPrincipleFromDiagnosis — compilationRetryCount increments on failure', () => {
137
+ it('increments to 1 when compilation fails (no trajectory data)', () => {
138
+ const workspace = makeTempDir();
139
+ const stateDir = makeStateDir(workspace);
140
+ const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
141
+
142
+ const id = reducer.createPrincipleFromDiagnosis({
143
+ painId: `pain-fail-${Date.now()}`,
144
+ painType: 'tool_failure',
145
+ triggerPattern: 'unknown tool',
146
+ action: 'do nothing',
147
+ source: 'test-compilation-retry',
148
+ evaluability: 'weak_heuristic',
149
+ });
150
+
151
+ expect(id).not.toBeNull();
152
+ const ledger = loadLedger(stateDir);
153
+ const principle = ledger.tree.principles[id as string];
154
+ expect(principle).toBeDefined();
155
+ // Compilation was attempted and failed (no trajectory data) → count should be 0
156
+ // (sync failure sets count=0 so Phase 2 gets exactly 5 total attempts)
157
+ expect(principle?.compilationRetryCount).toBe(0);
158
+ });
159
+ });
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Principle schema — compilationRetryCount field exists and persists
163
+ // ---------------------------------------------------------------------------
164
+
165
+ describe('Principle schema — compilationRetryCount field persists', () => {
166
+ it('compilationRetryCount is stored and retrieved correctly', () => {
167
+ const workspace = makeTempDir();
168
+ const stateDir = makeStateDir(workspace);
169
+ const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
170
+
171
+ const id = reducer.createPrincipleFromDiagnosis({
172
+ painId: `pain-schema-${Date.now()}`,
173
+ painType: 'tool_failure',
174
+ triggerPattern: 'test pattern',
175
+ action: 'test action',
176
+ source: 'test-compilation-retry',
177
+ evaluability: 'weak_heuristic',
178
+ });
179
+
180
+ expect(id).not.toBeNull();
181
+ // Reload ledger to verify persistence
182
+ const ledger = loadLedger(stateDir);
183
+ const principle = ledger.tree.principles[id as string];
184
+ expect(principle).toBeDefined();
185
+ expect(typeof principle?.compilationRetryCount).toBe('number');
186
+ expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
187
+ });
188
+ });
@@ -0,0 +1,199 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { addPrincipleToLedger, loadLedger, type LedgerPrinciple } from '../../src/core/principle-tree-ledger.js';
6
+ import type { WorkspaceContext } from '../../src/core/workspace-context.js';
7
+ import type { PluginLogger } from '../../src/openclaw-sdk.js';
8
+ import { processCompilationBackfill } from '../../src/service/evolution-worker.js';
9
+
10
+ const tempDirs: string[] = [];
11
+
12
+ function makeTempDir(): string {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-backfill-test-'));
14
+ tempDirs.push(dir);
15
+ return dir;
16
+ }
17
+
18
+ function makeStateDir(workspace: string): string {
19
+ const stateDir = path.join(workspace, '.state');
20
+ fs.mkdirSync(stateDir, { recursive: true });
21
+ fs.writeFileSync(path.join(stateDir, 'EVOLUTION_STREAM'), '', 'utf8');
22
+ fs.writeFileSync(path.join(stateDir, 'PRINCIPLES'), '', 'utf8');
23
+ fs.writeFileSync(path.join(stateDir, 'evolution_queue.json'), '[]', 'utf8');
24
+ fs.writeFileSync(path.join(stateDir, 'ledger.json'), JSON.stringify({
25
+ trainingStore: {},
26
+ tree: { principles: {}, rules: {}, implementations: {}, metrics: {}, lastUpdated: new Date().toISOString() },
27
+ }), 'utf8');
28
+ return stateDir;
29
+ }
30
+
31
+ function makeWctx(workspace: string, stateDir: string): WorkspaceContext {
32
+ return {
33
+ workspaceDir: workspace,
34
+ stateDir,
35
+ resolve: (file: string) => path.join(stateDir, file),
36
+ } as unknown as WorkspaceContext;
37
+ }
38
+
39
+ function makePrinciple(id: string, overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
40
+ return {
41
+ id,
42
+ version: 1,
43
+ text: `principle ${id}`,
44
+ triggerPattern: 'test',
45
+ action: 'test action',
46
+ status: 'active',
47
+ priority: 'P1',
48
+ scope: 'general',
49
+ evaluability: 'weak_heuristic',
50
+ compilationRetryCount: undefined,
51
+ ruleIds: [],
52
+ conflictsWithPrincipleIds: [],
53
+ derivedFromPainIds: [],
54
+ valueScore: 0,
55
+ adherenceRate: 0,
56
+ painPreventedCount: 0,
57
+ createdAt: new Date().toISOString(),
58
+ updatedAt: new Date().toISOString(),
59
+ ...overrides,
60
+ } as LedgerPrinciple;
61
+ }
62
+
63
+ const noopLogger: PluginLogger = {
64
+ debug: () => {},
65
+ info: () => {},
66
+ warn: () => {},
67
+ error: () => {},
68
+ };
69
+
70
+ afterEach(() => {
71
+ vi.restoreAllMocks();
72
+ for (const dir of tempDirs.splice(0)) {
73
+ fs.rmSync(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Phase 1: Backfill
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('processCompilationBackfill — Phase 1 backfill', () => {
82
+ it('sets compilationRetryCount=0 for old principles without retry count', () => {
83
+ const workspace = makeTempDir();
84
+ const stateDir = makeStateDir(workspace);
85
+
86
+ addPrincipleToLedger(stateDir, makePrinciple('P_001', {
87
+ evaluability: 'weak_heuristic',
88
+ compilationRetryCount: undefined,
89
+ }));
90
+
91
+ const wctx = makeWctx(workspace, stateDir);
92
+ processCompilationBackfill(wctx, noopLogger);
93
+
94
+ const ledger = loadLedger(stateDir);
95
+ // Phase 1 sets count=0, then Phase 2 runs and increments to 1 (compilation fails without trajectory data)
96
+ // The key assertion: count was set to 0 at some point (proves backfill ran)
97
+ expect(ledger.tree.principles['P_001'].compilationRetryCount).toBeGreaterThanOrEqual(0);
98
+ });
99
+
100
+ it('skips principles with manual_only evaluability', () => {
101
+ const workspace = makeTempDir();
102
+ const stateDir = makeStateDir(workspace);
103
+
104
+ addPrincipleToLedger(stateDir, makePrinciple('P_002', {
105
+ evaluability: 'manual_only',
106
+ compilationRetryCount: undefined,
107
+ }));
108
+
109
+ const wctx = makeWctx(workspace, stateDir);
110
+ processCompilationBackfill(wctx, noopLogger);
111
+
112
+ const ledger = loadLedger(stateDir);
113
+ expect(ledger.tree.principles['P_002'].compilationRetryCount).toBeUndefined();
114
+ });
115
+
116
+ it('writes COMPILATION_BACKFILL_DONE marker after backfill', () => {
117
+ const workspace = makeTempDir();
118
+ const stateDir = makeStateDir(workspace);
119
+
120
+ addPrincipleToLedger(stateDir, makePrinciple('P_003', {
121
+ evaluability: 'weak_heuristic',
122
+ compilationRetryCount: undefined,
123
+ }));
124
+
125
+ const wctx = makeWctx(workspace, stateDir);
126
+ processCompilationBackfill(wctx, noopLogger);
127
+
128
+ const markerPath = path.join(stateDir, 'COMPILATION_BACKFILL_DONE');
129
+ expect(fs.existsSync(markerPath)).toBe(true);
130
+ });
131
+
132
+ it('does not re-backfill if marker already exists', () => {
133
+ const workspace = makeTempDir();
134
+ const stateDir = makeStateDir(workspace);
135
+
136
+ // Pre-write the marker so Phase 1 (backfill) is skipped
137
+ const markerPath = path.join(stateDir, 'COMPILATION_BACKFILL_DONE');
138
+ fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8');
139
+
140
+ // Add principle with compilationRetryCount already set to a high value
141
+ // (simulating already-processed-by-Phase2)
142
+ addPrincipleToLedger(stateDir, makePrinciple('P_004', {
143
+ evaluability: 'weak_heuristic',
144
+ compilationRetryCount: 2,
145
+ }));
146
+
147
+ const wctx = makeWctx(workspace, stateDir);
148
+ processCompilationBackfill(wctx, noopLogger);
149
+
150
+ const ledger = loadLedger(stateDir);
151
+ // Phase 1 was skipped (marker exists), but Phase 2 still ran and incremented count
152
+ // So count goes from 2 -> 3 (compilation fails without trajectory data)
153
+ expect(ledger.tree.principles['P_004'].compilationRetryCount).toBe(3);
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Phase 2: Retry loop
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe('processCompilationBackfill — Phase 2 retry loop', () => {
162
+ it('increments count on compile failure (below exhaustion)', () => {
163
+ const workspace = makeTempDir();
164
+ const stateDir = makeStateDir(workspace);
165
+
166
+ // Principle queued with count=1 — next failure should make it 2
167
+ addPrincipleToLedger(stateDir, makePrinciple('P_012', {
168
+ evaluability: 'weak_heuristic',
169
+ compilationRetryCount: 1,
170
+ }));
171
+
172
+ const wctx = makeWctx(workspace, stateDir);
173
+ processCompilationBackfill(wctx, noopLogger);
174
+
175
+ const ledger = loadLedger(stateDir);
176
+ // Compilation fails (no trajectory data), so count increments
177
+ expect(ledger.tree.principles['P_012'].compilationRetryCount).toBe(2);
178
+ expect(ledger.tree.principles['P_012'].evaluability).toBe('weak_heuristic');
179
+ });
180
+
181
+ it('downgrades to manual_only after 5 consecutive failures', () => {
182
+ const workspace = makeTempDir();
183
+ const stateDir = makeStateDir(workspace);
184
+
185
+ // Principle at count=4 — next failure exhausts it
186
+ addPrincipleToLedger(stateDir, makePrinciple('P_011', {
187
+ evaluability: 'weak_heuristic',
188
+ compilationRetryCount: 4,
189
+ }));
190
+
191
+ const wctx = makeWctx(workspace, stateDir);
192
+ processCompilationBackfill(wctx, noopLogger);
193
+
194
+ const ledger = loadLedger(stateDir);
195
+ // Compilation fails (no trajectory), count becomes 5 >= 5, downgrades to manual_only
196
+ expect(ledger.tree.principles['P_011'].evaluability).toBe('manual_only');
197
+ expect(ledger.tree.principles['P_011'].compilationRetryCount).toBeUndefined();
198
+ });
199
+ });