principles-disciple 1.40.0 → 1.42.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/.planning/codebase/ARCHITECTURE.md +157 -0
- package/.planning/codebase/CONCERNS.md +145 -0
- package/.planning/codebase/CONVENTIONS.md +148 -0
- package/.planning/codebase/INTEGRATIONS.md +81 -0
- package/.planning/codebase/STACK.md +87 -0
- package/.planning/codebase/STRUCTURE.md +193 -0
- package/.planning/codebase/TESTING.md +243 -0
- package/esbuild.config.js +32 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/compile-principles.mjs +94 -0
- package/scripts/sync-plugin.mjs +96 -281
- package/src/commands/pain.ts +12 -5
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -3
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/core/principle-compiler/code-validator.ts +120 -0
- package/src/core/principle-compiler/compiler.ts +242 -0
- package/src/core/principle-compiler/index.ts +10 -0
- package/src/core/principle-compiler/ledger-registrar.ts +107 -0
- package/src/core/principle-compiler/template-generator.ts +108 -0
- package/src/core/reflection/reflection-context.ts +228 -0
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/prompt.ts +15 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/evolution-worker.ts +89 -365
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/deep-reflect.ts +22 -11
- package/src/types/event-payload.ts +80 -0
- package/src/types/queue.ts +70 -0
- package/src/utils/file-lock.ts +2 -2
- package/src/utils/io.ts +11 -3
- package/tests/core/code-validator.test.ts +197 -0
- package/tests/core/evolution-migration.test.ts +325 -1
- package/tests/core/ledger-registrar.test.ts +232 -0
- package/tests/core/principle-compiler.test.ts +348 -0
- package/tests/core/queue-purge.test.ts +337 -0
- package/tests/core/reflection-context.test.ts +356 -0
- package/tests/core/template-generator.test.ts +101 -0
- package/tests/fixtures/legacy-queue-v1.json +74 -0
- package/tests/integration/principle-compiler-e2e.test.ts +335 -0
- package/tests/queue/async-lock.test.ts +200 -0
- package/tests/service/evolution-worker.queue.test.ts +296 -0
- package/tests/service/queue-io.test.ts +229 -0
- package/tests/service/queue-migration.test.ts +147 -0
- package/tests/service/workflow-watchdog.test.ts +372 -0
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { migrateLegacyEvolutionData } from '../../src/core/evolution-migration.js';
|
|
6
|
+
import {
|
|
7
|
+
migrateToV2,
|
|
8
|
+
isLegacyQueueItem,
|
|
9
|
+
migrateQueueToV2,
|
|
10
|
+
loadEvolutionQueue,
|
|
11
|
+
} from '../../src/service/evolution-worker.js';
|
|
12
|
+
import type { LegacyEvolutionQueueItem, EvolutionQueueItem } from '../../src/service/evolution-worker.js';
|
|
6
13
|
|
|
7
14
|
const tempDirs: string[] = [];
|
|
8
15
|
|
|
@@ -48,3 +55,320 @@ describe('migrateLegacyEvolutionData', () => {
|
|
|
48
55
|
expect(second.importedEvents).toBe(0);
|
|
49
56
|
});
|
|
50
57
|
});
|
|
58
|
+
|
|
59
|
+
// ===== migrateToV2 integration tests =====
|
|
60
|
+
|
|
61
|
+
describe('migrateToV2', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.useRealTimers();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('legacy item without taskKind gets DEFAULT_TASK_KIND (pain_diagnosis)', () => {
|
|
71
|
+
const legacyItem: LegacyEvolutionQueueItem = {
|
|
72
|
+
id: 'test-legacy-001',
|
|
73
|
+
score: 75,
|
|
74
|
+
source: 'tool_failure',
|
|
75
|
+
reason: 'Tool failed',
|
|
76
|
+
timestamp: '2026-04-10T00:00:00.000Z',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const migrated = migrateToV2(legacyItem);
|
|
80
|
+
|
|
81
|
+
expect(migrated.taskKind).toBe('pain_diagnosis');
|
|
82
|
+
expect(migrated.priority).toBe('medium');
|
|
83
|
+
expect(migrated.retryCount).toBe(0);
|
|
84
|
+
expect(migrated.maxRetries).toBe(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('legacy item without priority gets DEFAULT_PRIORITY (medium)', () => {
|
|
88
|
+
const legacyItem: LegacyEvolutionQueueItem = {
|
|
89
|
+
id: 'test-legacy-002',
|
|
90
|
+
score: 50,
|
|
91
|
+
source: 'gate_block',
|
|
92
|
+
reason: 'Gate blocked',
|
|
93
|
+
timestamp: '2026-04-11T00:00:00.000Z',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const migrated = migrateToV2(legacyItem);
|
|
97
|
+
|
|
98
|
+
expect(migrated.priority).toBe('medium');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('migrateToV2 preserves all original fields', () => {
|
|
102
|
+
const legacyItem: LegacyEvolutionQueueItem = {
|
|
103
|
+
id: 'test-legacy-003',
|
|
104
|
+
task: 'Diagnose pain',
|
|
105
|
+
score: 88,
|
|
106
|
+
source: 'runtime_unavailable',
|
|
107
|
+
reason: 'Session timeout',
|
|
108
|
+
timestamp: '2026-04-12T00:00:00.000Z',
|
|
109
|
+
enqueued_at: '2026-04-12T00:00:05.000Z',
|
|
110
|
+
started_at: '2026-04-12T00:01:00.000Z',
|
|
111
|
+
completed_at: '2026-04-12T00:05:00.000Z',
|
|
112
|
+
assigned_session_key: 'session-key-xyz',
|
|
113
|
+
trigger_text_preview: 'Session timed out',
|
|
114
|
+
status: 'completed',
|
|
115
|
+
resolution: 'auto_completed_timeout',
|
|
116
|
+
session_id: 'session-xyz',
|
|
117
|
+
agent_id: 'main',
|
|
118
|
+
traceId: 'trace-xyz',
|
|
119
|
+
resultRef: 'result-xyz',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const migrated = migrateToV2(legacyItem);
|
|
123
|
+
|
|
124
|
+
expect(migrated.id).toBe('test-legacy-003');
|
|
125
|
+
expect(migrated.task).toBe('Diagnose pain');
|
|
126
|
+
expect(migrated.score).toBe(88);
|
|
127
|
+
expect(migrated.source).toBe('runtime_unavailable');
|
|
128
|
+
expect(migrated.reason).toBe('Session timeout');
|
|
129
|
+
expect(migrated.timestamp).toBe('2026-04-12T00:00:00.000Z');
|
|
130
|
+
expect(migrated.enqueued_at).toBe('2026-04-12T00:00:05.000Z');
|
|
131
|
+
expect(migrated.started_at).toBe('2026-04-12T00:01:00.000Z');
|
|
132
|
+
expect(migrated.completed_at).toBe('2026-04-12T00:05:00.000Z');
|
|
133
|
+
expect(migrated.assigned_session_key).toBe('session-key-xyz');
|
|
134
|
+
expect(migrated.trigger_text_preview).toBe('Session timed out');
|
|
135
|
+
expect(migrated.status).toBe('completed');
|
|
136
|
+
expect(migrated.resolution).toBe('auto_completed_timeout');
|
|
137
|
+
expect(migrated.session_id).toBe('session-xyz');
|
|
138
|
+
expect(migrated.agent_id).toBe('main');
|
|
139
|
+
expect(migrated.traceId).toBe('trace-xyz');
|
|
140
|
+
expect(migrated.resultRef).toBe('result-xyz');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('migrateQueueToV2 migrates legacy items and passes through V2 items unchanged', () => {
|
|
144
|
+
const mixedQueue = [
|
|
145
|
+
// Legacy item (no taskKind) - should be migrated
|
|
146
|
+
{
|
|
147
|
+
id: 'legacy-001',
|
|
148
|
+
score: 70,
|
|
149
|
+
source: 'tool_failure',
|
|
150
|
+
reason: 'Legacy reason',
|
|
151
|
+
timestamp: '2026-04-10T00:00:00.000Z',
|
|
152
|
+
} as LegacyEvolutionQueueItem,
|
|
153
|
+
// V2 item (has taskKind) - should be passed through
|
|
154
|
+
{
|
|
155
|
+
id: 'v2-001',
|
|
156
|
+
taskKind: 'sleep_reflection' as const,
|
|
157
|
+
priority: 'high' as const,
|
|
158
|
+
score: 60,
|
|
159
|
+
source: 'nocturnal',
|
|
160
|
+
reason: 'V2 reason',
|
|
161
|
+
timestamp: '2026-04-11T00:00:00.000Z',
|
|
162
|
+
status: 'pending' as const,
|
|
163
|
+
retryCount: 0,
|
|
164
|
+
maxRetries: 3,
|
|
165
|
+
} as EvolutionQueueItem,
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const result = migrateQueueToV2(mixedQueue as any);
|
|
169
|
+
|
|
170
|
+
expect(result).toHaveLength(2);
|
|
171
|
+
// Legacy item should be migrated
|
|
172
|
+
expect(result[0].id).toBe('legacy-001');
|
|
173
|
+
expect((result[0] as EvolutionQueueItem).taskKind).toBe('pain_diagnosis');
|
|
174
|
+
expect((result[0] as EvolutionQueueItem).priority).toBe('medium');
|
|
175
|
+
// V2 item should be passed through unchanged
|
|
176
|
+
expect(result[1].id).toBe('v2-001');
|
|
177
|
+
expect((result[1] as EvolutionQueueItem).taskKind).toBe('sleep_reflection');
|
|
178
|
+
expect((result[1] as EvolutionQueueItem).priority).toBe('high');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('isLegacyQueueItem returns true only when taskKind is absent', () => {
|
|
182
|
+
const legacyItem = { id: 'test', score: 50, source: 'x', reason: 'y', timestamp: '2026-01-01T00:00:00Z' };
|
|
183
|
+
const v2Item = { id: 'test', taskKind: 'sleep_reflection', score: 50, source: 'x', reason: 'y', timestamp: '2026-01-01T00:00:00Z', priority: 'medium', status: 'pending', retryCount: 0, maxRetries: 3 };
|
|
184
|
+
|
|
185
|
+
expect(isLegacyQueueItem(legacyItem)).toBe(true);
|
|
186
|
+
expect(isLegacyQueueItem(v2Item)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ===== Queue state transition tests =====
|
|
191
|
+
|
|
192
|
+
describe('Queue migration state transitions', () => {
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
vi.useFakeTimers();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
vi.useRealTimers();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('pending legacy item migrates to V2 with pending status', () => {
|
|
202
|
+
const tempDir = makeTempDir();
|
|
203
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
204
|
+
const legacyPending = {
|
|
205
|
+
id: 'pending-001',
|
|
206
|
+
score: 72,
|
|
207
|
+
source: 'tool_failure',
|
|
208
|
+
reason: 'Write tool failed',
|
|
209
|
+
timestamp: '2026-04-10T08:00:00.000Z',
|
|
210
|
+
enqueued_at: '2026-04-10T08:00:05.000Z',
|
|
211
|
+
started_at: null,
|
|
212
|
+
completed_at: null,
|
|
213
|
+
assigned_session_key: null,
|
|
214
|
+
trigger_text_preview: 'Write failed',
|
|
215
|
+
status: 'pending',
|
|
216
|
+
resolution: null,
|
|
217
|
+
session_id: null,
|
|
218
|
+
agent_id: null,
|
|
219
|
+
traceId: 'trace-pending',
|
|
220
|
+
};
|
|
221
|
+
fs.writeFileSync(queuePath, JSON.stringify([legacyPending]), 'utf8');
|
|
222
|
+
|
|
223
|
+
const result = loadEvolutionQueue(queuePath);
|
|
224
|
+
|
|
225
|
+
expect(result).toHaveLength(1);
|
|
226
|
+
expect(result[0].id).toBe('pending-001');
|
|
227
|
+
expect(result[0].status).toBe('pending');
|
|
228
|
+
expect(result[0].taskKind).toBe('pain_diagnosis');
|
|
229
|
+
expect(result[0].priority).toBe('medium');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('in_progress legacy item migrates to V2 with in_progress status', () => {
|
|
233
|
+
const tempDir = makeTempDir();
|
|
234
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
235
|
+
const legacyInProgress = {
|
|
236
|
+
id: 'inprogress-001',
|
|
237
|
+
score: 65,
|
|
238
|
+
source: 'runtime_unavailable',
|
|
239
|
+
reason: 'Session timeout',
|
|
240
|
+
timestamp: '2026-04-11T14:00:00.000Z',
|
|
241
|
+
enqueued_at: '2026-04-11T14:00:10.000Z',
|
|
242
|
+
started_at: '2026-04-11T14:05:00.000Z',
|
|
243
|
+
completed_at: null,
|
|
244
|
+
assigned_session_key: 'session-key-abc',
|
|
245
|
+
trigger_text_preview: 'Session timeout',
|
|
246
|
+
status: 'in_progress',
|
|
247
|
+
resolution: null,
|
|
248
|
+
session_id: 'session-abc',
|
|
249
|
+
agent_id: 'main',
|
|
250
|
+
traceId: 'trace-inprogress',
|
|
251
|
+
};
|
|
252
|
+
fs.writeFileSync(queuePath, JSON.stringify([legacyInProgress]), 'utf8');
|
|
253
|
+
|
|
254
|
+
const result = loadEvolutionQueue(queuePath);
|
|
255
|
+
|
|
256
|
+
expect(result).toHaveLength(1);
|
|
257
|
+
expect(result[0].id).toBe('inprogress-001');
|
|
258
|
+
expect(result[0].status).toBe('in_progress');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('completed legacy item migrates to V2 with completed status', () => {
|
|
262
|
+
const tempDir = makeTempDir();
|
|
263
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
264
|
+
const legacyCompleted = {
|
|
265
|
+
id: 'completed-001',
|
|
266
|
+
score: 90,
|
|
267
|
+
source: 'gate_block',
|
|
268
|
+
reason: 'Principle score below threshold',
|
|
269
|
+
timestamp: '2026-04-12T09:00:00.000Z',
|
|
270
|
+
enqueued_at: '2026-04-12T09:00:30.000Z',
|
|
271
|
+
started_at: '2026-04-12T09:01:00.000Z',
|
|
272
|
+
completed_at: '2026-04-12T09:05:00.000Z',
|
|
273
|
+
assigned_session_key: 'session-key-def',
|
|
274
|
+
trigger_text_preview: 'Score below threshold',
|
|
275
|
+
status: 'completed',
|
|
276
|
+
resolution: 'late_marker_no_principle',
|
|
277
|
+
session_id: 'session-def',
|
|
278
|
+
agent_id: 'main',
|
|
279
|
+
traceId: 'trace-completed',
|
|
280
|
+
};
|
|
281
|
+
fs.writeFileSync(queuePath, JSON.stringify([legacyCompleted]), 'utf8');
|
|
282
|
+
|
|
283
|
+
const result = loadEvolutionQueue(queuePath);
|
|
284
|
+
|
|
285
|
+
expect(result).toHaveLength(1);
|
|
286
|
+
expect(result[0].id).toBe('completed-001');
|
|
287
|
+
expect(result[0].status).toBe('completed');
|
|
288
|
+
expect(result[0].resolution).toBe('late_marker_no_principle');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('failed legacy item migrates to V2 with failed status', () => {
|
|
292
|
+
const tempDir = makeTempDir();
|
|
293
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
294
|
+
const legacyFailed = {
|
|
295
|
+
id: 'failed-001',
|
|
296
|
+
score: 55,
|
|
297
|
+
source: 'thin_violation',
|
|
298
|
+
reason: 'Rule scoring bias',
|
|
299
|
+
timestamp: '2026-04-13T16:00:00.000Z',
|
|
300
|
+
enqueued_at: '2026-04-13T16:00:15.000Z',
|
|
301
|
+
started_at: '2026-04-13T16:01:00.000Z',
|
|
302
|
+
completed_at: '2026-04-13T16:05:00.000Z',
|
|
303
|
+
assigned_session_key: 'session-key-ghi',
|
|
304
|
+
trigger_text_preview: 'Rule scoring bias',
|
|
305
|
+
status: 'failed',
|
|
306
|
+
resolution: 'failed_max_retries',
|
|
307
|
+
session_id: 'session-ghi',
|
|
308
|
+
agent_id: 'main',
|
|
309
|
+
traceId: 'trace-failed',
|
|
310
|
+
};
|
|
311
|
+
fs.writeFileSync(queuePath, JSON.stringify([legacyFailed]), 'utf8');
|
|
312
|
+
|
|
313
|
+
const result = loadEvolutionQueue(queuePath);
|
|
314
|
+
|
|
315
|
+
expect(result).toHaveLength(1);
|
|
316
|
+
expect(result[0].id).toBe('failed-001');
|
|
317
|
+
expect(result[0].status).toBe('failed');
|
|
318
|
+
expect(result[0].resolution).toBe('failed_max_retries');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('legacy item with missing optional fields retains undefined values', () => {
|
|
322
|
+
const tempDir = makeTempDir();
|
|
323
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
324
|
+
// Minimal legacy item - only required fields
|
|
325
|
+
const minimalLegacy = {
|
|
326
|
+
id: 'minimal-001',
|
|
327
|
+
score: 40,
|
|
328
|
+
source: 'minimal_source',
|
|
329
|
+
reason: 'Minimal test item',
|
|
330
|
+
timestamp: '2026-04-14T00:00:00.000Z',
|
|
331
|
+
};
|
|
332
|
+
fs.writeFileSync(queuePath, JSON.stringify([minimalLegacy]), 'utf8');
|
|
333
|
+
|
|
334
|
+
const result = loadEvolutionQueue(queuePath);
|
|
335
|
+
|
|
336
|
+
expect(result).toHaveLength(1);
|
|
337
|
+
expect(result[0].id).toBe('minimal-001');
|
|
338
|
+
expect(result[0].enqueued_at).toBeUndefined();
|
|
339
|
+
expect(result[0].started_at).toBeUndefined();
|
|
340
|
+
expect(result[0].completed_at).toBeUndefined();
|
|
341
|
+
expect(result[0].assigned_session_key).toBeUndefined();
|
|
342
|
+
expect(result[0].trigger_text_preview).toBeUndefined();
|
|
343
|
+
expect(result[0].session_id).toBeUndefined();
|
|
344
|
+
expect(result[0].agent_id).toBeUndefined();
|
|
345
|
+
expect(result[0].traceId).toBeUndefined();
|
|
346
|
+
expect(result[0].resultRef).toBeUndefined();
|
|
347
|
+
// But defaults are applied for V2-required fields
|
|
348
|
+
expect(result[0].taskKind).toBe('pain_diagnosis');
|
|
349
|
+
expect(result[0].priority).toBe('medium');
|
|
350
|
+
expect(result[0].retryCount).toBe(0);
|
|
351
|
+
expect(result[0].maxRetries).toBe(3);
|
|
352
|
+
expect(result[0].status).toBe('pending'); // default status
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('empty queue file returns empty array', () => {
|
|
356
|
+
const tempDir = makeTempDir();
|
|
357
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
358
|
+
fs.writeFileSync(queuePath, JSON.stringify([]), 'utf8');
|
|
359
|
+
|
|
360
|
+
const result = loadEvolutionQueue(queuePath);
|
|
361
|
+
|
|
362
|
+
expect(result).toHaveLength(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('corrupted JSON file returns empty array', () => {
|
|
366
|
+
const tempDir = makeTempDir();
|
|
367
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
368
|
+
fs.writeFileSync(queuePath, 'not valid json{ ', 'utf8');
|
|
369
|
+
|
|
370
|
+
const result = loadEvolutionQueue(queuePath);
|
|
371
|
+
|
|
372
|
+
expect(result).toHaveLength(0);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger Registrar Tests (Task 4)
|
|
3
|
+
*
|
|
4
|
+
* TDD test suite for registerCompiledRule — creates a gate rule + code
|
|
5
|
+
* implementation in the principle tree ledger for a compiled principle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
11
|
+
import { safeRmDir } from '../test-utils.js';
|
|
12
|
+
import {
|
|
13
|
+
loadLedger,
|
|
14
|
+
saveLedger,
|
|
15
|
+
type HybridLedgerStore,
|
|
16
|
+
type LedgerPrinciple,
|
|
17
|
+
type LedgerRule,
|
|
18
|
+
} from '../../src/core/principle-tree-ledger.js';
|
|
19
|
+
import { registerCompiledRule, type RegisterInput, type RegisterResult } from '../../src/core/principle-compiler/ledger-registrar.js';
|
|
20
|
+
|
|
21
|
+
describe('ledger-registrar', () => {
|
|
22
|
+
let tempDir: string;
|
|
23
|
+
let stateDir: string;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
tempDir = fs.mkdtempSync(path.join(process.env.TMP || '/tmp', 'pd-registrar-'));
|
|
27
|
+
stateDir = path.join(tempDir, '.state');
|
|
28
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
safeRmDir(tempDir);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
const stateFile = path.join(stateDir, 'principle_training_state.json');
|
|
37
|
+
if (fs.existsSync(stateFile)) {
|
|
38
|
+
fs.unlinkSync(stateFile);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Helper: Create a minimal ledger principle
|
|
43
|
+
function createLedgerPrinciple(principleId: string, overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
|
|
44
|
+
return {
|
|
45
|
+
id: principleId,
|
|
46
|
+
version: 1,
|
|
47
|
+
text: `Test principle ${principleId}`,
|
|
48
|
+
triggerPattern: 'test',
|
|
49
|
+
action: 'test action',
|
|
50
|
+
status: 'active',
|
|
51
|
+
priority: 'P1',
|
|
52
|
+
scope: 'general',
|
|
53
|
+
evaluability: 'deterministic',
|
|
54
|
+
valueScore: 0,
|
|
55
|
+
adherenceRate: 0,
|
|
56
|
+
painPreventedCount: 0,
|
|
57
|
+
derivedFromPainIds: [],
|
|
58
|
+
ruleIds: [],
|
|
59
|
+
conflictsWithPrincipleIds: [],
|
|
60
|
+
createdAt: '2026-04-10T00:00:00.000Z',
|
|
61
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper: Setup ledger with a single principle
|
|
67
|
+
function setupLedgerWithPrinciple(principleId: string): void {
|
|
68
|
+
const tree = {
|
|
69
|
+
principles: {} as Record<string, LedgerPrinciple>,
|
|
70
|
+
rules: {} as Record<string, LedgerRule>,
|
|
71
|
+
implementations: {},
|
|
72
|
+
metrics: {},
|
|
73
|
+
lastUpdated: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
tree.principles[principleId] = createLedgerPrinciple(principleId);
|
|
77
|
+
|
|
78
|
+
const store: HybridLedgerStore = {
|
|
79
|
+
trainingStore: {},
|
|
80
|
+
tree,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
saveLedger(stateDir, store);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('registerCompiledRule', () => {
|
|
87
|
+
it('creates a gate rule and code implementation in the ledger', () => {
|
|
88
|
+
setupLedgerWithPrinciple('P_001');
|
|
89
|
+
|
|
90
|
+
const input: RegisterInput = {
|
|
91
|
+
principleId: 'P_001',
|
|
92
|
+
codeContent: 'export function check() { return true; }',
|
|
93
|
+
coversCondition: 'file_write',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = registerCompiledRule(stateDir, input);
|
|
97
|
+
|
|
98
|
+
// Verify result structure
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
expect(result.ruleId).toBe('R_P_001_auto');
|
|
101
|
+
expect(result.implementationId).toBe('IMPL_P_001_auto');
|
|
102
|
+
|
|
103
|
+
// Verify rule in ledger
|
|
104
|
+
const ledger = loadLedger(stateDir);
|
|
105
|
+
const rule = ledger.tree.rules['R_P_001_auto'];
|
|
106
|
+
expect(rule).toBeDefined();
|
|
107
|
+
expect(rule.id).toBe('R_P_001_auto');
|
|
108
|
+
expect(rule.type).toBe('gate');
|
|
109
|
+
expect(rule.enforcement).toBe('block');
|
|
110
|
+
expect(rule.status).toBe('proposed');
|
|
111
|
+
expect(rule.principleId).toBe('P_001');
|
|
112
|
+
expect(rule.implementationIds).toContain('IMPL_P_001_auto');
|
|
113
|
+
|
|
114
|
+
// Verify implementation in ledger
|
|
115
|
+
const impl = ledger.tree.implementations['IMPL_P_001_auto'];
|
|
116
|
+
expect(impl).toBeDefined();
|
|
117
|
+
expect(impl.id).toBe('IMPL_P_001_auto');
|
|
118
|
+
expect(impl.ruleId).toBe('R_P_001_auto');
|
|
119
|
+
expect(impl.type).toBe('code');
|
|
120
|
+
expect(impl.coversCondition).toBe('file_write');
|
|
121
|
+
expect(impl.lifecycleState).toBe('active');
|
|
122
|
+
|
|
123
|
+
// Verify principle linked to rule
|
|
124
|
+
const principle = ledger.tree.principles['P_001'];
|
|
125
|
+
expect(principle.ruleIds).toContain('R_P_001_auto');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns the codePath in the result', () => {
|
|
129
|
+
setupLedgerWithPrinciple('P_042');
|
|
130
|
+
|
|
131
|
+
const input: RegisterInput = {
|
|
132
|
+
principleId: 'P_042',
|
|
133
|
+
codeContent: '// some code',
|
|
134
|
+
coversCondition: 'git_push',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = registerCompiledRule(stateDir, input);
|
|
138
|
+
|
|
139
|
+
expect(result.success).toBe(true);
|
|
140
|
+
expect(result.codePath).toContain('P_042');
|
|
141
|
+
expect(result.codePath).toMatch(/\.ts$/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('throws if the principle does not exist', () => {
|
|
145
|
+
// Empty ledger — no principles
|
|
146
|
+
const store: HybridLedgerStore = {
|
|
147
|
+
trainingStore: {},
|
|
148
|
+
tree: {
|
|
149
|
+
principles: {},
|
|
150
|
+
rules: {},
|
|
151
|
+
implementations: {},
|
|
152
|
+
metrics: {},
|
|
153
|
+
lastUpdated: new Date().toISOString(),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
saveLedger(stateDir, store);
|
|
157
|
+
|
|
158
|
+
const input: RegisterInput = {
|
|
159
|
+
principleId: 'P_NONEXISTENT',
|
|
160
|
+
codeContent: 'export function check() { return true; }',
|
|
161
|
+
coversCondition: 'test',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
expect(() => registerCompiledRule(stateDir, input)).toThrow(/missing principle.*P_NONEXISTENT/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('sets correct timestamps on rule and implementation', () => {
|
|
168
|
+
setupLedgerWithPrinciple('P_100');
|
|
169
|
+
|
|
170
|
+
const before = new Date().toISOString();
|
|
171
|
+
const input: RegisterInput = {
|
|
172
|
+
principleId: 'P_100',
|
|
173
|
+
codeContent: 'export const x = 1;',
|
|
174
|
+
coversCondition: 'test_condition',
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const result = registerCompiledRule(stateDir, input);
|
|
178
|
+
const after = new Date().toISOString();
|
|
179
|
+
|
|
180
|
+
expect(result.success).toBe(true);
|
|
181
|
+
|
|
182
|
+
const ledger = loadLedger(stateDir);
|
|
183
|
+
const rule = ledger.tree.rules['R_P_100_auto'];
|
|
184
|
+
const impl = ledger.tree.implementations['IMPL_P_100_auto'];
|
|
185
|
+
|
|
186
|
+
// Timestamps should be between before and after
|
|
187
|
+
expect(rule.createdAt >= before).toBe(true);
|
|
188
|
+
expect(rule.createdAt <= after).toBe(true);
|
|
189
|
+
expect(rule.updatedAt).toBe(rule.createdAt);
|
|
190
|
+
|
|
191
|
+
expect(impl.createdAt >= before).toBe(true);
|
|
192
|
+
expect(impl.createdAt <= after).toBe(true);
|
|
193
|
+
expect(impl.updatedAt).toBe(impl.createdAt);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('handles multiple registrations for different principles', () => {
|
|
197
|
+
// Setup ledger with two principles
|
|
198
|
+
const tree = {
|
|
199
|
+
principles: {} as Record<string, LedgerPrinciple>,
|
|
200
|
+
rules: {} as Record<string, LedgerRule>,
|
|
201
|
+
implementations: {},
|
|
202
|
+
metrics: {},
|
|
203
|
+
lastUpdated: new Date().toISOString(),
|
|
204
|
+
};
|
|
205
|
+
tree.principles['P_001'] = createLedgerPrinciple('P_001');
|
|
206
|
+
tree.principles['P_002'] = createLedgerPrinciple('P_002');
|
|
207
|
+
|
|
208
|
+
const store: HybridLedgerStore = { trainingStore: {}, tree };
|
|
209
|
+
saveLedger(stateDir, store);
|
|
210
|
+
|
|
211
|
+
const result1 = registerCompiledRule(stateDir, {
|
|
212
|
+
principleId: 'P_001',
|
|
213
|
+
codeContent: '// code 1',
|
|
214
|
+
coversCondition: 'cond_1',
|
|
215
|
+
});
|
|
216
|
+
const result2 = registerCompiledRule(stateDir, {
|
|
217
|
+
principleId: 'P_002',
|
|
218
|
+
codeContent: '// code 2',
|
|
219
|
+
coversCondition: 'cond_2',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result1.success).toBe(true);
|
|
223
|
+
expect(result2.success).toBe(true);
|
|
224
|
+
expect(result1.ruleId).toBe('R_P_001_auto');
|
|
225
|
+
expect(result2.ruleId).toBe('R_P_002_auto');
|
|
226
|
+
|
|
227
|
+
const ledger = loadLedger(stateDir);
|
|
228
|
+
expect(Object.keys(ledger.tree.rules)).toHaveLength(2);
|
|
229
|
+
expect(Object.keys(ledger.tree.implementations)).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|