principles-disciple 1.47.0 → 1.48.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.47.0",
5
+ "version": "1.48.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -607,11 +607,23 @@ export async function processCompilationBackfill(
607
607
  );
608
608
  if (hasActiveImpl) {
609
609
  // Already compiled — mark as done
610
- updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: undefined });
610
+ try {
611
+ updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: undefined });
612
+ } catch (err) {
613
+ SystemLogger.log(wctx.workspaceDir, 'BACKFILL_UPDATE_FAILED',
614
+ `Failed to mark principle ${principleId} as done: ${String(err)}`);
615
+ continue;
616
+ }
611
617
  } else {
612
618
  // Needs compilation — queue it
613
- updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: 0 });
614
- backfillQueued++;
619
+ try {
620
+ updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: 0 });
621
+ backfillQueued++;
622
+ } catch (err) {
623
+ SystemLogger.log(wctx.workspaceDir, 'BACKFILL_UPDATE_FAILED',
624
+ `Failed to queue principle ${principleId}: ${String(err)}`);
625
+ continue;
626
+ }
615
627
  }
616
628
  }
617
629
  if (backfillQueued > 0) {
@@ -619,7 +631,12 @@ export async function processCompilationBackfill(
619
631
  `Queued ${backfillQueued} old principles for compilation`);
620
632
  }
621
633
  // Write marker so we don't backfill again in this process
622
- atomicWriteFileSync(backfillMarkerPath, new Date().toISOString());
634
+ try {
635
+ atomicWriteFileSync(backfillMarkerPath, new Date().toISOString());
636
+ } catch (err) {
637
+ SystemLogger.log(wctx.workspaceDir, 'BACKFILL_MARKER_WRITE_FAILED',
638
+ `Failed to write backfill marker: ${String(err)}`);
639
+ }
623
640
  }
624
641
 
625
642
  // ── Phase 2: Retry pending compilations ───────────────────────────────────
@@ -642,21 +659,21 @@ export async function processCompilationBackfill(
642
659
  try {
643
660
  const result = compiler.compileOne(principleId);
644
661
  if (result.success) {
645
- safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, undefined);
662
+ tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, undefined);
646
663
  SystemLogger.log(wctx.workspaceDir, 'COMPILE_SUCCESS',
647
664
  `Principle ${principleId} compiled successfully (attempt ${count + 1})`);
648
665
  } else {
649
666
  const nextCount = count + 1;
650
667
  if (nextCount >= 5) {
651
668
  // Exhausted: single write to set manual_only (no intermediate count write)
652
- safeUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
669
+ tryUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
653
670
  evaluability: 'manual_only',
654
671
  compilationRetryCount: undefined,
655
672
  });
656
673
  SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
657
674
  `Principle ${principleId} compilation exhausted after 5 attempts: ${result.reason ?? 'unknown'}`);
658
675
  } else {
659
- safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
676
+ tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
660
677
  SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
661
678
  `Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt ${nextCount}/5)`);
662
679
  }
@@ -665,14 +682,14 @@ export async function processCompilationBackfill(
665
682
  const nextCount = count + 1;
666
683
  if (nextCount >= 5) {
667
684
  // Exhausted: single write to set manual_only (no intermediate count write)
668
- safeUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
685
+ tryUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
669
686
  evaluability: 'manual_only',
670
687
  compilationRetryCount: undefined,
671
688
  });
672
689
  SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
673
690
  `Principle ${principleId} compilation exhausted after 5 attempts: threw ${String(compileErr)}`);
674
691
  } else {
675
- safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
692
+ tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
676
693
  SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
677
694
  `Principle ${principleId} compile threw: ${String(compileErr)} (attempt ${nextCount}/5)`);
678
695
  }
@@ -681,11 +698,11 @@ export async function processCompilationBackfill(
681
698
  }
682
699
 
683
700
  /**
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.
701
+ * Wrapper for updateRetryCount — logs but does not propagate errors.
702
+ * Errors are silently swallowed: if update fails, the principle stays in its
703
+ * current retry state and will be picked up again on the next heartbeat.
687
704
  */
688
- function safeUpdateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number | undefined): void {
705
+ function tryUpdateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number | undefined): void {
689
706
  try {
690
707
  updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
691
708
  } catch (err) {
@@ -696,8 +713,10 @@ function safeUpdateRetryCount(stateDir: string, workspaceDir: string, principleI
696
713
 
697
714
  /**
698
715
  * Wrapper for updatePrinciple with multiple fields — logs but does not propagate errors.
716
+ * Errors are silently swallowed: if update fails, the principle stays in its
717
+ * current state and will be picked up again on the next heartbeat.
699
718
  */
700
- function safeUpdatePrinciple(
719
+ function tryUpdatePrinciple(
701
720
  stateDir: string,
702
721
  workspaceDir: string,
703
722
  principleId: string,
@@ -915,7 +934,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
915
934
  } else {
916
935
  logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
917
936
  const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
918
- painId: task.id,
937
+ painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
919
938
  painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
920
939
  triggerPattern: principle.trigger_pattern,
921
940
  action: principle.action,
@@ -1013,7 +1032,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1013
1032
  logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping for task ${task.id}`);
1014
1033
  } else {
1015
1034
  const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
1016
- painId: task.id,
1035
+ painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
1017
1036
  painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
1018
1037
  triggerPattern: principle.trigger_pattern,
1019
1038
  action: principle.action,
@@ -0,0 +1,253 @@
1
+ /**
2
+ * E2E Test: Pain ID Chain — pain event → createPrincipleFromDiagnosis → compile → RuleHost
3
+ *
4
+ * Tests the complete chain:
5
+ * 1. recordPainEvent() returns AUTOINCREMENT row ID as number
6
+ * 2. createPrincipleFromDiagnosis(painId: String(painEventId))
7
+ * 3. derivedFromPainIds stores the stringified numeric ID
8
+ * 4. PrincipleCompiler.compileOne() succeeds (registers active implementation)
9
+ * 5. RuleHost.evaluate(matching input) → block
10
+ * 6. RuleHost.evaluate(non-matching input) → undefined (passthrough)
11
+ *
12
+ * Pain ID chain fixed in commits 4b0dce59 and 0146bbb7:
13
+ * - recordPainEvent() now returns real AUTOINCREMENT ID (was -1)
14
+ * - derivedFromPainIds now stores String(painId) correctly
15
+ * - LedgerPrinciple.derivedFromPainIds used by compiler reflection
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ import { TrajectoryDatabase } from '../../src/core/trajectory.js';
23
+ import { PrincipleCompiler } from '../../src/core/principle-compiler/compiler.js';
24
+ import { RuleHost } from '../../src/core/rule-host.js';
25
+ import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
26
+ import {
27
+ loadLedger,
28
+ } from '../../src/core/principle-tree-ledger.js';
29
+ import type { RuleHostInput } from '../../src/core/rule-host-types.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface TestWorkspace {
36
+ workspaceDir: string;
37
+ stateDir: string;
38
+ trajectory: TrajectoryDatabase;
39
+ reducer: EvolutionReducerImpl;
40
+ }
41
+
42
+ function createTestWorkspace(): TestWorkspace {
43
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-chain-e2e-'));
44
+ const stateDir = path.join(workspaceDir, '.state');
45
+ fs.mkdirSync(stateDir, { recursive: true });
46
+
47
+ const trajectory = new TrajectoryDatabase({ workspaceDir });
48
+ const reducer = new EvolutionReducerImpl({ workspaceDir, stateDir });
49
+
50
+ return { workspaceDir, stateDir, trajectory, reducer };
51
+ }
52
+
53
+ function disposeTestWorkspace(ws: TestWorkspace): void {
54
+ ws.trajectory.dispose();
55
+ fs.rmSync(ws.workspaceDir, { recursive: true, force: true });
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Tests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe('Pain ID Chain E2E: pain event → principle → compile → RuleHost', () => {
63
+ let ws: TestWorkspace;
64
+
65
+ beforeEach(() => {
66
+ ws = createTestWorkspace();
67
+ });
68
+
69
+ afterEach(() => {
70
+ disposeTestWorkspace(ws);
71
+ });
72
+
73
+ it('full chain: pain event ID → createPrincipleFromDiagnosis → derivedFromPainIds → compile → block', () => {
74
+ const sessionId = 'session-pain-chain-001';
75
+
76
+ // ── Step 1: Record tool call + pain event, capture the returned AUTOINCREMENT ID ──
77
+ ws.trajectory.recordToolCall({
78
+ sessionId,
79
+ toolName: 'bash',
80
+ outcome: 'failure',
81
+ errorType: 'command_not_found',
82
+ errorMessage: 'heartbeat: command not found',
83
+ paramsJson: { command: 'heartbeat --status' },
84
+ });
85
+
86
+ // recordPainEvent() returns the real AUTOINCREMENT row ID as a number (fix from 4b0dce59)
87
+ const painEventId = ws.trajectory.recordPainEvent({
88
+ sessionId,
89
+ source: 'gate_block',
90
+ score: 80,
91
+ reason: 'Blocked bash heartbeat command due to unsafe operation',
92
+ severity: 'moderate',
93
+ origin: 'system_infer',
94
+ });
95
+
96
+ // Verify painEventId is a positive integer (real AUTOINCREMENT, not -1)
97
+ expect(typeof painEventId).toBe('number');
98
+ expect(painEventId).toBeGreaterThan(0);
99
+
100
+ // ── Step 2: Create principle via createPrincipleFromDiagnosis with stringified pain ID ──
101
+ const triggerPattern = 'heartbeat.*bash';
102
+ const action = 'Block heartbeat commands in bash';
103
+ const painIdStr = String(painEventId);
104
+
105
+ const principleId = ws.reducer.createPrincipleFromDiagnosis({
106
+ painId: painIdStr,
107
+ painType: 'tool_failure',
108
+ triggerPattern,
109
+ action,
110
+ source: 'pain-id-chain-e2e',
111
+ evaluability: 'deterministic',
112
+ });
113
+
114
+ expect(principleId).not.toBeNull();
115
+ expect(typeof principleId).toBe('string');
116
+
117
+ // ── Step 3: Verify derivedFromPainIds in the ledger contains the stringified pain ID ──
118
+ const ledger = loadLedger(ws.stateDir);
119
+ const ledgerPrinciple = ledger.tree.principles[principleId!];
120
+ expect(ledgerPrinciple).toBeDefined();
121
+ expect(ledgerPrinciple!.derivedFromPainIds).toContain(painIdStr);
122
+
123
+ // ── Step 4: Compile the principle (registers active implementation) ──
124
+ const compiler = new PrincipleCompiler(ws.stateDir, ws.trajectory);
125
+ const compileResult = compiler.compileOne(principleId!);
126
+
127
+ expect(compileResult.success).toBe(true);
128
+ expect(compileResult.principleId).toBe(principleId);
129
+ expect(compileResult.code).toBeDefined();
130
+ expect(compileResult.code).toContain('heartbeat');
131
+ expect(compileResult.ruleId).toBeDefined();
132
+ expect(compileResult.implementationId).toBeDefined();
133
+
134
+ // Verify implementation is active
135
+ const updatedLedger = loadLedger(ws.stateDir);
136
+ const impl = updatedLedger.tree.implementations[compileResult.implementationId!];
137
+ expect(impl.lifecycleState).toBe('active');
138
+
139
+ // ── Step 5: RuleHost.evaluate(matching input) → block ──
140
+ const host = new RuleHost(ws.stateDir, { warn: () => {} });
141
+
142
+ const matchingInput: RuleHostInput = {
143
+ action: {
144
+ toolName: 'bash',
145
+ normalizedPath: null,
146
+ paramsSummary: { command: 'heartbeat --status' },
147
+ },
148
+ workspace: {
149
+ isRiskPath: false,
150
+ planStatus: 'NONE',
151
+ hasPlanFile: false,
152
+ },
153
+ session: {
154
+ sessionId: 'session-eval-001',
155
+ currentGfi: 50,
156
+ recentThinking: false,
157
+ },
158
+ evolution: {
159
+ epTier: 0,
160
+ },
161
+ derived: {
162
+ estimatedLineChanges: 0,
163
+ bashRisk: 'unknown',
164
+ },
165
+ };
166
+
167
+ const blockResult = host.evaluate(matchingInput);
168
+
169
+ expect(blockResult).toBeDefined();
170
+ expect(blockResult!.decision).toBe('block');
171
+ expect(blockResult!.matched).toBe(true);
172
+ expect(blockResult!.reason).toContain(principleId);
173
+
174
+ // ── Step 6: RuleHost.evaluate(non-matching input) → undefined (passthrough) ──
175
+ const nonMatchingInput: RuleHostInput = {
176
+ action: {
177
+ toolName: 'Read',
178
+ normalizedPath: '/some/file.txt',
179
+ paramsSummary: {},
180
+ },
181
+ workspace: {
182
+ isRiskPath: false,
183
+ planStatus: 'NONE',
184
+ hasPlanFile: false,
185
+ },
186
+ session: {
187
+ sessionId: 'session-eval-002',
188
+ currentGfi: 50,
189
+ recentThinking: false,
190
+ },
191
+ evolution: {
192
+ epTier: 0,
193
+ },
194
+ derived: {
195
+ estimatedLineChanges: 0,
196
+ bashRisk: 'unknown',
197
+ },
198
+ };
199
+
200
+ const passResult = host.evaluate(nonMatchingInput);
201
+ expect(passResult).toBeUndefined();
202
+ });
203
+
204
+ it('compileOne returns failure for non-existent principle ID', () => {
205
+ const compiler = new PrincipleCompiler(ws.stateDir, ws.trajectory);
206
+ const badResult = compiler.compileOne('non-existent-principle-id');
207
+ expect(badResult.success).toBe(false);
208
+ });
209
+
210
+ it('recordPainEvent returns sequential IDs for multiple events', () => {
211
+ const sessionId = 'session-seq-001';
212
+
213
+ ws.trajectory.recordToolCall({
214
+ sessionId,
215
+ toolName: 'bash',
216
+ outcome: 'failure',
217
+ errorType: 'command_not_found',
218
+ errorMessage: 'test error 1',
219
+ paramsJson: { command: 'test1' },
220
+ });
221
+
222
+ const id1 = ws.trajectory.recordPainEvent({
223
+ sessionId,
224
+ source: 'gate_block',
225
+ score: 50,
226
+ reason: 'First pain event',
227
+ severity: 'low',
228
+ origin: 'system_infer',
229
+ });
230
+
231
+ ws.trajectory.recordToolCall({
232
+ sessionId,
233
+ toolName: 'bash',
234
+ outcome: 'failure',
235
+ errorType: 'command_not_found',
236
+ errorMessage: 'test error 2',
237
+ paramsJson: { command: 'test2' },
238
+ });
239
+
240
+ const id2 = ws.trajectory.recordPainEvent({
241
+ sessionId,
242
+ source: 'gate_block',
243
+ score: 60,
244
+ reason: 'Second pain event',
245
+ severity: 'moderate',
246
+ origin: 'system_infer',
247
+ });
248
+
249
+ expect(id2).toBeGreaterThan(id1);
250
+ expect(typeof id1).toBe('number');
251
+ expect(typeof id2).toBe('number');
252
+ });
253
+ });