principles-disciple 1.55.0 → 1.56.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.
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/src/core/event-log.ts +33 -0
- package/src/core/evolution-types.ts +1 -1
- package/src/core/observability.ts +1 -1
- package/src/service/evolution-queue-migration.ts +2 -1
- package/src/service/evolution-worker.ts +31 -1
- package/src/tools/write-pain-flag.ts +0 -1
- package/tests/core/event-log.test.ts +74 -0
- package/tests/core/pain-context-extractor.test.ts +2 -1
- package/tests/core/pain-lifecycle.test.ts +2 -1
- package/tests/integration/pain-lifecycle-e2e.test.ts +2 -1
- package/tests/commands/evolver.test.ts +0 -22
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "principles-disciple",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.0",
|
|
4
4
|
"description": "Native OpenClaw plugin for Principles Disciple",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/bundle.js",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
+
"@principles/core": "^0.1.0",
|
|
70
71
|
"@sinclair/typebox": "^0.34.48",
|
|
71
72
|
"better-sqlite3": "^12.9.0",
|
|
72
73
|
"lucide-react": "^1.7.0",
|
package/src/core/event-log.ts
CHANGED
|
@@ -51,6 +51,9 @@ export class EventLog {
|
|
|
51
51
|
private currentEventsFile: string | undefined;
|
|
52
52
|
private currentDate: string | undefined;
|
|
53
53
|
|
|
54
|
+
// Pain score sum per date (for avgScore calculation)
|
|
55
|
+
private readonly painScoreSums: Map<string, number> = new Map();
|
|
56
|
+
|
|
54
57
|
constructor(stateDir: string, logger?: PluginLogger) {
|
|
55
58
|
this.logsDir = path.join(stateDir, 'logs');
|
|
56
59
|
if (!fs.existsSync(this.logsDir)) {
|
|
@@ -156,6 +159,10 @@ export class EventLog {
|
|
|
156
159
|
recordEvolutionTask(data: EvolutionTaskEventData): void {
|
|
157
160
|
this.record('evolution_task', 'enqueued', undefined, data);
|
|
158
161
|
}
|
|
162
|
+
|
|
163
|
+
recordEvolutionTaskCompleted(data: EvolutionTaskEventData): void {
|
|
164
|
+
this.record('evolution_task', 'completed', undefined, data);
|
|
165
|
+
}
|
|
159
166
|
|
|
160
167
|
recordDeepReflection(sessionId: string | undefined, data: DeepReflectionEventData): void {
|
|
161
168
|
const category = data.passed ? 'passed' : data.timeout ? 'failure' : 'completed';
|
|
@@ -238,6 +245,18 @@ export class EventLog {
|
|
|
238
245
|
stats.pain.signalsDetected++;
|
|
239
246
|
stats.pain.maxScore = Math.max(stats.pain.maxScore, data.score);
|
|
240
247
|
|
|
248
|
+
// Track signals by source
|
|
249
|
+
if (data.source) {
|
|
250
|
+
stats.pain.signalsBySource[data.source] = (stats.pain.signalsBySource[data.source] || 0) + 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Accumulate score for avg calculation
|
|
254
|
+
const currentSum = this.painScoreSums.get(entry.date) ?? 0;
|
|
255
|
+
this.painScoreSums.set(entry.date, currentSum + (data.score || 0));
|
|
256
|
+
stats.pain.avgScore = stats.pain.signalsDetected > 0
|
|
257
|
+
? Math.round((currentSum + (data.score || 0)) / stats.pain.signalsDetected)
|
|
258
|
+
: 0;
|
|
259
|
+
|
|
241
260
|
// Update empathy stats for user_empathy source
|
|
242
261
|
if (data.source === 'user_empathy') {
|
|
243
262
|
if (data.deduped) {
|
|
@@ -291,6 +310,20 @@ export class EventLog {
|
|
|
291
310
|
const data = entry.data as unknown as EmpathyRollbackEventData;
|
|
292
311
|
stats.empathy.rollbackCount++;
|
|
293
312
|
stats.empathy.rolledBackScore += data.originalScore || 0;
|
|
313
|
+
} else if (entry.type === 'rule_match') {
|
|
314
|
+
const data = entry.data as unknown as RuleMatchEventData;
|
|
315
|
+
if (data.ruleId) {
|
|
316
|
+
stats.pain.rulesMatched[data.ruleId] = (stats.pain.rulesMatched[data.ruleId] || 0) + 1;
|
|
317
|
+
}
|
|
318
|
+
} else if (entry.type === 'rule_promotion') {
|
|
319
|
+
stats.pain.candidatesPromoted++;
|
|
320
|
+
stats.evolution.rulesPromoted++;
|
|
321
|
+
} else if (entry.type === 'evolution_task') {
|
|
322
|
+
if (entry.category === 'completed') {
|
|
323
|
+
stats.evolution.tasksCompleted++;
|
|
324
|
+
} else if (entry.category === 'enqueued') {
|
|
325
|
+
stats.evolution.tasksEnqueued++;
|
|
326
|
+
}
|
|
294
327
|
}
|
|
295
328
|
}
|
|
296
329
|
|
|
@@ -470,7 +470,7 @@ export type EvolutionLoopEvent =
|
|
|
470
470
|
|
|
471
471
|
// V2 Queue Types (moved from evolution-worker.ts for shared use)
|
|
472
472
|
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
473
|
-
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'success' | 'failure' | 'skipped';
|
|
473
|
+
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'success' | 'failure' | 'skipped' | 'noise_classified';
|
|
474
474
|
|
|
475
475
|
export interface EvolutionQueueItem {
|
|
476
476
|
id: string;
|
|
@@ -235,7 +235,7 @@ function persistBaselines(stateDir: string, baselines: ObservabilityBaselines):
|
|
|
235
235
|
fs.mkdirSync(dir, { recursive: true });
|
|
236
236
|
}
|
|
237
237
|
atomicWriteFileSync(filePath, JSON.stringify(baselines, null, 2));
|
|
238
|
-
} catch
|
|
238
|
+
} catch {
|
|
239
239
|
// Baselines persistence is best-effort — don't crash the caller
|
|
240
240
|
// (the SystemLogger call above already logged the values)
|
|
241
241
|
}
|
|
@@ -26,7 +26,8 @@ export type TaskResolution =
|
|
|
26
26
|
| 'late_marker_principle_created'
|
|
27
27
|
| 'late_marker_no_principle'
|
|
28
28
|
| 'stub_fallback'
|
|
29
|
-
| 'skipped_thin_violation'
|
|
29
|
+
| 'skipped_thin_violation'
|
|
30
|
+
| 'noise_classified';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Recent pain context for sleep_reflection tasks.
|
|
@@ -109,7 +109,7 @@ let timeoutId: NodeJS.Timeout | null = null;
|
|
|
109
109
|
* Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
|
|
110
110
|
*/
|
|
111
111
|
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
112
|
-
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation';
|
|
112
|
+
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'noise_classified';
|
|
113
113
|
|
|
114
114
|
export interface EvolutionQueueItem {
|
|
115
115
|
// Core identity
|
|
@@ -926,6 +926,21 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
926
926
|
if (fs.existsSync(reportPath)) {
|
|
927
927
|
try {
|
|
928
928
|
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
929
|
+
|
|
930
|
+
// ── Step 3: Noise Classification Filter ──
|
|
931
|
+
// Skip principle creation for low-value noise categories that don't represent
|
|
932
|
+
// systemic failures or behavioral issues worth encoding as principles.
|
|
933
|
+
const classification = reportData?.classification;
|
|
934
|
+
const noiseCategories: Record<string, boolean> = {
|
|
935
|
+
'development_transient': true, // CRLF drift, duplicate match, self-resolved dev issues
|
|
936
|
+
'user_error': true, // User mistakes, wrong file, bad input
|
|
937
|
+
};
|
|
938
|
+
if (classification?.category && noiseCategories[classification.category]) {
|
|
939
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Skipping principle for noise category "${classification.category}" — pain was ${classification.severity || 'low'} severity, not a systemic failure`);
|
|
940
|
+
task.status = 'completed';
|
|
941
|
+
task.completed_at = new Date().toISOString();
|
|
942
|
+
task.resolution = 'noise_classified';
|
|
943
|
+
} else {
|
|
929
944
|
// Check ALL known nesting paths — matches subagent.ts parseDiagnosticianReport
|
|
930
945
|
const principle = reportData?.principle
|
|
931
946
|
|| reportData?.phases?.principle_extraction?.principle
|
|
@@ -1017,6 +1032,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1017
1032
|
} else {
|
|
1018
1033
|
logger.warn(`[PD:EvolutionWorker] Diagnostician report for task ${task.id} missing principle fields — diagnostician did not produce a principle`);
|
|
1019
1034
|
}
|
|
1035
|
+
}
|
|
1020
1036
|
} catch (err) {
|
|
1021
1037
|
logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
|
|
1022
1038
|
}
|
|
@@ -1051,6 +1067,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1051
1067
|
durationMs,
|
|
1052
1068
|
});
|
|
1053
1069
|
|
|
1070
|
+
// Record task completion in event stats
|
|
1071
|
+
eventLog.recordEvolutionTaskCompleted({
|
|
1072
|
+
taskId: task.id,
|
|
1073
|
+
taskType: task.source || 'unknown',
|
|
1074
|
+
reason: task.reason || '',
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1054
1077
|
// Update evolution_tasks table
|
|
1055
1078
|
wctx.trajectory?.updateEvolutionTask?.(task.id, {
|
|
1056
1079
|
status: 'completed',
|
|
@@ -1139,6 +1162,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1139
1162
|
durationMs: age,
|
|
1140
1163
|
});
|
|
1141
1164
|
|
|
1165
|
+
// Record task completion in event stats (for timeout path too)
|
|
1166
|
+
eventLog.recordEvolutionTaskCompleted({
|
|
1167
|
+
taskId: task.id,
|
|
1168
|
+
taskType: task.source || 'unknown',
|
|
1169
|
+
reason: task.reason || '',
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1142
1172
|
// Update evolution_tasks table - use task.resolution, not hardcoded value
|
|
1143
1173
|
wctx.trajectory?.updateEvolutionTask?.(task.id, {
|
|
1144
1174
|
status: 'completed',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from '../openclaw-sdk.js';
|
|
2
2
|
import { Type } from '@sinclair/typebox';
|
|
3
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
4
3
|
import { buildPainFlag } from '../core/pain.js';
|
|
5
4
|
import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
|
|
6
5
|
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
@@ -179,4 +179,78 @@ describe('EventLog', () => {
|
|
|
179
179
|
expect(stats.totalPenaltyScore).toBe(12);
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
describe('Evolution and rule stats', () => {
|
|
184
|
+
it('should count evolution_task enqueued events', () => {
|
|
185
|
+
eventLog.recordEvolutionTask({ taskId: 't1', taskType: 'pain_diagnosis', reason: 'test' });
|
|
186
|
+
eventLog.recordEvolutionTask({ taskId: 't2', taskType: 'pain_diagnosis', reason: 'test' });
|
|
187
|
+
|
|
188
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
189
|
+
const stats = eventLog.getDailyStats(today);
|
|
190
|
+
|
|
191
|
+
expect(stats.evolution.tasksEnqueued).toBe(2);
|
|
192
|
+
expect(stats.evolution.tasksCompleted).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should count evolution_task completed events', () => {
|
|
196
|
+
// First enqueue
|
|
197
|
+
eventLog.recordEvolutionTask({ taskId: 't1', taskType: 'pain_diagnosis', reason: 'test' });
|
|
198
|
+
// Then complete
|
|
199
|
+
eventLog.recordEvolutionTaskCompleted({ taskId: 't1', taskType: 'pain_diagnosis', reason: 'test' });
|
|
200
|
+
|
|
201
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
202
|
+
const stats = eventLog.getDailyStats(today);
|
|
203
|
+
|
|
204
|
+
expect(stats.evolution.tasksEnqueued).toBe(1);
|
|
205
|
+
expect(stats.evolution.tasksCompleted).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should track rule_match events in rulesMatched', () => {
|
|
209
|
+
eventLog.recordRuleMatch('s1', { ruleId: 'edit-exact-match', layer: 'L2', severity: 0.8, textPreview: 'test' });
|
|
210
|
+
eventLog.recordRuleMatch('s1', { ruleId: 'edit-exact-match', layer: 'L2', severity: 0.8, textPreview: 'test' });
|
|
211
|
+
eventLog.recordRuleMatch('s1', { ruleId: 'path-traversal', layer: 'L1', severity: 0.9, textPreview: 'test2' });
|
|
212
|
+
|
|
213
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
214
|
+
const stats = eventLog.getDailyStats(today);
|
|
215
|
+
|
|
216
|
+
expect(stats.pain.rulesMatched['edit-exact-match']).toBe(2);
|
|
217
|
+
expect(stats.pain.rulesMatched['path-traversal']).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should track rule_promotion events', () => {
|
|
221
|
+
eventLog.recordRulePromotion({ fingerprint: 'fp1', ruleId: 'r1', phrase: 'test', sampleCount: 5, avgSimilarity: 0.9 });
|
|
222
|
+
eventLog.recordRulePromotion({ fingerprint: 'fp2', ruleId: 'r2', phrase: 'test2', sampleCount: 3, avgSimilarity: 0.8 });
|
|
223
|
+
|
|
224
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
225
|
+
const stats = eventLog.getDailyStats(today);
|
|
226
|
+
|
|
227
|
+
expect(stats.pain.candidatesPromoted).toBe(2);
|
|
228
|
+
expect(stats.evolution.rulesPromoted).toBe(2);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should track pain signals by source', () => {
|
|
232
|
+
eventLog.recordPainSignal('s1', { source: 'tool_failure', score: 50, reason: 'edit failed' });
|
|
233
|
+
eventLog.recordPainSignal('s2', { source: 'tool_failure', score: 60, reason: 'read failed' });
|
|
234
|
+
eventLog.recordPainSignal('s3', { source: 'user_empathy', score: 10, reason: 'user frustrated' });
|
|
235
|
+
|
|
236
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
237
|
+
const stats = eventLog.getDailyStats(today);
|
|
238
|
+
|
|
239
|
+
expect(stats.pain.signalsBySource['tool_failure']).toBe(2);
|
|
240
|
+
expect(stats.pain.signalsBySource['user_empathy']).toBe(1);
|
|
241
|
+
expect(stats.pain.signalsDetected).toBe(3);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should calculate avgScore for pain signals', () => {
|
|
245
|
+
eventLog.recordPainSignal('s1', { source: 'tool_failure', score: 50, reason: 'test' });
|
|
246
|
+
eventLog.recordPainSignal('s2', { source: 'tool_failure', score: 70, reason: 'test' });
|
|
247
|
+
eventLog.recordPainSignal('s3', { source: 'tool_failure', score: 60, reason: 'test' });
|
|
248
|
+
|
|
249
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
250
|
+
const stats = eventLog.getDailyStats(today);
|
|
251
|
+
|
|
252
|
+
expect(stats.pain.avgScore).toBe(60); // (50+70+60)/3 = 60
|
|
253
|
+
expect(stats.pain.maxScore).toBe(70);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
182
256
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
4
5
|
|
|
5
|
-
const TEST_AGENTS_DIR = path.join(
|
|
6
|
+
const TEST_AGENTS_DIR = path.join(os.tmpdir(), 'pd-test-agents-' + Date.now());
|
|
6
7
|
|
|
7
8
|
// Set env before module load
|
|
8
9
|
process.env.PD_TEST_AGENTS_DIR = TEST_AGENTS_DIR;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
4
5
|
import { clearPainFlag, PAIN_FLAG_FILENAME } from '../../src/core/pain-lifecycle.js';
|
|
5
6
|
import { resolvePdPath } from '../../src/core/paths.js';
|
|
6
7
|
|
|
7
8
|
describe('PainLifecycle', () => {
|
|
8
|
-
const workspaceDir = fs.mkdtempSync(path.join(
|
|
9
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pain-lifecycle-test-'));
|
|
9
10
|
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
10
11
|
|
|
11
12
|
beforeEach(() => {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
4
5
|
import { clearPainFlag } from '../../src/core/pain-lifecycle.js';
|
|
5
6
|
import { resolvePdPath } from '../../src/core/paths.js';
|
|
6
7
|
|
|
7
8
|
describe('Pain Lifecycle E2E', () => {
|
|
8
|
-
const workspaceDir = fs.mkdtempSync(path.join(
|
|
9
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pain-lifecycle-e2e-'));
|
|
9
10
|
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
10
11
|
const stateDir = path.dirname(painFlagPath);
|
|
11
12
|
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { handleEvolveTask } from '../../src/commands/evolver';
|
|
3
|
-
|
|
4
|
-
describe('Evolver Synergy Module', () => {
|
|
5
|
-
it('should handle /evolve-task command', () => {
|
|
6
|
-
const mockCtx = {
|
|
7
|
-
workspaceDir: '/mock/workspace',
|
|
8
|
-
commandBody: '/evolve-task',
|
|
9
|
-
channel: 'cli',
|
|
10
|
-
isAuthorizedSender: true,
|
|
11
|
-
config: {} as any,
|
|
12
|
-
args: 'Fix tests'
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const result = handleEvolveTask(mockCtx as any);
|
|
16
|
-
|
|
17
|
-
expect(result).toBeDefined();
|
|
18
|
-
expect(result.text).toContain('Evolver Handoff Requested');
|
|
19
|
-
expect(result.text).toContain('sessions_spawn');
|
|
20
|
-
expect(result.text).toContain('Fix tests');
|
|
21
|
-
});
|
|
22
|
-
});
|