principles-disciple 1.32.0 → 1.34.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.
Files changed (37) hide show
  1. package/openclaw.plugin.json +4 -4
  2. package/package.json +1 -1
  3. package/src/core/correction-cue-learner.ts +203 -0
  4. package/src/core/correction-types.ts +88 -0
  5. package/src/core/evolution-logger.ts +3 -3
  6. package/src/core/init.ts +67 -0
  7. package/src/service/correction-observer-types.ts +58 -0
  8. package/src/service/correction-observer-workflow-manager.ts +218 -0
  9. package/src/service/evolution-worker.ts +172 -146
  10. package/src/service/nocturnal-service.ts +4 -1
  11. package/src/service/subagent-workflow/index.ts +14 -0
  12. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +3 -1
  13. package/tests/service/evolution-worker.nocturnal.test.ts +14 -1
  14. package/tests/service/evolution-worker.timeout.test.ts +350 -0
  15. package/tests/commands/implementation-lifecycle.test.ts +0 -362
  16. package/tests/core/detection-funnel.test.ts +0 -63
  17. package/tests/core/evolution-e2e.test.ts +0 -58
  18. package/tests/core/evolution-engine-gate-integration.test.ts +0 -543
  19. package/tests/core/evolution-engine.test.ts +0 -562
  20. package/tests/core/evolution-reducer.test.ts +0 -180
  21. package/tests/core/evolution-user-stories.e2e.test.ts +0 -249
  22. package/tests/core/local-worker-routing.test.ts +0 -757
  23. package/tests/core/rule-host.test.ts +0 -389
  24. package/tests/core/trajectory-correction-pain.test.ts +0 -180
  25. package/tests/hooks/gate-edit-verification.test.ts +0 -435
  26. package/tests/hooks/llm.test.ts +0 -308
  27. package/tests/hooks/progressive-trust-gate.test.ts +0 -277
  28. package/tests/hooks/prompt.test.ts +0 -1473
  29. package/tests/index.integration.test.ts +0 -179
  30. package/tests/index.shadow-routing.integration.test.ts +0 -140
  31. package/tests/service/evolution-worker.test.ts +0 -462
  32. package/tests/service/nocturnal-service.test.ts +0 -577
  33. package/tests/service/nocturnal-workflow-manager.test.ts +0 -441
  34. package/tests/tools/critique-prompt.test.ts +0 -260
  35. package/tests/tools/deep-reflect.test.ts +0 -232
  36. package/tests/tools/model-index.test.ts +0 -246
  37. package/tests/ui/app.test.tsx +0 -114
@@ -1,577 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import * as os from 'os';
5
- import {
6
- executeNocturnalReflection,
7
- executeNocturnalReflectionAsync,
8
- listApprovedNocturnalArtifacts,
9
- } from '../../src/service/nocturnal-service.js';
10
- import { createNocturnalTrajectoryExtractor } from '../../src/core/nocturnal-trajectory-extractor.js';
11
- import { TrajectoryDatabase, TrajectoryRegistry } from '../../src/core/trajectory.js';
12
- import {
13
- checkWorkspaceIdle,
14
- clearAllCooldowns,
15
- recordRunStart,
16
- recordRunEnd,
17
- } from '../../src/service/nocturnal-runtime.js';
18
- import { NocturnalPathResolver } from '../../src/core/nocturnal-paths.js';
19
- import { seedSessionForTest, clearSession, listSessions } from '../../src/core/session-tracker.js';
20
-
21
- /**
22
- * NocturnalService Integration Tests
23
- *
24
- * NOTE: These tests have complex setup due to:
25
- * 1. TrajectoryRegistry singleton - must use same instance as service
26
- * 2. Async cleanup on Windows - wrapped in try-catch
27
- * 3. Fire-and-forget cooldowns - use awaitClearAllCooldowns helper
28
- */
29
- describe('NocturnalService', () => {
30
- let tmpDir: string;
31
- let workspaceDir: string;
32
- let stateDir: string;
33
- let trajectory: TrajectoryDatabase;
34
-
35
- beforeEach(() => {
36
- // Create a clean temp directory structure:
37
- // tmpDir/
38
- // workspace/ ← workspaceDir
39
- // state/ ← stateDir
40
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-service-test-'));
41
- workspaceDir = path.join(tmpDir, 'workspace');
42
- stateDir = path.join(tmpDir, 'state');
43
- fs.mkdirSync(workspaceDir, { recursive: true });
44
- fs.mkdirSync(stateDir, { recursive: true });
45
-
46
- // Initialize trajectory DB and prime the TrajectoryRegistry singleton.
47
- // The service uses createNocturnalTrajectoryExtractor which internally
48
- // calls TrajectoryRegistry.get(workspaceDir). We call it here so that
49
- // the service sees the SAME instance we seed with test data.
50
- trajectory = new TrajectoryDatabase({ workspaceDir });
51
-
52
- // Prime the singleton so the service gets our seeded instance
53
- const extractor = createNocturnalTrajectoryExtractor(workspaceDir);
54
- // extractor holds the same TrajectoryDatabase instance via singleton
55
-
56
- // Also clear any residual SessionTracker sessions from previous tests
57
- // (SessionTracker uses an in-memory Map that persists across tests)
58
- for (const session of listSessions()) {
59
- clearSession(session.sessionId);
60
- }
61
- });
62
-
63
- afterEach(() => {
64
- // Dispose in correct order: trajectory first, then registry.
65
- // On Windows, file handles may not be released immediately, so wrap in try-catch.
66
- try {
67
- trajectory.dispose();
68
- } catch {
69
- // Best effort
70
- }
71
- try {
72
- TrajectoryRegistry.dispose(workspaceDir);
73
- } catch {
74
- // Best effort
75
- }
76
- try {
77
- fs.rmSync(tmpDir, { recursive: true, force: true });
78
- } catch {
79
- // On Windows, some file handles may still be open
80
- }
81
- });
82
-
83
- // -------------------------------------------------------------------------
84
- // Helper: awaitable clear cooldowns (sync version writes directly)
85
- // -------------------------------------------------------------------------
86
-
87
- function clearCooldownsSync(): void {
88
- // Write empty cooldown state directly without async
89
- const runtimePath = path.join(stateDir, 'nocturnal-runtime.json');
90
- const defaultState = {
91
- principleCooldowns: {},
92
- recentRunTimestamps: [],
93
- };
94
- fs.writeFileSync(runtimePath, JSON.stringify(defaultState, null, 2), 'utf-8');
95
- }
96
-
97
- // -------------------------------------------------------------------------
98
- // Helper: create a properly idle override (bypasses real idle check)
99
- // -------------------------------------------------------------------------
100
-
101
- function makeIdleResult(): ReturnType<typeof checkWorkspaceIdle> {
102
- return {
103
- isIdle: true,
104
- mostRecentActivityAt: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
105
- idleForMs: 2 * 60 * 60 * 1000,
106
- userActiveSessions: 0,
107
- abandonedSessionIds: [],
108
- trajectoryGuardrailConfirmsIdle: true,
109
- reason: 'test override — workspace considered idle',
110
- };
111
- }
112
-
113
- // -------------------------------------------------------------------------
114
- // Helper: seed a minimal trajectory
115
- // -------------------------------------------------------------------------
116
-
117
- function seedSession(
118
- sessionId: string,
119
- startedAt: string,
120
- opts: {
121
- withToolCalls?: number;
122
- withPain?: boolean;
123
- withGateBlock?: boolean;
124
- outcome?: 'success' | 'failure';
125
- } = {}
126
- ): void {
127
- trajectory.recordSession({ sessionId, startedAt });
128
- const { withToolCalls = 1, withPain = false, withGateBlock = false, outcome = 'failure' } = opts;
129
-
130
- for (let i = 0; i < withToolCalls; i++) {
131
- trajectory.recordToolCall({
132
- sessionId,
133
- toolName: 'Bash',
134
- outcome,
135
- errorMessage: outcome === 'failure' ? 'Command failed: exit code 1' : null,
136
- });
137
- }
138
-
139
- if (withPain) {
140
- trajectory.recordPainEvent({
141
- sessionId,
142
- source: 'test',
143
- score: 50,
144
- reason: 'Test pain event',
145
- });
146
- }
147
-
148
- if (withGateBlock) {
149
- trajectory.recordGateBlock({
150
- sessionId,
151
- toolName: 'Edit',
152
- reason: 'Safety check: RISK_PATH modification requires PLAN.md',
153
- riskLevel: 'medium',
154
- });
155
- }
156
-
157
- // Also seed SessionTracker.sessions so checkWorkspaceIdle can see the session
158
- // (SessionTracker is separate from TrajectoryDatabase)
159
- const lastActivityAt = new Date(startedAt).getTime();
160
- seedSessionForTest(sessionId, workspaceDir, lastActivityAt);
161
- }
162
-
163
- // -------------------------------------------------------------------------
164
- // Helper: seed evaluable principles (T-08 for most tests)
165
- // -------------------------------------------------------------------------
166
-
167
- function seedPrinciples(): void {
168
- const trainingStatesPath = path.join(stateDir, 'principle_training_state.json');
169
- // T-01 has low compliance so T-08 wins selection (higher compliance = lower score priority)
170
- const data = {
171
- 'T-01': {
172
- principleId: 'T-01',
173
- principleName: 'Map Before Territory',
174
- evaluability: 'deterministic',
175
- complianceRate: 0.3,
176
- violationTrend: 1,
177
- observedViolationCount: 3,
178
- applicableOpportunityCount: 10,
179
- generatedSampleCount: 1,
180
- cooldownUntil: null,
181
- internalizationStatus: 'internalized',
182
- },
183
- 'T-08': {
184
- principleId: 'T-08',
185
- principleName: 'Pain as Signal',
186
- evaluability: 'deterministic',
187
- complianceRate: 0.8,
188
- violationTrend: 1,
189
- observedViolationCount: 5,
190
- applicableOpportunityCount: 8,
191
- generatedSampleCount: 0,
192
- cooldownUntil: null,
193
- internalizationStatus: 'internalized',
194
- },
195
- };
196
- fs.writeFileSync(trainingStatesPath, JSON.stringify(data, null, 2), 'utf-8');
197
- }
198
-
199
- // -------------------------------------------------------------------------
200
- // Helper: force workspace to be "idle"
201
- // -------------------------------------------------------------------------
202
-
203
- function forceIdleWorkspace(): void {
204
- // Workspace is idle when there are no recent sessions or sessions are old
205
- // A session from 2+ hours ago will be considered abandoned
206
- const oldSessionId = 'session-old-abandoned';
207
- const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000 - 1000).toISOString();
208
- seedSession(oldSessionId, twoHoursAgo, { withToolCalls: 0 });
209
- }
210
-
211
- // -------------------------------------------------------------------------
212
- // Tests: executeNocturnalReflection — successful run
213
- // -------------------------------------------------------------------------
214
-
215
- describe('executeNocturnalReflection — successful run', () => {
216
- it('produces an approved artifact when all conditions are met', () => {
217
- // Setup: idle workspace + evaluable principles + clear cooldowns + violating session
218
- forceIdleWorkspace();
219
- seedPrinciples();
220
- clearCooldownsSync();
221
-
222
- const recentSessionId = 'session-recent-violation';
223
- const now = new Date().toISOString();
224
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
225
-
226
- const idleResult = makeIdleResult();
227
- const result = executeNocturnalReflection(workspaceDir, stateDir, { idleCheckOverride: idleResult });
228
-
229
- expect(result.success).toBe(true);
230
- expect(result.artifact).toBeDefined();
231
- expect(result.artifact?.principleId).toBe('T-08');
232
- expect(result.artifact?.sessionId).toBe(recentSessionId);
233
- expect(result.artifact?.badDecision).toBeTruthy();
234
- expect(result.artifact?.betterDecision).toBeTruthy();
235
- expect(result.artifact?.rationale).toBeTruthy();
236
- expect(result.diagnostics.persisted).toBe(true);
237
- expect(result.diagnostics.persistedPath).toBeDefined();
238
- });
239
-
240
- it('persists artifact to the samples directory', () => {
241
- forceIdleWorkspace();
242
- seedPrinciples();
243
- clearCooldownsSync();
244
-
245
- const recentSessionId = 'session-recent-2';
246
- const now = new Date().toISOString();
247
- seedSession(recentSessionId, now, { withToolCalls: 2, withPain: true, outcome: 'failure' });
248
-
249
- const idleResult = makeIdleResult();
250
- const result = executeNocturnalReflection(workspaceDir, stateDir, { idleCheckOverride: idleResult });
251
- expect(result.success).toBe(true);
252
- expect(result.diagnostics.persistedPath).toBeDefined();
253
-
254
- // Verify file exists
255
- const persistedPath = result.diagnostics.persistedPath!;
256
- expect(fs.existsSync(persistedPath)).toBe(true);
257
-
258
- // Verify content
259
- const content = JSON.parse(fs.readFileSync(persistedPath, 'utf-8'));
260
- expect(content.status).toBe('approved');
261
- expect(content.artifactId).toBe(result.artifact?.artifactId);
262
- expect(content.boundedAction).toBeDefined();
263
- });
264
-
265
- it('returns a boundedAction in the artifact', () => {
266
- forceIdleWorkspace();
267
- seedPrinciples();
268
- clearCooldownsSync();
269
-
270
- const recentSessionId = 'session-recent-3';
271
- const now = new Date().toISOString();
272
- // Seed with pain=true so T-08 is violated (pain+failure needed for T-08)
273
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
274
-
275
- const idleResult = makeIdleResult();
276
- const result = executeNocturnalReflection(workspaceDir, stateDir, { idleCheckOverride: idleResult });
277
- expect(result.success).toBe(true);
278
- expect(result.artifact?.boundedAction).toBeDefined();
279
- expect(result.artifact?.boundedAction?.verb).toBeTruthy();
280
- expect(result.artifact?.boundedAction?.target).toBeTruthy();
281
- });
282
- });
283
-
284
- // -------------------------------------------------------------------------
285
- // Tests: executeNocturnalReflection — skip conditions
286
- // -------------------------------------------------------------------------
287
-
288
- describe('executeNocturnalReflection — skip conditions', () => {
289
- it('skips when workspace is not idle', () => {
290
- // Setup: NO forceIdleWorkspace - workspace has a very recent session
291
- seedPrinciples();
292
- clearCooldownsSync();
293
-
294
- // Create a session that started JUST NOW (non-idle)
295
- const activeSessionId = 'session-active';
296
- const justNow = new Date().toISOString();
297
- seedSession(activeSessionId, justNow, { withToolCalls: 1 });
298
-
299
- const result = executeNocturnalReflection(workspaceDir, stateDir);
300
-
301
- // Workspace is NOT idle, so preflight should block
302
- expect(result.success).toBe(false);
303
- expect(result.noTargetSelected).toBe(false); // preflight blocked
304
- });
305
-
306
- it('skips when no evaluable principles exist', () => {
307
- forceIdleWorkspace();
308
- clearCooldownsSync();
309
- // Don't seed any principles
310
-
311
- const recentSessionId = 'session-recent';
312
- const now = new Date().toISOString();
313
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
314
-
315
- const idleResult = makeIdleResult();
316
- const result = executeNocturnalReflection(workspaceDir, stateDir, { idleCheckOverride: idleResult });
317
- expect(result.success).toBe(false);
318
- expect(result.noTargetSelected).toBe(true);
319
- expect(result.skipReason).toBe('no_evaluable_principles');
320
- });
321
-
322
- it('skips when no violating sessions found (only successful sessions)', () => {
323
- forceIdleWorkspace();
324
- seedPrinciples();
325
- clearCooldownsSync();
326
-
327
- // Only successful sessions (no violations)
328
- const sessionId = 'session-success-only';
329
- const now = new Date().toISOString();
330
- seedSession(sessionId, now, { withToolCalls: 3, outcome: 'success' });
331
-
332
- const idleResult = makeIdleResult();
333
- const result = executeNocturnalReflection(workspaceDir, stateDir, { idleCheckOverride: idleResult });
334
- expect(result.success).toBe(false);
335
- expect(result.noTargetSelected).toBe(true);
336
- expect(result.skipReason).toBe('no_violating_sessions');
337
- });
338
-
339
- it('returns failure when snapshot extraction fails', () => {
340
- forceIdleWorkspace();
341
- seedPrinciples();
342
- clearCooldownsSync();
343
-
344
- // Create a session with no tool calls - will fail snapshot extraction
345
- const sessionId = 'session-no-toolcalls';
346
- const now = new Date().toISOString();
347
- seedSession(sessionId, now, { withToolCalls: 0, withPain: false, outcome: 'success' });
348
-
349
- const idleResult = makeIdleResult();
350
- const result = executeNocturnalReflection(workspaceDir, stateDir, { idleCheckOverride: idleResult });
351
- // Session has no tool calls, so minToolCalls=1 filter removes it
352
- // No sessions available → selection skips with insufficient_snapshot_data
353
- expect(result.success).toBe(false);
354
- expect(result.noTargetSelected).toBe(true);
355
- });
356
- });
357
-
358
- // -------------------------------------------------------------------------
359
- // Tests: executeNocturnalReflection — reflector override
360
- // -------------------------------------------------------------------------
361
-
362
- describe('executeNocturnalReflection — reflector override', () => {
363
- it('uses reflectorOutputOverride when skipReflector is true', () => {
364
- forceIdleWorkspace();
365
- seedPrinciples();
366
- clearCooldownsSync();
367
-
368
- const recentSessionId = 'session-override-test';
369
- const now = new Date().toISOString();
370
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
371
-
372
- const overrideArtifact = {
373
- artifactId: '11111111-2222-4333-aaaa-bbbbbbbbbbbb',
374
- sessionId: recentSessionId,
375
- principleId: 'T-08',
376
- sourceSnapshotRef: 'snapshot-override',
377
- badDecision: 'Overridden bad decision',
378
- betterDecision: 'Read the error message before retrying the bash command',
379
- rationale: 'Overridden rationale for testing purposes',
380
- createdAt: new Date().toISOString(),
381
- };
382
-
383
- const idleResult = makeIdleResult();
384
- const result = executeNocturnalReflection(workspaceDir, stateDir, {
385
- skipReflector: true,
386
- reflectorOutputOverride: JSON.stringify(overrideArtifact),
387
- idleCheckOverride: idleResult,
388
- });
389
-
390
- expect(result.success).toBe(true);
391
- expect(result.artifact?.artifactId).toBe('11111111-2222-4333-aaaa-bbbbbbbbbbbb');
392
- expect(result.artifact?.badDecision).toBe('Overridden bad decision');
393
- });
394
-
395
- it('fails if skipReflector is true but no override provided', () => {
396
- forceIdleWorkspace();
397
- seedPrinciples();
398
- clearCooldownsSync();
399
-
400
- // Seed a session so selector finds a target, then validation fails because no override
401
- const recentSessionId = 'session-no-override';
402
- const now = new Date().toISOString();
403
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
404
-
405
- const idleResult = makeIdleResult();
406
- const result = executeNocturnalReflection(workspaceDir, stateDir, {
407
- skipReflector: true,
408
- idleCheckOverride: idleResult,
409
- // no reflectorOutputOverride
410
- });
411
-
412
- expect(result.success).toBe(false);
413
- expect(result.validationFailed).toBe(true);
414
- expect(result.validationFailures.some(f => f.includes('reflectorOutputOverride'))).toBe(true);
415
- });
416
-
417
- it('rejects invalid JSON in reflectorOutputOverride', () => {
418
- forceIdleWorkspace();
419
- seedPrinciples();
420
- clearCooldownsSync();
421
-
422
- const recentSessionId = 'session-bad-override';
423
- const now = new Date().toISOString();
424
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
425
-
426
- const idleResult = makeIdleResult();
427
- const result = executeNocturnalReflection(workspaceDir, stateDir, {
428
- skipReflector: true,
429
- reflectorOutputOverride: 'not valid json',
430
- idleCheckOverride: idleResult,
431
- });
432
-
433
- expect(result.success).toBe(false);
434
- expect(result.validationFailed).toBe(true);
435
- expect(result.validationFailures.some(f => f.includes('parse'))).toBe(true);
436
- });
437
- });
438
-
439
- // -------------------------------------------------------------------------
440
- // Tests: executeNocturnalReflection — arbiter/executability rejection
441
- // -------------------------------------------------------------------------
442
-
443
- describe('executeNocturnalReflection — validation rejection', () => {
444
- it('rejects artifact with vague verb in betterDecision', () => {
445
- forceIdleWorkspace();
446
- seedPrinciples();
447
- clearCooldownsSync();
448
-
449
- const recentSessionId = 'session-vague';
450
- const now = new Date().toISOString();
451
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
452
-
453
- const overrideArtifact = {
454
- artifactId: '22222222-3333-4444-aaaa-cccccccccccc',
455
- sessionId: recentSessionId,
456
- principleId: 'T-08',
457
- sourceSnapshotRef: 'snapshot-vague',
458
- badDecision: 'Made a bad decision',
459
- betterDecision: 'Understand the error first',
460
- rationale: 'Testing executability rejection for vague verbs',
461
- createdAt: new Date().toISOString(),
462
- };
463
-
464
- const idleResult = makeIdleResult();
465
- const result = executeNocturnalReflection(workspaceDir, stateDir, {
466
- skipReflector: true,
467
- reflectorOutputOverride: JSON.stringify(overrideArtifact),
468
- idleCheckOverride: idleResult,
469
- });
470
-
471
- expect(result.success).toBe(false);
472
- expect(result.validationFailed).toBe(true);
473
- expect(result.validationFailures.some(f => f.includes('vague verb'))).toBe(true);
474
- });
475
- });
476
-
477
- // -------------------------------------------------------------------------
478
- // Tests: listApprovedNocturnalArtifacts
479
- // -------------------------------------------------------------------------
480
-
481
- describe('listApprovedNocturnalArtifacts', () => {
482
- it('returns empty array when no artifacts exist', () => {
483
- const artifacts = listApprovedNocturnalArtifacts(workspaceDir);
484
- expect(artifacts).toHaveLength(0);
485
- });
486
-
487
- it('returns approved artifacts sorted by createdAt descending', () => {
488
- // Create some sample artifacts
489
- const samples = [
490
- {
491
- artifactId: 'older-artifact',
492
- sessionId: 'session-1',
493
- principleId: 'T-08',
494
- sourceSnapshotRef: 'snap-1',
495
- badDecision: 'Bad decision 1',
496
- betterDecision: 'Better decision 1',
497
- rationale: 'Rationale 1',
498
- createdAt: '2026-03-27T10:00:00.000Z',
499
- persistedAt: '2026-03-27T10:00:00.000Z',
500
- status: 'approved',
501
- },
502
- {
503
- artifactId: 'newer-artifact',
504
- sessionId: 'session-2',
505
- principleId: 'T-08',
506
- sourceSnapshotRef: 'snap-2',
507
- badDecision: 'Bad decision 2',
508
- betterDecision: 'Better decision 2',
509
- rationale: 'Rationale 2',
510
- createdAt: '2026-03-27T12:00:00.000Z',
511
- persistedAt: '2026-03-27T12:00:00.000Z',
512
- status: 'approved',
513
- },
514
- {
515
- artifactId: 'rejected-artifact',
516
- sessionId: 'session-3',
517
- principleId: 'T-08',
518
- sourceSnapshotRef: 'snap-3',
519
- badDecision: 'Bad decision 3',
520
- betterDecision: 'Better decision 3',
521
- rationale: 'Rationale 3',
522
- createdAt: '2026-03-27T14:00:00.000Z',
523
- persistedAt: '2026-03-27T14:00:00.000Z',
524
- status: 'rejected', // Should be filtered out
525
- },
526
- ];
527
-
528
- for (const sample of samples) {
529
- const samplePath = NocturnalPathResolver.samplePath(workspaceDir, sample.artifactId);
530
- const sampleDir = path.dirname(samplePath);
531
- if (!fs.existsSync(sampleDir)) {
532
- fs.mkdirSync(sampleDir, { recursive: true });
533
- }
534
- fs.writeFileSync(samplePath, JSON.stringify(sample), 'utf-8');
535
- }
536
-
537
- const artifacts = listApprovedNocturnalArtifacts(workspaceDir);
538
- expect(artifacts).toHaveLength(2);
539
- // Should be sorted by createdAt descending (newer first)
540
- expect(artifacts[0].artifactId).toBe('newer-artifact');
541
- expect(artifacts[1].artifactId).toBe('older-artifact');
542
- });
543
-
544
- it('skips malformed JSON files', () => {
545
- const samplePath = NocturnalPathResolver.samplePath(workspaceDir, 'malformed');
546
- const sampleDir = path.dirname(samplePath);
547
- if (!fs.existsSync(sampleDir)) {
548
- fs.mkdirSync(sampleDir, { recursive: true });
549
- }
550
- fs.writeFileSync(samplePath, 'not valid json {{{', 'utf-8');
551
-
552
- const artifacts = listApprovedNocturnalArtifacts(workspaceDir);
553
- expect(artifacts).toHaveLength(0);
554
- });
555
- });
556
-
557
- // -------------------------------------------------------------------------
558
- // Tests: executeNocturnalReflectionAsync
559
- // -------------------------------------------------------------------------
560
-
561
- describe('executeNocturnalReflectionAsync', () => {
562
- it('returns a Promise that resolves to the same result as sync version', async () => {
563
- forceIdleWorkspace();
564
- seedPrinciples();
565
- clearCooldownsSync();
566
-
567
- const recentSessionId = 'session-async-test';
568
- const now = new Date().toISOString();
569
- seedSession(recentSessionId, now, { withToolCalls: 3, withPain: true, outcome: 'failure' });
570
-
571
- const idleResult = makeIdleResult();
572
- const result = await executeNocturnalReflectionAsync(workspaceDir, stateDir, { idleCheckOverride: idleResult });
573
- expect(result.success).toBe(true);
574
- expect(result.artifact).toBeDefined();
575
- });
576
- });
577
- });