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.
@@ -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
+ }
@@ -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 (active + probation summary)
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 active = reducer.getActivePrinciples().slice(-3);
909
- const probation = reducer.getProbationPrinciples().slice(0, 5);
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
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
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
+ });