principles-disciple 1.51.0 → 1.53.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 +1 -1
- package/scripts/compile-principles.mjs +11 -1
- package/scripts/sync-plugin.mjs +183 -19
- package/src/core/bootstrap-rules.ts +41 -4
- package/src/core/evolution-hook.ts +74 -0
- package/src/core/file-storage-adapter.ts +203 -0
- package/src/core/init.ts +29 -2
- package/src/core/nocturnal-trinity.ts +230 -0
- package/src/core/observability.ts +242 -0
- package/src/core/pain-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +136 -0
- package/src/core/pd-task-reconciler.ts +14 -11
- package/src/core/principle-injection.ts +208 -0
- package/src/core/principle-injector.ts +84 -0
- package/src/core/storage-adapter.ts +65 -0
- package/src/core/telemetry-event.ts +109 -0
- package/src/hooks/prompt.ts +33 -5
- package/src/service/event-log-auditor.ts +52 -39
- package/src/service/evolution-worker.ts +52 -2
- package/tests/core/evolution-hook.test.ts +123 -0
- package/tests/core/file-storage-adapter.test.ts +285 -0
- package/tests/core/nocturnal-trinity.test.ts +236 -0
- package/tests/core/observability.test.ts +383 -0
- package/tests/core/pain-signal-adapter.test.ts +116 -0
- package/tests/core/pain-signal.test.ts +190 -0
- package/tests/core/principle-injection.test.ts +223 -0
- package/tests/core/principle-injector.test.ts +90 -0
- package/tests/core/storage-conformance.test.ts +429 -0
- package/tests/core/telemetry-event.test.ts +119 -0
package/src/hooks/prompt.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { classifyTask, type RoutingInput } from '../core/local-worker-routing.js
|
|
|
11
11
|
import { extractSummary, getHistoryVersions, parseWorkingMemorySection, workingMemoryToInjection, autoCompressFocus, safeReadCurrentFocus } from '../core/focus-history.js';
|
|
12
12
|
import { EmpathyObserverWorkflowManager, empathyObserverWorkflowSpec, isExpectedSubagentError } from '../service/subagent-workflow/index.js';
|
|
13
13
|
import { PathResolver } from '../core/path-resolver.js';
|
|
14
|
+
import { selectPrinciplesForInjection, DEFAULT_PRINCIPLE_BUDGET } from '../core/principle-injection.js';
|
|
14
15
|
import { isSubagentRuntimeAvailable } from '../utils/subagent-probe.js';
|
|
15
16
|
import { getPendingDiagnosticianTasks } from '../core/diagnostician-task-store.js';
|
|
16
17
|
import {
|
|
@@ -533,8 +534,11 @@ The empathy observer subagent handles pain detection independently.
|
|
|
533
534
|
|
|
534
535
|
const empathyEnabled = wctx.config.get('empathy_engine.enabled') !== false;
|
|
535
536
|
logger?.info?.(`[PD:Empathy] Conditions: enabled=${empathyEnabled}, isUser=${isUserInteraction}, sessionId=${!!sessionId}, api=${!!api}, !agentToAgent=${!isAgentToAgent}, workspaceDir=${!!workspaceDir}, hasMessage=${!!latestUserMessage}`);
|
|
537
|
+
|
|
538
|
+
// Track if we should inject behavioral constraints (will be added to appendSystemContext later)
|
|
539
|
+
let shouldInjectBehavioralConstraints = false;
|
|
536
540
|
if (empathyEnabled && isUserInteraction && sessionId && api && !isAgentToAgent) {
|
|
537
|
-
|
|
541
|
+
shouldInjectBehavioralConstraints = true;
|
|
538
542
|
|
|
539
543
|
// ── Empathy Hybrid Matching (keyword + subagent sampling) ──
|
|
540
544
|
// Fast keyword scan on every turn, with strategic subagent sampling
|
|
@@ -898,12 +902,26 @@ ${taskBlocks}${processingNote}
|
|
|
898
902
|
}
|
|
899
903
|
|
|
900
904
|
|
|
901
|
-
// Evolution principles injection
|
|
905
|
+
// Evolution principles injection — budget-aware selection (SDK-QUAL-04)
|
|
902
906
|
let evolutionPrinciplesContent = '';
|
|
903
907
|
try {
|
|
904
908
|
const reducer = wctx.evolutionReducer;
|
|
905
|
-
const
|
|
906
|
-
const
|
|
909
|
+
const allActive = reducer.getActivePrinciples();
|
|
910
|
+
const allProbation = reducer.getProbationPrinciples();
|
|
911
|
+
|
|
912
|
+
// Budget-aware selection: prioritize P0>P1>P2 and recency
|
|
913
|
+
const activeSelection = selectPrinciplesForInjection(allActive, DEFAULT_PRINCIPLE_BUDGET);
|
|
914
|
+
const active = activeSelection.selected;
|
|
915
|
+
|
|
916
|
+
// Probation principles get a smaller sub-budget (1000 chars)
|
|
917
|
+
const probationBudget = 1000;
|
|
918
|
+
const probationSelection = selectPrinciplesForInjection(allProbation, probationBudget);
|
|
919
|
+
const probation = probationSelection.selected;
|
|
920
|
+
|
|
921
|
+
if (activeSelection.wasTruncated || probationSelection.wasTruncated) {
|
|
922
|
+
logger?.info?.(`[PD:Prompt] Principles truncated: active=${activeSelection.breakdown.p0 + activeSelection.breakdown.p1 + activeSelection.breakdown.p2}/${allActive.length} (${activeSelection.totalChars}c), probation=${probation.length}/${allProbation.length} (${probationSelection.totalChars}c)`);
|
|
923
|
+
}
|
|
924
|
+
|
|
907
925
|
if (ctx.sessionId) {
|
|
908
926
|
if (probation.length > 0) {
|
|
909
927
|
setInjectedProbationIds(ctx.sessionId, probation.map((p) => p.id), workspaceDir);
|
|
@@ -935,9 +953,18 @@ ${taskBlocks}${processingNote}
|
|
|
935
953
|
}
|
|
936
954
|
|
|
937
955
|
// Build appendSystemContext with recency effect
|
|
938
|
-
// Content order (most important last): project_context -> working_memory -> reflection_log -> thinking_os -> principles
|
|
956
|
+
// Content order (most important last): behavioral_constraints -> project_context -> working_memory -> reflection_log -> thinking_os -> principles
|
|
939
957
|
const appendParts: string[] = [];
|
|
940
958
|
|
|
959
|
+
// 0. Behavioral Constraints (empathy observer coordination)
|
|
960
|
+
// Injected here (appendSystemContext) instead of prependContext to hide from WebUI users.
|
|
961
|
+
// See: https://github.com/csuzngjh/principles/issues/XXX
|
|
962
|
+
if (shouldInjectBehavioralConstraints) {
|
|
963
|
+
appendParts.push(`<behavioral_constraints>
|
|
964
|
+
${empathySilenceConstraint}
|
|
965
|
+
</behavioral_constraints>`);
|
|
966
|
+
}
|
|
967
|
+
|
|
941
968
|
// 1. Project Context (lowest priority, goes first)
|
|
942
969
|
if (projectContextContent) {
|
|
943
970
|
appendParts.push(`<project_context>\n${projectContextContent}\n</project_context>`);
|
|
@@ -1087,6 +1114,7 @@ The sections below are ordered by priority. When conflicts arise, **later sectio
|
|
|
1087
1114
|
---
|
|
1088
1115
|
|
|
1089
1116
|
**【EXECUTION RULES】** (Priority: Low → High):
|
|
1117
|
+
- \`<behavioral_constraints>\` - Output format restrictions (hide diagnostic JSON)
|
|
1090
1118
|
- \`<project_context>\` - Current priorities (can be overridden)
|
|
1091
1119
|
- \`<reflection_log>\` - Past lessons (inform your approach)
|
|
1092
1120
|
- \`<thinking_os>\` - Thinking models (guide your reasoning)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* EventLog Auditor
|
|
3
|
-
*
|
|
2
|
+
* EventLog Auditor - Search and verify events across all .state directories
|
|
3
|
+
*
|
|
4
4
|
* This tool addresses a common debugging issue where hook events may be
|
|
5
5
|
* written to the wrong .state directory due to workspaceDir resolution bugs.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* const report = await auditEventLogs(openclawDir, ['after_tool_call', 'before_tool_call']);
|
|
9
9
|
* console.log(report.summary);
|
|
@@ -42,7 +42,7 @@ interface AuditReport {
|
|
|
42
42
|
*/
|
|
43
43
|
function findEventLogs(baseDir: string, maxDepth = 4): string[] {
|
|
44
44
|
const results: string[] = [];
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
function scan(dir: string, depth: number): void {
|
|
47
47
|
if (depth > maxDepth) return;
|
|
48
48
|
try {
|
|
@@ -58,37 +58,50 @@ function findEventLogs(baseDir: string, maxDepth = 4): string[] {
|
|
|
58
58
|
// Permission denied or directory doesn't exist
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
scan(baseDir, 0);
|
|
63
63
|
return results;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Find events.jsonl in well-known locations.
|
|
68
|
+
* Dynamically discovers workspace directories instead of hardcoding names.
|
|
68
69
|
*/
|
|
69
70
|
function findKnownEventLogPaths(): string[] {
|
|
70
71
|
const homeDir = os.homedir();
|
|
71
72
|
const candidates: string[] = [];
|
|
72
|
-
|
|
73
|
-
// Common patterns
|
|
74
|
-
const
|
|
73
|
+
|
|
74
|
+
// Common patterns (legacy, non-workspace paths)
|
|
75
|
+
const legacyPatterns = [
|
|
75
76
|
path.join(homeDir, '.state', 'logs', 'events.jsonl'),
|
|
76
77
|
path.join(homeDir, '.openclaw', '.state', 'logs', 'events.jsonl'),
|
|
77
|
-
path.join(homeDir, '.openclaw', 'workspace-main', '.state', 'logs', 'events.jsonl'),
|
|
78
|
-
path.join(homeDir, '.openclaw', 'workspace-builder', '.state', 'logs', 'events.jsonl'),
|
|
79
|
-
path.join(homeDir, '.openclaw', 'workspace-pm', '.state', 'logs', 'events.jsonl'),
|
|
80
|
-
path.join(homeDir, '.openclaw', 'workspace-hr', '.state', 'logs', 'events.jsonl'),
|
|
81
|
-
path.join(homeDir, '.openclaw', 'workspace-repair', '.state', 'logs', 'events.jsonl'),
|
|
82
|
-
path.join(homeDir, '.openclaw', 'workspace-research', '.state', 'logs', 'events.jsonl'),
|
|
83
|
-
path.join(homeDir, '.openclaw', 'workspace-scout', '.state', 'logs', 'events.jsonl'),
|
|
84
78
|
];
|
|
85
|
-
|
|
86
|
-
for (const p of
|
|
79
|
+
|
|
80
|
+
for (const p of legacyPatterns) {
|
|
87
81
|
if (fs.existsSync(p)) {
|
|
88
82
|
candidates.push(p);
|
|
89
83
|
}
|
|
90
84
|
}
|
|
91
|
-
|
|
85
|
+
|
|
86
|
+
// Dynamically discover workspace directories under ~/.openclaw/
|
|
87
|
+
const openclawDir = path.join(homeDir, '.openclaw');
|
|
88
|
+
if (fs.existsSync(openclawDir)) {
|
|
89
|
+
try {
|
|
90
|
+
const entries = fs.readdirSync(openclawDir, { withFileTypes: true });
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry.isDirectory()) continue;
|
|
93
|
+
// Skip known non-workspace directories
|
|
94
|
+
if (entry.name.startsWith('.') || entry.name === 'extensions' || entry.name === 'memory') continue;
|
|
95
|
+
const eventLogPath = path.join(openclawDir, entry.name, '.state', 'logs', 'events.jsonl');
|
|
96
|
+
if (fs.existsSync(eventLogPath)) {
|
|
97
|
+
candidates.push(eventLogPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Directory read failed, skip dynamic discovery
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
return candidates;
|
|
93
106
|
}
|
|
94
107
|
|
|
@@ -133,25 +146,25 @@ function countAllHooks(filePath: string): Record<string, number> {
|
|
|
133
146
|
|
|
134
147
|
/**
|
|
135
148
|
* Audit all events.jsonl files.
|
|
136
|
-
*
|
|
149
|
+
*
|
|
137
150
|
* @param openclawDir - Base OpenClaw directory (e.g., ~/.openclaw)
|
|
138
151
|
* @param expectedToolHooks - Hook names that should appear in the primary workspace
|
|
139
152
|
*/
|
|
140
|
-
|
|
153
|
+
|
|
141
154
|
export async function auditEventLogs(
|
|
142
155
|
openclawDir: string,
|
|
143
156
|
expectedToolHooks: string[] = ['before_tool_call', 'after_tool_call'],
|
|
144
157
|
): Promise<AuditReport> {
|
|
145
158
|
const homeDir = os.homedir();
|
|
146
|
-
|
|
159
|
+
|
|
147
160
|
// Find all event logs
|
|
148
161
|
const knownPaths = findKnownEventLogPaths();
|
|
149
162
|
const scannedPaths = findEventLogs(homeDir, 4);
|
|
150
163
|
const allPaths = [...new Set([...knownPaths, ...scannedPaths])];
|
|
151
|
-
|
|
164
|
+
|
|
152
165
|
const locations: LocationReport[] = [];
|
|
153
166
|
let primaryPath: string | null = null;
|
|
154
|
-
|
|
167
|
+
|
|
155
168
|
for (const filePath of allPaths) {
|
|
156
169
|
try {
|
|
157
170
|
const stat = fs.statSync(filePath);
|
|
@@ -165,7 +178,7 @@ export async function auditEventLogs(
|
|
|
165
178
|
hookCounts: allCounts,
|
|
166
179
|
recentEntries: recent,
|
|
167
180
|
});
|
|
168
|
-
|
|
181
|
+
|
|
169
182
|
// Determine primary path (workspace-main or most recent)
|
|
170
183
|
if (filePath.includes('workspace-main') || filePath.includes('workspace-main')) {
|
|
171
184
|
primaryPath = filePath;
|
|
@@ -174,7 +187,7 @@ export async function auditEventLogs(
|
|
|
174
187
|
// Skip unreadable files
|
|
175
188
|
}
|
|
176
189
|
}
|
|
177
|
-
|
|
190
|
+
|
|
178
191
|
// If no primary found, use most recent
|
|
179
192
|
if (!primaryPath && locations.length > 0) {
|
|
180
193
|
locations.sort((a, b) => {
|
|
@@ -184,21 +197,21 @@ export async function auditEventLogs(
|
|
|
184
197
|
});
|
|
185
198
|
primaryPath = locations[0].path;
|
|
186
199
|
}
|
|
187
|
-
|
|
200
|
+
|
|
188
201
|
// Detect misplaced tool hook events
|
|
189
202
|
const misplacedEvents: { path: string; entries: EventLogEntry[] }[] = [];
|
|
190
203
|
for (const loc of locations) {
|
|
191
204
|
if (loc.path === primaryPath) continue;
|
|
192
|
-
|
|
193
|
-
const toolHookEntries = loc.recentEntries.filter(e =>
|
|
205
|
+
|
|
206
|
+
const toolHookEntries = loc.recentEntries.filter(e =>
|
|
194
207
|
e.type === 'hook_execution' && expectedToolHooks.includes(e.data?.hook as string)
|
|
195
208
|
);
|
|
196
|
-
|
|
209
|
+
|
|
197
210
|
if (toolHookEntries.length > 0) {
|
|
198
211
|
misplacedEvents.push({ path: loc.path, entries: toolHookEntries });
|
|
199
212
|
}
|
|
200
213
|
}
|
|
201
|
-
|
|
214
|
+
|
|
202
215
|
return {
|
|
203
216
|
searchedPaths: allPaths,
|
|
204
217
|
locations,
|
|
@@ -210,37 +223,37 @@ export async function auditEventLogs(
|
|
|
210
223
|
/**
|
|
211
224
|
* Format audit report for display.
|
|
212
225
|
*/
|
|
213
|
-
|
|
226
|
+
|
|
214
227
|
export function formatAuditReport(report: AuditReport): string {
|
|
215
228
|
const lines: string[] = [];
|
|
216
|
-
|
|
229
|
+
|
|
217
230
|
lines.push('=== Event Log Audit Report ===\n');
|
|
218
|
-
|
|
231
|
+
|
|
219
232
|
lines.push(`Searched ${report.searchedPaths.length} paths:\n`);
|
|
220
233
|
for (const p of report.searchedPaths) {
|
|
221
234
|
lines.push(` ${p}`);
|
|
222
235
|
}
|
|
223
236
|
lines.push('');
|
|
224
|
-
|
|
237
|
+
|
|
225
238
|
lines.push(`Primary: ${report.primaryPath ?? 'NOT FOUND'}\n`);
|
|
226
|
-
|
|
239
|
+
|
|
227
240
|
for (const loc of report.locations) {
|
|
228
241
|
const isPrimary = loc.path === report.primaryPath;
|
|
229
242
|
lines.push(`─── ${isPrimary ? '[PRIMARY]' : '[OTHER] '}${loc.path}`);
|
|
230
243
|
lines.push(` Last modified: ${loc.lastModified?.toISOString() ?? 'never'}`);
|
|
231
244
|
lines.push(` Hook counts:`);
|
|
232
|
-
|
|
245
|
+
|
|
233
246
|
const hooks = Object.entries(loc.hookCounts).sort((a, b) => b[1] - a[1]);
|
|
234
247
|
for (const [hook, count] of hooks) {
|
|
235
248
|
lines.push(` ${hook}: ${count}`);
|
|
236
249
|
}
|
|
237
|
-
|
|
250
|
+
|
|
238
251
|
if (hooks.length === 0) {
|
|
239
252
|
lines.push(` (no hooks recorded)`);
|
|
240
253
|
}
|
|
241
254
|
lines.push('');
|
|
242
255
|
}
|
|
243
|
-
|
|
256
|
+
|
|
244
257
|
if (report.misplacedEvents.length > 0) {
|
|
245
258
|
lines.push('⚠️ MISPLACED tool hook events detected:');
|
|
246
259
|
for (const me of report.misplacedEvents) {
|
|
@@ -258,6 +271,6 @@ export function formatAuditReport(report: AuditReport): string {
|
|
|
258
271
|
} else {
|
|
259
272
|
lines.push('✅ No misplaced tool hook events detected.');
|
|
260
273
|
}
|
|
261
|
-
|
|
274
|
+
|
|
262
275
|
return lines.join('\n');
|
|
263
276
|
}
|
|
@@ -18,6 +18,7 @@ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
|
18
18
|
import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
|
|
19
19
|
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
20
20
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
21
|
+
import { validatePainSignal, type PainSignalValidationResult } from '../core/pain-signal.js';
|
|
21
22
|
|
|
22
23
|
// Re-export queue I/O (extracted to queue-io.ts)
|
|
23
24
|
export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
|
|
@@ -330,6 +331,26 @@ async function doEnqueuePainTask(
|
|
|
330
331
|
return result;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
// Validate pain signal through TypeBox schema before enqueuing.
|
|
335
|
+
// Malformed signals are logged and skipped — they never enter the queue.
|
|
336
|
+
const signalInput = {
|
|
337
|
+
source: v.source,
|
|
338
|
+
score: v.score,
|
|
339
|
+
timestamp: new Date().toISOString(),
|
|
340
|
+
reason: v.reason,
|
|
341
|
+
sessionId: v.sessionId || '',
|
|
342
|
+
agentId: v.agentId || '',
|
|
343
|
+
traceId: v.traceId || '',
|
|
344
|
+
triggerTextPreview: v.preview,
|
|
345
|
+
};
|
|
346
|
+
const validation: PainSignalValidationResult = validatePainSignal(signalInput);
|
|
347
|
+
if (!validation.valid) {
|
|
348
|
+
result.skipped_reason = `invalid_pain_signal (${validation.errors.join('; ')})`;
|
|
349
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Pain signal validation failed, skipping enqueue: ${validation.errors.join('; ')}`);
|
|
350
|
+
SystemLogger.log(wctx.workspaceDir, 'PAIN_SIGNAL_INVALID', `Validation errors: ${validation.errors.join('; ')} | source=${v.source} score=${v.score}`);
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
|
|
333
354
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
334
355
|
const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
|
|
335
356
|
try {
|
|
@@ -763,9 +784,38 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
763
784
|
}
|
|
764
785
|
|
|
765
786
|
// V2: Migrate queue to current schema if needed
|
|
766
|
-
|
|
787
|
+
let queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
788
|
+
|
|
789
|
+
// Validate queue items — filter out malformed entries before processing.
|
|
790
|
+
// Malformed items are logged + skipped; they never crash the evolution cycle.
|
|
791
|
+
const beforeValidation = queue.length;
|
|
792
|
+
queue = queue.filter((item) => {
|
|
793
|
+
const errors: string[] = [];
|
|
794
|
+
if (!item.id || typeof item.id !== 'string') errors.push('missing/invalid id');
|
|
795
|
+
if (!item.source || typeof item.source !== 'string') errors.push('missing/invalid source');
|
|
796
|
+
if (typeof item.score !== 'number') errors.push('missing/invalid score');
|
|
797
|
+
if (!item.status || typeof item.status !== 'string') errors.push('missing/invalid status');
|
|
798
|
+
if (!item.taskKind || typeof item.taskKind !== 'string') errors.push('missing/invalid taskKind');
|
|
799
|
+
else {
|
|
800
|
+
const validTaskKinds = ['pain_diagnosis', 'sleep_reflection', 'model_eval', 'keyword_optimization'];
|
|
801
|
+
if (!validTaskKinds.includes(item.taskKind)) {
|
|
802
|
+
errors.push(`invalid taskKind value '${item.taskKind}' (expected one of: ${validTaskKinds.join(', ')})`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (typeof item.retryCount !== 'number') errors.push('missing/invalid retryCount');
|
|
806
|
+
if (typeof item.maxRetries !== 'number') errors.push('missing/invalid maxRetries');
|
|
807
|
+
if (errors.length > 0) {
|
|
808
|
+
logger?.warn?.(`[PD:EvolutionWorker] Skipping malformed queue item: ${errors.join(', ')} | ${JSON.stringify(item).slice(0, 200)}`);
|
|
809
|
+
SystemLogger.log(wctx.workspaceDir, 'QUEUE_ITEM_MALFORMED', `Skipped: ${errors.join(', ')} | id=${item.id || 'N/A'}`);
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
return true;
|
|
813
|
+
});
|
|
814
|
+
if (queue.length < beforeValidation) {
|
|
815
|
+
logger?.info?.(`[PD:EvolutionWorker] Filtered ${beforeValidation - queue.length} malformed queue item(s)`);
|
|
816
|
+
}
|
|
767
817
|
|
|
768
|
-
let queueChanged = rawQueue.some(isLegacyQueueItem);
|
|
818
|
+
let queueChanged = rawQueue.some(isLegacyQueueItem) || queue.length < beforeValidation;
|
|
769
819
|
|
|
770
820
|
// Guard: Skip keyword_optimization if one is already pending/in-progress (CORR-08)
|
|
771
821
|
if (hasPendingTask(queue, 'keyword_optimization')) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EvolutionHook, PrincipleCreatedEvent, PrinciplePromotedEvent } from '../../src/core/evolution-hook.js';
|
|
3
|
+
import { noOpEvolutionHook } from '../../src/core/evolution-hook.js';
|
|
4
|
+
import type { PainSignal } from '../../src/core/pain-signal.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function validPainSignal(overrides: Partial<PainSignal> = {}): PainSignal {
|
|
11
|
+
return {
|
|
12
|
+
source: 'tool_failure',
|
|
13
|
+
score: 75,
|
|
14
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
15
|
+
reason: 'File not found',
|
|
16
|
+
sessionId: 'session-001',
|
|
17
|
+
agentId: 'main',
|
|
18
|
+
traceId: 'trace-001',
|
|
19
|
+
triggerTextPreview: 'File not found: test.ts',
|
|
20
|
+
domain: 'coding',
|
|
21
|
+
severity: 'high',
|
|
22
|
+
context: {},
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Tests
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe('EvolutionHook', () => {
|
|
32
|
+
it('implements all 3 methods', () => {
|
|
33
|
+
const calls: string[] = [];
|
|
34
|
+
const hook: EvolutionHook = {
|
|
35
|
+
onPainDetected(signal: PainSignal): void { calls.push(`pain:${signal.source}`); },
|
|
36
|
+
onPrincipleCreated(event: PrincipleCreatedEvent): void { calls.push(`created:${event.id}`); },
|
|
37
|
+
onPrinciplePromoted(event: PrinciplePromotedEvent): void { calls.push(`promoted:${event.id}`); },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
hook.onPainDetected(validPainSignal());
|
|
41
|
+
hook.onPrincipleCreated({ id: 'p-1', text: 'Test principle', trigger: 'tool failure' });
|
|
42
|
+
hook.onPrinciplePromoted({ id: 'p-1', from: 'candidate', to: 'active' });
|
|
43
|
+
|
|
44
|
+
expect(calls).toEqual(['pain:tool_failure', 'created:p-1', 'promoted:p-1']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('onPainDetected receives a PainSignal', () => {
|
|
48
|
+
let received: PainSignal | undefined;
|
|
49
|
+
const hook: EvolutionHook = {
|
|
50
|
+
...noOpEvolutionHook,
|
|
51
|
+
onPainDetected(signal: PainSignal): void { received = signal; },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const signal = validPainSignal();
|
|
55
|
+
hook.onPainDetected(signal);
|
|
56
|
+
expect(received).toEqual(signal);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('onPrincipleCreated receives a PrincipleCreatedEvent', () => {
|
|
60
|
+
let received: PrincipleCreatedEvent | undefined;
|
|
61
|
+
const hook: EvolutionHook = {
|
|
62
|
+
...noOpEvolutionHook,
|
|
63
|
+
onPrincipleCreated(event: PrincipleCreatedEvent): void { received = event; },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const event = { id: 'p-1', text: 'Test principle', trigger: 'tool failure' };
|
|
67
|
+
hook.onPrincipleCreated(event);
|
|
68
|
+
expect(received).toEqual(event);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('onPrinciplePromoted receives a PrinciplePromotedEvent', () => {
|
|
72
|
+
let received: PrinciplePromotedEvent | undefined;
|
|
73
|
+
const hook: EvolutionHook = {
|
|
74
|
+
...noOpEvolutionHook,
|
|
75
|
+
onPrinciplePromoted(event: PrinciplePromotedEvent): void { received = event; },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const event = { id: 'p-1', from: 'candidate', to: 'active' };
|
|
79
|
+
hook.onPrinciplePromoted(event);
|
|
80
|
+
expect(received).toEqual(event);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('noOpEvolutionHook', () => {
|
|
85
|
+
it('implements all 3 methods as no-ops', () => {
|
|
86
|
+
expect(() => {
|
|
87
|
+
noOpEvolutionHook.onPainDetected(validPainSignal());
|
|
88
|
+
noOpEvolutionHook.onPrincipleCreated({ id: 'p-1', text: 'Test', trigger: 'test' });
|
|
89
|
+
noOpEvolutionHook.onPrinciplePromoted({ id: 'p-1', from: 'candidate', to: 'active' });
|
|
90
|
+
}).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('can be spread to override individual methods', () => {
|
|
94
|
+
const calls: string[] = [];
|
|
95
|
+
const hook: EvolutionHook = {
|
|
96
|
+
...noOpEvolutionHook,
|
|
97
|
+
onPainDetected(_signal: PainSignal): void { calls.push('pain'); },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
hook.onPainDetected(validPainSignal());
|
|
101
|
+
hook.onPrincipleCreated({ id: 'p-1', text: 'Test', trigger: 'test' });
|
|
102
|
+
|
|
103
|
+
expect(calls).toEqual(['pain']);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('PrincipleCreatedEvent', () => {
|
|
108
|
+
it('has required fields: id, text, trigger', () => {
|
|
109
|
+
const event: PrincipleCreatedEvent = { id: 'p-1', text: 'Always verify', trigger: 'tool failure' };
|
|
110
|
+
expect(event.id).toBe('p-1');
|
|
111
|
+
expect(event.text).toBe('Always verify');
|
|
112
|
+
expect(event.trigger).toBe('tool failure');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('PrinciplePromotedEvent', () => {
|
|
117
|
+
it('has required fields: id, from, to', () => {
|
|
118
|
+
const event: PrinciplePromotedEvent = { id: 'p-1', from: 'candidate', to: 'active' };
|
|
119
|
+
expect(event.id).toBe('p-1');
|
|
120
|
+
expect(event.from).toBe('candidate');
|
|
121
|
+
expect(event.to).toBe('active');
|
|
122
|
+
});
|
|
123
|
+
});
|