principles-disciple 1.52.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/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/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 +18 -3
- 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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrincipleInjector interface for the Evolution SDK.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the existing principle injection logic into a framework-agnostic
|
|
5
|
+
* contract. Per D-05, this delegates to selectPrinciplesForInjection and
|
|
6
|
+
* formatPrinciple without any behavioral changes.
|
|
7
|
+
*
|
|
8
|
+
* Per D-06, InjectionContext contains only generic fields (domain, sessionId,
|
|
9
|
+
* budgetChars) -- no framework-specific fields.
|
|
10
|
+
*/
|
|
11
|
+
import type { InjectablePrinciple } from './principle-injection.js';
|
|
12
|
+
import { selectPrinciplesForInjection, formatPrinciple } from './principle-injection.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// InjectionContext
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Generic injection context -- no framework-specific fields. */
|
|
19
|
+
export interface InjectionContext {
|
|
20
|
+
/** Domain context (e.g., 'coding', 'writing', 'analysis') */
|
|
21
|
+
domain: string;
|
|
22
|
+
/** Session identifier */
|
|
23
|
+
sessionId: string;
|
|
24
|
+
/** Maximum characters allowed for injected principles */
|
|
25
|
+
budgetChars: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// PrincipleInjector Interface
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Framework-agnostic principle injection interface.
|
|
34
|
+
*
|
|
35
|
+
* Wraps the existing budget-aware selection and formatting logic.
|
|
36
|
+
* Framework adapters convert their context to InjectionContext before calling.
|
|
37
|
+
*/
|
|
38
|
+
export interface PrincipleInjector {
|
|
39
|
+
/**
|
|
40
|
+
* Select principles relevant for injection within a character budget.
|
|
41
|
+
* Delegates to selectPrinciplesForInjection from principle-injection.ts.
|
|
42
|
+
*
|
|
43
|
+
* @param principles - All available principles to select from
|
|
44
|
+
* @param context - Generic injection context with budget constraint
|
|
45
|
+
* @returns Selected principles in injection order
|
|
46
|
+
*/
|
|
47
|
+
getRelevantPrinciples(
|
|
48
|
+
principles: InjectablePrinciple[],
|
|
49
|
+
context: InjectionContext,
|
|
50
|
+
): InjectablePrinciple[];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format a single principle for prompt injection.
|
|
54
|
+
* Delegates to formatPrinciple from principle-injection.ts.
|
|
55
|
+
*
|
|
56
|
+
* Format: "- [ID] text"
|
|
57
|
+
*
|
|
58
|
+
* @param principle - The principle to format
|
|
59
|
+
* @returns Formatted string for prompt injection
|
|
60
|
+
*/
|
|
61
|
+
formatForInjection(principle: InjectablePrinciple): string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Default Implementation
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default implementation that delegates to existing functions.
|
|
70
|
+
* Zero rewrite risk -- behavior is identical to calling the functions directly.
|
|
71
|
+
*/
|
|
72
|
+
export class DefaultPrincipleInjector implements PrincipleInjector {
|
|
73
|
+
getRelevantPrinciples(
|
|
74
|
+
principles: InjectablePrinciple[],
|
|
75
|
+
context: InjectionContext,
|
|
76
|
+
): InjectablePrinciple[] {
|
|
77
|
+
const result = selectPrinciplesForInjection(principles, context.budgetChars);
|
|
78
|
+
return result.selected;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
formatForInjection(principle: InjectablePrinciple): string {
|
|
82
|
+
return formatPrinciple(principle);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StorageAdapter interface for the Evolution SDK.
|
|
3
|
+
*
|
|
4
|
+
* This interface decouples the evolution engine from specific persistence
|
|
5
|
+
* implementations (file system, SQLite, remote API). All higher-level
|
|
6
|
+
* modules that need to read or mutate the principle ledger should depend
|
|
7
|
+
* on this interface rather than importing principle-tree-ledger directly.
|
|
8
|
+
*
|
|
9
|
+
* The interface uses HybridLedgerStore from principle-tree-ledger as the
|
|
10
|
+
* canonical store shape, but future adapters can map alternative backends
|
|
11
|
+
* to this shape.
|
|
12
|
+
*/
|
|
13
|
+
import type { HybridLedgerStore } from './principle-tree-ledger.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// StorageAdapter Interface
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Abstract storage adapter for the principle ledger.
|
|
21
|
+
*
|
|
22
|
+
* Implementations must guarantee:
|
|
23
|
+
* - Atomic writes (no partial/corrupted state on crash)
|
|
24
|
+
* - Thread-safe concurrent access (locking or equivalent)
|
|
25
|
+
* - Consistent read-after-write visibility
|
|
26
|
+
*
|
|
27
|
+
* The `mutateLedger` method is the preferred way to perform read-modify-write
|
|
28
|
+
* cycles. It encapsulates locking so callers never need to manage it.
|
|
29
|
+
*/
|
|
30
|
+
export interface StorageAdapter {
|
|
31
|
+
/**
|
|
32
|
+
* Load the current ledger state from persistence.
|
|
33
|
+
*
|
|
34
|
+
* If no persisted state exists (first run), returns an empty store.
|
|
35
|
+
*/
|
|
36
|
+
loadLedger(): Promise<HybridLedgerStore>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Persist the full ledger state.
|
|
40
|
+
*
|
|
41
|
+
* Must be atomic — partial writes must never be visible to readers.
|
|
42
|
+
*/
|
|
43
|
+
saveLedger(store: HybridLedgerStore): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Perform a read-modify-write cycle with automatic locking.
|
|
47
|
+
*
|
|
48
|
+
* The `mutate` function receives the current store and may return a
|
|
49
|
+
* value synchronously or via Promise. The store is persisted after
|
|
50
|
+
* the mutate function resolves, regardless of its return value.
|
|
51
|
+
*
|
|
52
|
+
* If two concurrent `mutateLedger` calls overlap, one must wait for
|
|
53
|
+
* the other to complete (pessimistic locking) or retry on conflict
|
|
54
|
+
* (optimistic locking). The choice is left to the implementation.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const count = await adapter.mutateLedger((store) => {
|
|
59
|
+
* store.tree.principles['p-1'] = newPrinciple;
|
|
60
|
+
* return Object.keys(store.tree.principles).length;
|
|
61
|
+
* });
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
mutateLedger<T>(mutate: (store: HybridLedgerStore) => T | Promise<T>): Promise<T>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TelemetryEvent schema for the Evolution SDK.
|
|
3
|
+
*
|
|
4
|
+
* TypeBox schema describing the shape of in-process evolution events.
|
|
5
|
+
* Per D-07, this is a documentation artifact -- the existing EvolutionLogger
|
|
6
|
+
* output should conform to this schema. No new TelemetryService is created.
|
|
7
|
+
*
|
|
8
|
+
* Per D-08, covers the 3 core events aligned with EvolutionHook:
|
|
9
|
+
* - pain_detected (maps to EvolutionStage 'pain_detected')
|
|
10
|
+
* - principle_candidate_created (maps to EvolutionStage 'principle_generated')
|
|
11
|
+
* - principle_promoted (maps to EvolutionStage 'completed')
|
|
12
|
+
*
|
|
13
|
+
* Injection and storage events are out of scope for this phase.
|
|
14
|
+
*/
|
|
15
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
16
|
+
import { Value } from '@sinclair/typebox/value';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Event Type Union
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The 3 core telemetry event types, aligned with EvolutionHook methods.
|
|
24
|
+
*
|
|
25
|
+
* Mapping to existing EvolutionLogger stages:
|
|
26
|
+
* - pain_detected -> EvolutionStage 'pain_detected'
|
|
27
|
+
* - principle_candidate_created -> EvolutionStage 'principle_generated'
|
|
28
|
+
* - principle_promoted -> EvolutionStage 'completed'
|
|
29
|
+
*/
|
|
30
|
+
export const TelemetryEventType = Type.Union([
|
|
31
|
+
Type.Literal('pain_detected'),
|
|
32
|
+
Type.Literal('principle_candidate_created'),
|
|
33
|
+
Type.Literal('principle_promoted'),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export type TelemetryEventType = Static<typeof TelemetryEventType>;
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// TelemetryEvent Schema
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Schema for an in-process telemetry event.
|
|
44
|
+
*
|
|
45
|
+
* Fields align with existing EvolutionLogEntry:
|
|
46
|
+
* - traceId <-> EvolutionLogEntry.traceId
|
|
47
|
+
* - timestamp <-> EvolutionLogEntry.timestamp
|
|
48
|
+
* - sessionId <-> EvolutionLogEntry.sessionId
|
|
49
|
+
* - payload <-> EvolutionLogEntry.metadata
|
|
50
|
+
*
|
|
51
|
+
* No PII fields. The agentId field is optional and contains only
|
|
52
|
+
* system identifiers (e.g., 'main', 'builder'), never user data.
|
|
53
|
+
*/
|
|
54
|
+
export const TelemetryEventSchema = Type.Object({
|
|
55
|
+
/** Event type (one of the 3 core types) */
|
|
56
|
+
eventType: TelemetryEventType,
|
|
57
|
+
/** Correlation trace ID for linking events across the pipeline */
|
|
58
|
+
traceId: Type.String({ minLength: 1 }),
|
|
59
|
+
/** ISO 8601 timestamp */
|
|
60
|
+
timestamp: Type.String({ minLength: 1 }),
|
|
61
|
+
/** Session identifier */
|
|
62
|
+
sessionId: Type.String(),
|
|
63
|
+
/** Agent identifier (system identifier only, no PII) */
|
|
64
|
+
agentId: Type.Optional(Type.String()),
|
|
65
|
+
/** Event-specific payload */
|
|
66
|
+
payload: Type.Record(Type.String(), Type.Unknown()),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export type TelemetryEvent = Static<typeof TelemetryEventSchema>;
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Validation
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export interface TelemetryEventValidationResult {
|
|
76
|
+
valid: boolean;
|
|
77
|
+
errors: string[];
|
|
78
|
+
event?: TelemetryEvent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validates an arbitrary object against the TelemetryEvent schema.
|
|
83
|
+
*
|
|
84
|
+
* Returns a structured result with:
|
|
85
|
+
* - `valid`: whether the input conforms to the schema
|
|
86
|
+
* - `errors`: human-readable list of validation failures
|
|
87
|
+
* - `event`: the typed event (only present when valid)
|
|
88
|
+
*/
|
|
89
|
+
export function validateTelemetryEvent(input: unknown): TelemetryEventValidationResult {
|
|
90
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
91
|
+
return { valid: false, errors: ['Input must be a non-null object'] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const errors = [...Value.Errors(TelemetryEventSchema, input)];
|
|
95
|
+
if (errors.length > 0) {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
errors: errors.map(
|
|
99
|
+
(e) => `${e.path ? `${e.path}: ` : ''}${e.message}`,
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
valid: true,
|
|
106
|
+
errors: [],
|
|
107
|
+
event: Value.Cast(TelemetryEventSchema, input) as TelemetryEvent,
|
|
108
|
+
};
|
|
109
|
+
}
|
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 {
|
|
@@ -901,12 +902,26 @@ ${taskBlocks}${processingNote}
|
|
|
901
902
|
}
|
|
902
903
|
|
|
903
904
|
|
|
904
|
-
// Evolution principles injection
|
|
905
|
+
// Evolution principles injection — budget-aware selection (SDK-QUAL-04)
|
|
905
906
|
let evolutionPrinciplesContent = '';
|
|
906
907
|
try {
|
|
907
908
|
const reducer = wctx.evolutionReducer;
|
|
908
|
-
const
|
|
909
|
-
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
|
+
|
|
910
925
|
if (ctx.sessionId) {
|
|
911
926
|
if (probation.length > 0) {
|
|
912
927
|
setInjectedProbationIds(ctx.sessionId, probation.map((p) => p.id), workspaceDir);
|
|
@@ -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
|
+
});
|