principles-disciple 1.26.0 → 1.27.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.26.0",
5
+ "version": "1.27.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -76,8 +76,8 @@
76
76
  }
77
77
  },
78
78
  "buildFingerprint": {
79
- "gitSha": "6c8a61389b46",
80
- "bundleMd5": "2803099a748622cc50286ad4c55be551",
81
- "builtAt": "2026-04-13T02:02:51.311Z"
79
+ "gitSha": "9037c29faf9a",
80
+ "bundleMd5": "e7e9ebf7f67083f72f8f4328314d5670",
81
+ "builtAt": "2026-04-13T06:10:29.622Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -961,6 +961,17 @@ export class TrajectoryDatabase {
961
961
  throw new SampleNotFoundError(`${sampleId} (after update)`);
962
962
  }
963
963
 
964
+ // #Phase2b: Emit pain event for rejected corrections
965
+ if (status === 'rejected') {
966
+ this.recordCorrectionRejectedPain({
967
+ session_id: record.session_id,
968
+ quality_score: record.quality_score,
969
+ diff_excerpt: record.diff_excerpt,
970
+ principle_ids_json: record.principle_ids_json,
971
+ created_at: record.created_at,
972
+ });
973
+ }
974
+
964
975
  return {
965
976
  sampleId: String(record.sample_id),
966
977
  sessionId: String(record.session_id),
@@ -977,6 +988,43 @@ export class TrajectoryDatabase {
977
988
  };
978
989
  }
979
990
 
991
+ /**
992
+ * When a correction sample is rejected, emit a pain event to the trajectory.
993
+ * This feeds rejected corrections into the nocturnal pipeline as a high-fidelity
994
+ * violation signal (human-verified, unlike heuristic pain detection).
995
+ */
996
+ private recordCorrectionRejectedPain(record: {
997
+ session_id: unknown;
998
+ quality_score: unknown;
999
+ diff_excerpt: unknown;
1000
+ principle_ids_json: unknown;
1001
+ created_at: unknown;
1002
+ }): void {
1003
+ const sessionId = String(record.session_id);
1004
+ const qualityScore = Number(record.quality_score);
1005
+ const diffExcerpt = String(record.diff_excerpt ?? '');
1006
+ const principleIds = String(record.principle_ids_json ?? '[]');
1007
+ // quality_score (0-100) from correction sample → pain score (0-100), clamped
1008
+ const painScore = Math.max(0, Math.min(100, Math.round(Number(qualityScore) || 0)));
1009
+ const reason = `Correction rejected (quality ${qualityScore.toFixed(2)}). Principles: ${principleIds}${diffExcerpt ? ` — ${diffExcerpt.slice(0, 120)}` : ''}`;
1010
+
1011
+ try {
1012
+ this.recordPainEvent({
1013
+ sessionId,
1014
+ source: 'correction_rejected',
1015
+ score: painScore,
1016
+ reason,
1017
+ severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
1018
+ origin: 'system_infer',
1019
+ text: diffExcerpt || undefined,
1020
+ createdAt: String(record.created_at),
1021
+ });
1022
+ } catch (err) {
1023
+ // Non-fatal: pain event recording should not break the review flow
1024
+ console.warn(`[Trajectory] Failed to record correction_rejected pain event: ${String(err)}`);
1025
+ }
1026
+ }
1027
+
980
1028
  /**
981
1029
  * Export correction samples to JSONL file.
982
1030
  *
@@ -1496,6 +1544,11 @@ export class TrajectoryDatabase {
1496
1544
  `).get(sessionId) as Record<string, unknown> | undefined;
1497
1545
  if (!correctionTurn || !correctionTurn.references_assistant_turn_id) return;
1498
1546
 
1547
+ // #Phase2b-fix: Tool failure is NOT required for correction samples.
1548
+ // User corrections are the highest-fidelity signal — they indicate the agent
1549
+ // said or did something wrong, regardless of whether tool calls succeeded.
1550
+ // Requiring tool failure excluded the most valuable cases: "agent did something
1551
+ // that technically worked but violated a principle or was logically wrong."
1499
1552
  const failedCall = this.db.prepare(`
1500
1553
  SELECT id, tool_name, error_type, error_message
1501
1554
  FROM tool_calls
@@ -1503,26 +1556,37 @@ export class TrajectoryDatabase {
1503
1556
  ORDER BY id DESC
1504
1557
  LIMIT 1
1505
1558
  `).get(sessionId) as Record<string, unknown> | undefined;
1506
- if (!failedCall) return;
1507
1559
 
1508
- const successfulCalls = this.db.prepare(`
1509
- SELECT id, tool_name
1560
+ const recentCalls = this.db.prepare(`
1561
+ SELECT id, tool_name, outcome
1510
1562
  FROM tool_calls
1511
- WHERE session_id = ? AND outcome = 'success'
1563
+ WHERE session_id = ?
1512
1564
  ORDER BY id DESC
1513
- LIMIT 3
1565
+ LIMIT 5
1514
1566
  `).all(sessionId) as Record<string, unknown>[];
1515
- if (successfulCalls.length === 0) return;
1516
1567
 
1517
- const sampleId = `sample_${crypto.createHash('md5').update(`${sessionId}:${correctionTurn.id}:${successfulCalls[0].id}`).digest('hex').slice(0, 12)}`;
1568
+ const successfulCalls = recentCalls.filter(c => c.outcome === 'success');
1569
+
1570
+ // Generate sample ID from correction turn + first recent call (or correction id if no calls)
1571
+ const refForHash = successfulCalls[0]?.id ?? correctionTurn.id;
1572
+ const sampleId = `sample_${crypto.createHash('md5').update(`${sessionId}:${correctionTurn.id}:${refForHash}`).digest('hex').slice(0, 12)}`;
1518
1573
  const userRawText = this.restoreRawText(correctionTurn.raw_text as string | null, correctionTurn.blob_ref as string | null);
1574
+
1575
+ // Quality scoring: correction cue is always valuable
1576
+ // Tool failure adds context (20pts), successful calls add context (up to 15pts)
1577
+ // Pure conversation corrections still score 55-75 (high enough to review)
1519
1578
  const qualityScore = [
1520
1579
  correctionTurn.references_assistant_turn_id ? 35 : 0,
1521
1580
  correctionTurn.correction_cue ? 20 : 0,
1522
1581
  failedCall ? 20 : 0,
1523
- successfulCalls.length > 0 ? 25 : 0,
1582
+ Math.min(successfulCalls.length, 3) * 5,
1524
1583
  ].reduce((sum, value) => sum + value, 0);
1525
1584
 
1585
+ // Diff excerpt: prefer user correction text, fallback to error info, fallback to cue
1586
+ const diffText = userRawText
1587
+ || (failedCall ? String(failedCall.error_message ?? failedCall.error_type ?? failedCall.tool_name) : '')
1588
+ || String(correctionTurn.correction_cue ?? 'user correction');
1589
+
1526
1590
  this.withWrite(() => {
1527
1591
  this.db.prepare(`
1528
1592
  INSERT OR IGNORE INTO correction_samples (
@@ -1535,8 +1599,8 @@ export class TrajectoryDatabase {
1535
1599
  sessionId,
1536
1600
  Number(correctionTurn.references_assistant_turn_id),
1537
1601
  Number(correctionTurn.id),
1538
- safeJson(successfulCalls.map((call) => ({ id: call.id, toolName: call.tool_name }))),
1539
- summarizeForDiff(userRawText || String(failedCall.error_message ?? failedCall.error_type ?? failedCall.tool_name)),
1602
+ safeJson(successfulCalls.map((call) => ({ id: call.id, toolName: call.tool_name, outcome: call.outcome }))),
1603
+ summarizeForDiff(diffText),
1540
1604
  '[]',
1541
1605
  qualityScore,
1542
1606
  nowIso(),
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { TrajectoryDatabase } from '../../src/core/trajectory.js';
6
+
7
+ function safeRmDir(dir: string): void {
8
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
9
+ }
10
+
11
+ describe('Trajectory — correction_rejected pain event (Phase 2b)', () => {
12
+ let workspaceDir: string;
13
+ let trajectory: TrajectoryDatabase;
14
+
15
+ beforeEach(() => {
16
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-correction-pain-'));
17
+ trajectory = new TrajectoryDatabase({ workspaceDir });
18
+ });
19
+
20
+ afterEach(() => {
21
+ trajectory?.dispose();
22
+ safeRmDir(workspaceDir);
23
+ });
24
+
25
+ it('emits a pain event when a correction sample is rejected', async () => {
26
+ // Step 1: Create a session
27
+ trajectory.recordSession({
28
+ sessionId: 'test-session-001',
29
+ startedAt: new Date().toISOString(),
30
+ });
31
+
32
+ // Step 2: Create an assistant turn (to be referenced)
33
+ const assistantTurnId = trajectory.recordAssistantTurn({
34
+ sessionId: 'test-session-001',
35
+ turnIndex: 0,
36
+ rawText: 'Here is some code I wrote',
37
+ createdAt: new Date().toISOString(),
38
+ });
39
+
40
+ // Step 3: Create a user turn with correction_cue (triggers auto-creation)
41
+ trajectory.recordUserTurn({
42
+ sessionId: 'test-session-001',
43
+ turnIndex: 1,
44
+ rawText: 'This is wrong. Fix it properly.',
45
+ correctionDetected: true,
46
+ correctionCue: 'This is wrong. Fix it properly.',
47
+ referencesAssistantTurnId: assistantTurnId,
48
+ createdAt: new Date().toISOString(),
49
+ });
50
+
51
+ // Wait for async sample creation
52
+ await new Promise(resolve => setTimeout(resolve, 50));
53
+
54
+ // Verify sample was created as pending
55
+ const pendingSamples = trajectory.listCorrectionSamples('pending');
56
+ expect(pendingSamples.length).toBe(1);
57
+ const sampleId = pendingSamples[0].sampleId;
58
+
59
+ // Verify no pain events yet
60
+ const painEventsBefore = trajectory.listPainEventsForSession('test-session-001');
61
+ expect(painEventsBefore.length).toBe(0);
62
+
63
+ // Step 4: Review as rejected
64
+ trajectory.reviewCorrectionSample(sampleId, 'rejected', 'Does not match requirements');
65
+
66
+ // Step 5: Verify pain event was created
67
+ const painEventsAfter = trajectory.listPainEventsForSession('test-session-001');
68
+ expect(painEventsAfter.length).toBe(1);
69
+
70
+ const painEvent = painEventsAfter[0];
71
+ expect(painEvent.source).toBe('correction_rejected');
72
+ expect(painEvent.reason).toContain('Correction rejected');
73
+ expect(painEvent.origin).toBe('system_infer');
74
+ });
75
+
76
+ it('does NOT emit a pain event when a correction sample is approved', async () => {
77
+ // Setup: session + assistant turn + user correction turn
78
+ trajectory.recordSession({
79
+ sessionId: 'test-session-002',
80
+ startedAt: new Date().toISOString(),
81
+ });
82
+ const assistantTurnId = trajectory.recordAssistantTurn({
83
+ sessionId: 'test-session-002',
84
+ turnIndex: 0,
85
+ rawText: 'Here is some code',
86
+ createdAt: new Date().toISOString(),
87
+ });
88
+ trajectory.recordUserTurn({
89
+ sessionId: 'test-session-002',
90
+ turnIndex: 1,
91
+ rawText: 'This needs work',
92
+ correctionDetected: true,
93
+ correctionCue: 'This needs work',
94
+ referencesAssistantTurnId: assistantTurnId,
95
+ createdAt: new Date().toISOString(),
96
+ });
97
+
98
+ // Wait for async sample creation
99
+ await new Promise(resolve => setTimeout(resolve, 50));
100
+
101
+ // Get pending sample
102
+ const pendingSamples = trajectory.listCorrectionSamples('pending');
103
+ expect(pendingSamples.length).toBe(1);
104
+ const sampleId = pendingSamples[0].sampleId;
105
+
106
+ // Review as approved - should NOT trigger pain event
107
+ trajectory.reviewCorrectionSample(sampleId, 'approved', 'Looks good');
108
+
109
+ // Verify NO pain event was created (approved != rejected)
110
+ const painEvents = trajectory.listPainEventsForSession('test-session-002');
111
+ expect(painEvents.length).toBe(0);
112
+ });
113
+
114
+ it('maps quality_score to pain_score correctly (0-100 range)', async () => {
115
+ // Setup: with quality score components
116
+ trajectory.recordSession({
117
+ sessionId: 'test-session-003',
118
+ startedAt: new Date().toISOString(),
119
+ });
120
+ const assistantTurnId = trajectory.recordAssistantTurn({
121
+ sessionId: 'test-session-003',
122
+ turnIndex: 0,
123
+ rawText: 'Code here',
124
+ createdAt: new Date().toISOString(),
125
+ });
126
+
127
+ // Create user turn with correction_cue (adds 20 points)
128
+ trajectory.recordUserTurn({
129
+ sessionId: 'test-session-003',
130
+ turnIndex: 1,
131
+ rawText: 'Wrong approach. Try a different algorithm.',
132
+ correctionDetected: true,
133
+ correctionCue: 'Wrong approach. Try a different algorithm.',
134
+ referencesAssistantTurnId: assistantTurnId,
135
+ createdAt: new Date().toISOString(),
136
+ });
137
+
138
+ // Add a failed tool call (adds 20 points)
139
+ trajectory.recordToolCall({
140
+ sessionId: 'test-session-003',
141
+ turnIndex: 2,
142
+ toolName: 'write',
143
+ toolCallIndex: 0,
144
+ paramsJson: { path: '/tmp/test.txt', content: 'test' },
145
+ outcome: 'failure',
146
+ errorMessage: 'Permission denied',
147
+ errorType: 'PermissionError',
148
+ createdAt: new Date().toISOString(),
149
+ });
150
+
151
+ // Add successful calls (adds 25 points)
152
+ trajectory.recordToolCall({
153
+ sessionId: 'test-session-003',
154
+ turnIndex: 3,
155
+ toolName: 'read',
156
+ toolCallIndex: 1,
157
+ paramsJson: { path: '/tmp/test.txt' },
158
+ outcome: 'success',
159
+ resultJson: { content: 'file content' },
160
+ createdAt: new Date().toISOString(),
161
+ });
162
+
163
+ // Wait for async sample creation
164
+ await new Promise(resolve => setTimeout(resolve, 50));
165
+
166
+ // Get pending sample (quality_score ~65: 20 + 20 + 25)
167
+ const pendingSamples = trajectory.listCorrectionSamples('pending');
168
+ expect(pendingSamples.length).toBe(1);
169
+ const sampleId = pendingSamples[0].sampleId;
170
+
171
+ // Review as rejected
172
+ trajectory.reviewCorrectionSample(sampleId, 'rejected', 'Test rejection');
173
+
174
+ // Verify pain score is clamped to 0-100
175
+ const painEvents = trajectory.listPainEventsForSession('test-session-003');
176
+ expect(painEvents.length).toBe(1);
177
+ expect(painEvents[0].score).toBeGreaterThanOrEqual(0);
178
+ expect(painEvents[0].score).toBeLessThanOrEqual(100);
179
+ });
180
+ });