principles-disciple 1.28.3 → 1.30.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.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.28.3",
5
+ "version": "1.30.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.28.3",
3
+ "version": "1.30.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Nocturnal Pipeline — End-to-End Acceptance Test
5
+ *
6
+ * Verifies that all components of the Nocturnal reflection pipeline
7
+ * work correctly in a real environment (not unit tests).
8
+ *
9
+ * Usage:
10
+ * node scripts/acceptance-test.mjs --workspace /path/to/workspace
11
+ *
12
+ * Output: Pass/Fail for each checkpoint with detailed diagnostics.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, readdirSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { execFileSync } from 'child_process';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+
23
+ // ─── Helpers ───
24
+ let passCount = 0;
25
+ let failCount = 0;
26
+ let warnCount = 0;
27
+
28
+ function assert(condition, testName, detail = '') {
29
+ if (condition) {
30
+ console.log(` ✅ ${testName}`);
31
+ passCount++;
32
+ } else {
33
+ console.log(` ❌ ${testName}${detail ? ` — ${detail}` : ''}`);
34
+ failCount++;
35
+ }
36
+ }
37
+
38
+ function warn(testName, detail = '') {
39
+ console.log(` ⚠️ ${testName}${detail ? ` — ${detail}` : ''}`);
40
+ warnCount++;
41
+ }
42
+
43
+ function runSql(dbPath, sql) {
44
+ return execFileSync('sqlite3', [dbPath, sql], {
45
+ encoding: 'utf-8',
46
+ timeout: 5000,
47
+ }).trim();
48
+ }
49
+
50
+ // ─── Parse workspace ───
51
+ function parseArgs() {
52
+ const argv = process.argv.slice(2);
53
+ for (let i = 0; i < argv.length; i++) {
54
+ if (argv[i] === '--workspace' && argv[i + 1]) return argv[++i];
55
+ }
56
+ try {
57
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim();
58
+ } catch {
59
+ return process.cwd();
60
+ }
61
+ }
62
+
63
+ // ─── Main ───
64
+ function main() {
65
+ const workspaceDir = parseArgs();
66
+ const stateDir = join(workspaceDir, '.state');
67
+ const dbPath = join(stateDir, 'trajectory.db');
68
+
69
+ if (!existsSync(dbPath)) {
70
+ console.error('❌ trajectory.db not found. Run seed script first.');
71
+ process.exit(1);
72
+ }
73
+
74
+ console.log('\n🧪 Nocturnal Pipeline Acceptance Test');
75
+ console.log('═'.repeat(55));
76
+ console.log(`Workspace: ${workspaceDir}`);
77
+ console.log(`Database: ${dbPath}\n`);
78
+
79
+ // ═══════════════════════════════════════════════
80
+ // SECTION 1: Seed Scenario Data Integrity
81
+ // ═══════════════════════════════════════════════
82
+ console.log('── 1. Seed Scenario Data Integrity ──');
83
+
84
+ // 1.1 Count seed sessions
85
+ const sessionCount = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM sessions WHERE session_id LIKE 'seed-%';"));
86
+ assert(sessionCount === 10, '10 seed sessions exist', `found ${sessionCount}`);
87
+
88
+ // 1.2 Count seed pain events
89
+ const painCount = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM pain_events WHERE session_id LIKE 'seed-%';"));
90
+ assert(painCount >= 10, 'Pain events exist for seed sessions', `found ${painCount}`);
91
+
92
+ // 1.3 Verify signal diversity
93
+ const sources = runSql(dbPath, "SELECT DISTINCT source FROM pain_events WHERE session_id LIKE 'seed-%';").split('\n');
94
+ assert(sources.includes('tool_failure'), 'tool_failure signal present');
95
+ assert(sources.includes('user_empathy'), 'user_empathy signal present');
96
+
97
+ // 1.4 Verify pain event scores are in valid range
98
+ const invalidScores = runSql(dbPath, "SELECT COUNT(*) FROM pain_events WHERE session_id LIKE 'seed-%' AND (score < 0 OR score > 100);");
99
+ assert(parseInt(invalidScores) === 0, 'All pain scores in 0-100 range', `${invalidScores} invalid`);
100
+
101
+ // 1.5 Verify correction cues exist
102
+ const correctionCount = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM user_turns WHERE session_id LIKE 'seed-%' AND correction_detected = 1;"));
103
+ assert(correctionCount >= 5, 'Multiple correction cues present', `found ${correctionCount}`);
104
+
105
+ // 1.6 Verify tool calls (both success and failure)
106
+ const failureCalls = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM tool_calls WHERE session_id LIKE 'seed-%' AND outcome = 'failure';"));
107
+ const successCalls = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM tool_calls WHERE session_id LIKE 'seed-%' AND outcome = 'success';"));
108
+ assert(failureCalls > 0, 'Failed tool calls in seed data', `found ${failureCalls}`);
109
+ assert(successCalls > 0, 'Successful tool calls in seed data', `found ${successCalls}`);
110
+
111
+ // 1.7 Verify scenario descriptions are unique and meaningful
112
+ const reasons = runSql(dbPath, "SELECT DISTINCT reason FROM pain_events WHERE session_id LIKE 'seed-%' ORDER BY reason;").split('\n');
113
+ const uniqueReasons = new Set(reasons);
114
+ assert(uniqueReasons.size === reasons.length, 'All pain reasons are unique', `${uniqueReasons.size}/${reasons.length} unique`);
115
+
116
+ // ═══════════════════════════════════════════════
117
+ // SECTION 2: write_pain_flag Tool
118
+ // ═══════════════════════════════════════════════
119
+ console.log('\n── 2. write_pain_flag Tool ──');
120
+
121
+ // 2.1 Test tool registered in source code
122
+ const indexSource = join(__dirname, '..', 'src', 'index.ts');
123
+ if (existsSync(indexSource)) {
124
+ const indexContent = readFileSync(indexSource, 'utf-8');
125
+ const hasImport = indexContent.includes("write-pain-flag");
126
+ const hasRegister = indexContent.includes('createWritePainFlagTool');
127
+ assert(hasImport && hasRegister, 'write_pain_flag registered in index.ts');
128
+ } else {
129
+ warn('write_pain_flag tool check', 'index.ts not found');
130
+ }
131
+
132
+ // 2.2 Verify tool source file exists
133
+ const toolSource = join(__dirname, '..', 'src', 'tools', 'write-pain-flag.ts');
134
+ assert(existsSync(toolSource), 'write-pain-flag.ts source exists');
135
+
136
+ // 2.3 Verify atomic write function
137
+ const toolContent = readFileSync(toolSource, 'utf-8');
138
+ assert(toolContent.includes('renameSync'), 'Uses atomic write (renameSync)', 'renameSync not found');
139
+
140
+ // 2.4 Verify KV serialization
141
+ assert(toolContent.includes('serializeKvLines'), 'Uses serializeKvLines for KV format', 'serializeKvLines not found');
142
+
143
+ // 2.5 Verify [object Object] protection
144
+ assert(toolContent.includes('buildPainFlag'), 'Uses buildPainFlag factory', 'buildPainFlag not found');
145
+
146
+ // 2.6 Test actual write end-to-end
147
+ const testFlagPath = join(stateDir, '.pain_flag_acceptance_test');
148
+ const testStateDir = join(stateDir, '.state_test_' + Date.now());
149
+ mkdirSync(testStateDir, { recursive: true });
150
+ try {
151
+ const testPath = join(testStateDir, '.pain_flag');
152
+ writeFileSync(testPath + '.tmp', 'source: manual\nscore: 80\ntime: 2026-04-13T00:00:00.000Z\nreason: acceptance test\n', 'utf-8');
153
+ execFileSync('mv', [testPath + '.tmp', testPath]);
154
+ const content = readFileSync(testPath, 'utf-8');
155
+ assert(content.includes('source: manual'), 'Manual pain flag write works');
156
+ assert(!content.includes('[object Object]'), 'No [object Object] corruption');
157
+ } finally {
158
+ rmSync(testStateDir, { recursive: true, force: true });
159
+ }
160
+
161
+ // ═══════════════════════════════════════════════
162
+ // SECTION 3: Dedup Logic
163
+ // ═══════════════════════════════════════════════
164
+ console.log('\n── 3. Dedup Logic (Phase 3c) ──');
165
+
166
+ const workerSource = join(__dirname, '..', 'src', 'service', 'evolution-worker.ts');
167
+ if (existsSync(workerSource)) {
168
+ const workerContent = readFileSync(workerSource, 'utf-8');
169
+
170
+ // 3.1 Helper functions exist
171
+ assert(workerContent.includes('hasRecentSimilarReflection'), 'hasRecentSimilarReflection helper extracted');
172
+ assert(workerContent.includes('buildPainSourceKey'), 'buildPainSourceKey helper extracted');
173
+ assert(workerContent.includes('shouldSkipForDedup'), 'shouldSkipForDedup helper extracted');
174
+
175
+ // 3.2 Dedup window is configured
176
+ assert(workerContent.includes('4 * 60 * 60 * 1000') || workerContent.includes('DEDUP_WINDOW_MS'), '4-hour dedup window configured');
177
+
178
+ // 3.3 No-pain-context bypass
179
+ const bypassCheck = workerContent.includes('!painSourceKey') || workerContent.includes('painSourceKey === null') ||
180
+ workerContent.includes('painSourceKey) return false') || workerContent.includes('if (!painSourceKey) return false');
181
+ assert(bypassCheck, 'no_pain_context bypasses dedup', 'bypass pattern not found');
182
+
183
+ // 3.4 Only completed tasks are checked (not failed)
184
+ assert(workerContent.includes("status !== 'completed'") || workerContent.includes("status === 'completed'"), 'Only completed tasks matched for dedup');
185
+ } else {
186
+ fail('Dedup logic check', 'evolution-worker.ts not found');
187
+ }
188
+
189
+ // ═══════════════════════════════════════════════
190
+ // SECTION 4: Correction Rejected Pain Event
191
+ // ═══════════════════════════════════════════════
192
+ console.log('\n── 4. Correction Rejected Pain Signal ──');
193
+
194
+ const trajectorySource = join(__dirname, '..', 'src', 'core', 'trajectory.ts');
195
+ if (existsSync(trajectorySource)) {
196
+ const trajContent = readFileSync(trajectorySource, 'utf-8');
197
+
198
+ // 4.1 Method exists
199
+ assert(trajContent.includes('recordCorrectionRejectedPain'), 'recordCorrectionRejectedPain method exists');
200
+
201
+ // 4.2 Called on rejected status
202
+ assert(trajContent.includes("status === 'rejected'"), 'Pain event created on rejected status');
203
+
204
+ // 4.3 Uses correct source
205
+ assert(trajContent.includes("'correction_rejected'"), 'Uses correction_rejected source');
206
+
207
+ // 4.4 Score is clamped
208
+ assert(trajContent.includes('Math.max(0') && trajContent.includes('Math.min(100'), 'Score clamped 0-100');
209
+ } else {
210
+ fail('Correction rejected check', 'trajectory.ts not found');
211
+ }
212
+
213
+ // ═══════════════════════════════════════════════
214
+ // SECTION 5: Shell Injection Safety
215
+ // ═══════════════════════════════════════════════
216
+ console.log('\n── 5. Shell Injection Safety ──');
217
+
218
+ const seedScript = join(__dirname, '..', 'scripts', 'seed-nocturnal-scenarios.mjs');
219
+ const diagnoseScript = join(__dirname, '..', 'scripts', 'diagnose-nocturnal.mjs');
220
+
221
+ if (existsSync(seedScript)) {
222
+ const seedContent = readFileSync(seedScript, 'utf-8');
223
+ const execSyncCalls = (seedContent.match(/execSync\s*\(/g) || []).length;
224
+ const execFileCalls = (seedContent.match(/execFileSync\s*\(/g) || []).length;
225
+ assert(execSyncCalls === 0 || seedContent.includes("execSync('git") || seedContent.includes('execSync("git'),
226
+ 'Seed script: no sqlite3 in execSync',
227
+ `found ${execSyncCalls} execSync calls`);
228
+ assert(execFileCalls > 0, 'Seed script: uses execFileSync', `found ${execFileCalls} calls`);
229
+
230
+ // 5.1 Pre-flight check
231
+ assert(seedContent.includes('ensureSqlite3') || seedContent.includes('sqlite3 --version'), 'Seed script: sqlite3 pre-flight check');
232
+ }
233
+
234
+ if (existsSync(diagnoseScript)) {
235
+ const diagContent = readFileSync(diagnoseScript, 'utf-8');
236
+ // 5.2 Diagnose script uses execFileSync for sqlite3
237
+ const diagExecFileCalls = (diagContent.match(/execFileSync\s*\(\s*['"]sqlite3/g) || []).length;
238
+ assert(diagExecFileCalls > 0, 'Diagnose script: uses execFileSync for sqlite3', `found ${diagExecFileCalls} calls`);
239
+
240
+ // 5.3 No shell-interpolated sqlite3 calls
241
+ const shellSqliteCalls = (diagContent.match(/execSync\s*\(\s*['"]sqlite3/g) || []).length;
242
+ assert(shellSqliteCalls === 0, 'Diagnose script: no sqlite3 in execSync', `found ${shellSqliteCalls} unsafe calls`);
243
+ }
244
+
245
+ // ═══════════════════════════════════════════════
246
+ // SECTION 6: Pending Review Warning Fix
247
+ // ═══════════════════════════════════════════════
248
+ console.log('\n── 6. Pending Review Warning Fix ──');
249
+
250
+ if (existsSync(diagnoseScript)) {
251
+ const diagContent = readFileSync(diagnoseScript, 'utf-8');
252
+ // 6.1 Pending case returns warn object
253
+ const pendingWarnPattern = /if\s*\(\s*pending\s*>\s*0\s*\)\s*\{[\s\S]*?status:\s*['"]warn['"]/;
254
+ assert(pendingWarnPattern.test(diagContent), 'Pending review returns {status:"warn"} object');
255
+ }
256
+
257
+ // ═══════════════════════════════════════════════
258
+ // SECTION 7: Path Resolver Fallback
259
+ // ═══════════════════════════════════════════════
260
+ console.log('\n── 7. Path Resolver Fallback ──');
261
+
262
+ const pathResolverSource = join(__dirname, '..', 'src', 'core', 'path-resolver.ts');
263
+ if (existsSync(pathResolverSource)) {
264
+ const prContent = readFileSync(pathResolverSource, 'utf-8');
265
+ // 7.1 resolveWorkspaceDirFromApi checks config.workspaceDir
266
+ assert(prContent.includes('config.workspaceDir') || prContent.includes('cfgWorkspaceDir'),
267
+ 'resolveWorkspaceDirFromApi checks config.workspaceDir');
268
+ }
269
+
270
+ // ═══════════════════════════════════════════════
271
+ // SECTION 8: Seed Scenario Quality (Manual Review)
272
+ // ═══════════════════════════════════════════════
273
+ console.log('\n── 8. Seed Scenario Quality ──');
274
+
275
+ // 8.1 Verify pain score distribution
276
+ const avgScore = parseFloat(runSql(dbPath, "SELECT ROUND(AVG(score), 1) FROM pain_events WHERE session_id LIKE 'seed-%';"));
277
+ assert(avgScore > 50 && avgScore < 100, 'Average pain score reasonable (50-100)', `avg=${avgScore}`);
278
+
279
+ // 8.2 Verify severity levels are set
280
+ const nullSeverity = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM pain_events WHERE session_id LIKE 'seed-%' AND severity IS NULL;"));
281
+ assert(nullSeverity === 0, 'All seed pain events have severity', `${nullSeverity} null`);
282
+
283
+ // 8.3 Verify origin is set
284
+ const nullOrigin = parseInt(runSql(dbPath, "SELECT COUNT(*) FROM pain_events WHERE session_id LIKE 'seed-%' AND origin IS NULL;"));
285
+ assert(nullOrigin === 0, 'All seed pain events have origin', `${nullOrigin} null`);
286
+
287
+ // 8.4 Verify all 10 scenario IDs follow naming convention
288
+ const seedSessions = runSql(dbPath, "SELECT session_id FROM sessions WHERE session_id LIKE 'seed-%' ORDER BY session_id;").split('\n');
289
+ const validPattern = /^seed-[a-z-]+-\d{3}$/;
290
+ const allValid = seedSessions.every(s => validPattern.test(s));
291
+ assert(allValid, 'All session IDs follow naming convention', `${seedSessions.filter(s => !validPattern.test(s)).length} invalid`);
292
+
293
+ // ═══════════════════════════════════════════════
294
+ // SUMMARY
295
+ // ═══════════════════════════════════════════════
296
+ console.log('\n' + '═'.repeat(55));
297
+ console.log(` Acceptance Test Summary`);
298
+ console.log('═'.repeat(55));
299
+ console.log(` ✅ Passed: ${passCount}`);
300
+ console.log(` ❌ Failed: ${failCount}`);
301
+ console.log(` ⚠️ Warnings: ${warnCount}`);
302
+ console.log(` Total: ${passCount + failCount + warnCount}`);
303
+ console.log('═'.repeat(55));
304
+
305
+ if (failCount === 0) {
306
+ console.log('\n🎉 All acceptance tests passed!');
307
+ process.exit(0);
308
+ } else {
309
+ console.log(`\n❌ ${failCount} test(s) failed. Review details above.`);
310
+ process.exit(1);
311
+ }
312
+ }
313
+
314
+ main();
@@ -299,12 +299,11 @@ function main() {
299
299
  for (const s of scenarios) {
300
300
  const createdAt = daysAgo(s.days);
301
301
 
302
- // Check if scenario already exists with complete data (pain_events, not just sessions)
303
- // This prevents partial data if the script was interrupted mid-insert
302
+ // Check if scenario already exists (check sessions table first since that's inserted first)
304
303
  try {
305
- const existing = runSql(dbPath, `SELECT COUNT(*) FROM pain_events WHERE session_id = '${esc(s.sessionId)}';`);
304
+ const existing = runSql(dbPath, `SELECT COUNT(*) FROM sessions WHERE session_id = '${esc(s.sessionId)}';`);
306
305
  if (parseInt(existing) > 0) {
307
- console.log(` ⏭️ Skipping ${s.sessionId} (already exists with ${existing} pain events)`);
306
+ console.log(` ⏭️ Skipping ${s.sessionId} (already exists)`);
308
307
  skipped++;
309
308
  continue;
310
309
  }
@@ -336,12 +335,12 @@ VALUES ('${esc(s.sessionId)}', '${esc(tc.toolName)}', '${esc(tc.outcome)}', ${tc
336
335
 
337
336
  // 4. Pain events
338
337
  for (const pe of s.painEvents) {
339
- sql.push(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, created_at)
340
- VALUES ('${esc(s.sessionId)}', '${esc(pe.source)}', ${pe.score}, '${esc(pe.reason)}', '${esc(pe.severity)}', '${esc(pe.origin)}', ${pe.confidence !== undefined ? pe.confidence : 'NULL'}, ${pe.text ? `'${esc(pe.text)}'` : 'NULL'}, '${createdAt}');`);
338
+ sql.push(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, created_at)
339
+ VALUES ('${esc(s.sessionId)}', '${esc(pe.source)}', ${pe.score}, '${esc(pe.reason)}', '${esc(pe.severity)}', '${esc(pe.origin)}', ${pe.confidence !== undefined ? pe.confidence : 'NULL'}, '${createdAt}');`);
341
340
  }
342
341
 
343
342
  // Execute all SQL in one transaction via stdin piping
344
- const fullSql = `BEGIN TRANSACTION;\n${sql.join('\n')}\nCOMMIT;`;
343
+ const fullSql = `.bail on\nBEGIN TRANSACTION;\n${sql.join('\n')}\nCOMMIT;`;
345
344
  try {
346
345
  execFileSync('sqlite3', [dbPath], {
347
346
  input: fullSql,
@@ -362,11 +361,19 @@ VALUES ('${esc(s.sessionId)}', '${esc(pe.source)}', ${pe.score}, '${esc(pe.reaso
362
361
 
363
362
  // Print signal diversity report
364
363
  try {
365
- const painSummary = runSql(dbPath, `.mode column\n.headers on\nSELECT source, COUNT(*) as count, ROUND(AVG(score), 1) as avg_score FROM pain_events WHERE session_id LIKE 'seed-%' GROUP BY source;`);
364
+ const painSummary = execFileSync('sqlite3', [dbPath], {
365
+ input: `.mode column\n.headers on\nSELECT source, COUNT(*) as count, ROUND(AVG(score), 1) as avg_score FROM pain_events WHERE session_id LIKE 'seed-%' GROUP BY source;`,
366
+ encoding: 'utf-8',
367
+ timeout: 5000,
368
+ });
366
369
  console.log('\n📈 Signal diversity report (seed scenarios only):');
367
370
  console.log(painSummary);
368
371
 
369
- const correctionSummary = runSql(dbPath, `.mode column\n.headers on\nSELECT COUNT(*) as total, SUM(CASE WHEN correction_detected = 1 THEN 1 ELSE 0 END) as with_correction FROM user_turns WHERE session_id LIKE 'seed-%';`);
372
+ const correctionSummary = execFileSync('sqlite3', [dbPath], {
373
+ input: `.mode column\n.headers on\nSELECT COUNT(*) as total, SUM(CASE WHEN correction_detected = 1 THEN 1 ELSE 0 END) as with_correction FROM user_turns WHERE session_id LIKE 'seed-%';`,
374
+ encoding: 'utf-8',
375
+ timeout: 5000,
376
+ });
370
377
  console.log('📝 Correction scenarios:');
371
378
  console.log(correctionSummary);
372
379
  } catch (e) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Validate Live Path Script (Phase 18)
3
+ * Validate Live Path Script (Phase 18) — with Data Flow Monitoring
4
4
  *
5
5
  * Validates the end-to-end nocturnal workflow path with bootstrapped principles.
6
6
  *
@@ -11,6 +11,7 @@
11
11
  * - Polls subagent_workflows.db directly for nocturnal workflows
12
12
  * - Correlates workflow to queue item via taskId
13
13
  * - Verifies state='completed' and explicit resolution (not 'expired')
14
+ * - Monitors data flow: queue state → workflow state → artifact persistence
14
15
  * - Outputs summary and exits 0 on success, non-zero on failure
15
16
  *
16
17
  * Usage:
@@ -36,6 +37,29 @@ const STATE_DIR = path.join(WORKSPACE_DIR, '.state');
36
37
  const QUEUE_PATH = path.join(STATE_DIR, 'EVOLUTION_QUEUE');
37
38
  const LEDGER_PATH = path.join(STATE_DIR, 'principle_training_state.json');
38
39
  const DB_PATH = path.join(STATE_DIR, 'subagent_workflows.db');
40
+ const PAIN_FLAG_PATH = path.join(STATE_DIR, '.pain_flag');
41
+ const SAMPLES_DIR = path.join(STATE_DIR, 'nocturnal', 'samples');
42
+
43
+ // ─── Helpers ─────────────────────────────────────────────────────────────
44
+ function timestamp(): string {
45
+ return new Date().toISOString();
46
+ }
47
+
48
+ function logStep(step: string, detail: string): void {
49
+ console.log(`[${timestamp()}] ▸ ${step}: ${detail}`);
50
+ }
51
+
52
+ function logData(label: string, data: unknown): void {
53
+ const display = typeof data === 'string' ? data : JSON.stringify(data).slice(0, 300);
54
+ console.log(`[${timestamp()}] 📦 ${label}: ${display}`);
55
+ }
56
+
57
+ function safeReadJson(filePath: string): unknown {
58
+ try {
59
+ if (!fs.existsSync(filePath)) return null;
60
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
61
+ } catch { return null; }
62
+ }
39
63
 
40
64
  // ─── Types ───────────────────────────────────────────────────────────────
41
65
  interface LedgerRule {
@@ -277,11 +301,39 @@ function verifyWorkflowCompletion(taskId: string): {
277
301
  async function main() {
278
302
  const verbose = process.argv.includes('--verbose');
279
303
 
304
+ console.log('╔══════════════════════════════════════════════════════════╗');
305
+ console.log('║ Nocturnal Live Path Validation + Data Flow Monitor ║');
306
+ console.log('╚══════════════════════════════════════════════════════════╝');
307
+ logStep('WORKSPACE', WORKSPACE_DIR);
308
+
309
+ // 0. Baseline: snapshot current state
310
+ logStep('BASELINE', 'Capturing current state before validation');
311
+ const queueBefore = safeReadJson(QUEUE_PATH) as QueueItem[] | null;
312
+ logData('EVOLUTION_QUEUE (before)', queueBefore?.length ?? 0);
313
+ if (fs.existsSync(PAIN_FLAG_PATH)) {
314
+ logData('.pain_flag', 'EXISTS — ' + fs.readFileSync(PAIN_FLAG_PATH, 'utf8').slice(0, 100));
315
+ } else {
316
+ logData('.pain_flag', 'not present');
317
+ }
318
+ if (fs.existsSync(SAMPLES_DIR)) {
319
+ const samplesBefore = fs.readdirSync(SAMPLES_DIR).length;
320
+ logData('nocturnal/samples/', `${samplesBefore} files`);
321
+ } else {
322
+ logData('nocturnal/samples/', 'directory not present');
323
+ }
324
+ if (fs.existsSync(DB_PATH)) {
325
+ const wfCount = listNocturnalWorkflows().length;
326
+ logData('subagent_workflows.db', `${wfCount} nocturnal workflows`);
327
+ } else {
328
+ logData('subagent_workflows.db', 'not present');
329
+ }
330
+
280
331
  // 1. Check bootstrapped rules
281
332
  // eslint-disable-next-line @typescript-eslint/init-declarations
282
333
  let rules: LedgerRule[];
283
334
  try {
284
335
  rules = loadBootstrappedRules();
336
+ logStep('STEP 1', `Found ${rules.length} bootstrapped rule(s)`);
285
337
  } catch {
286
338
  console.error('FAIL: principle_training_state.json not found. Run Phase 17 bootstrap first: npm run bootstrap-rules');
287
339
  process.exit(1);
@@ -293,7 +345,6 @@ async function main() {
293
345
  }
294
346
 
295
347
  if (verbose) {
296
- console.log(`Found ${rules.length} bootstrapped rule(s)`);
297
348
  for (const rule of rules) {
298
349
  console.log(` - ${rule.id} (principleId=${rule.principleId}, action=${rule.action})`);
299
350
  }
@@ -304,33 +355,94 @@ async function main() {
304
355
 
305
356
  // 3. Build synthetic snapshot for validation
306
357
  const snapshot = buildSyntheticSnapshot(taskId);
307
- if (verbose) {
308
- console.log(`Created synthetic snapshot: sessionId=${snapshot.sessionId}`);
309
- }
358
+ logStep('STEP 2', `Synthetic snapshot: sessionId=${snapshot.sessionId}`);
359
+ logData('snapshot.recentPain', JSON.stringify(snapshot.recentPain));
310
360
 
311
361
  // 4. Enqueue task (with lock acquisition)
312
362
  try {
313
363
  await enqueueSleepReflectionTask(taskId);
314
- if (verbose) {
315
- console.log(`Enqueued sleep_reflection task: ${taskId}`);
316
- }
364
+ logStep('STEP 3', `Enqueued sleep_reflection task: ${taskId}`);
365
+
366
+ // Post-enqueue: verify queue state
367
+ const queueAfter = safeReadJson(QUEUE_PATH) as QueueItem[] | null;
368
+ const taskItem = queueAfter?.find(q => q.id === taskId);
369
+ logData('EVOLUTION_QUEUE (after)', `${queueAfter?.length ?? 0} tasks`);
370
+ logData(`task[${taskId}]`, taskItem ? JSON.stringify(taskItem) : 'NOT FOUND');
317
371
  } catch (error: unknown) {
318
372
  console.error('FAIL: Failed to enqueue sleep_reflection task:', String(error));
319
373
  process.exit(1);
320
374
  }
321
375
 
322
- // 5. Poll for completion
376
+ // 5. Poll for completion — with data flow monitoring
323
377
  const deadline = Date.now() + POLL_TIMEOUT_MS;
324
- if (verbose) {
325
- console.log('Polling for workflow completion...');
326
- }
378
+ let pollCount = 0;
379
+ let lastQueueStatus = 'unknown';
380
+ let lastWorkflowState = 'none';
381
+ logStep('STEP 4', `Polling for workflow completion (timeout: ${POLL_TIMEOUT_MS / 1000 / 60}min, interval: ${POLL_INTERVAL_MS / 1000}s)`);
327
382
 
328
383
  while (Date.now() < deadline) {
384
+ pollCount++;
329
385
  await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
330
386
 
387
+ // Capture queue state
388
+ const queueNow = safeReadJson(QUEUE_PATH) as QueueItem[] | null;
389
+ const taskNow = queueNow?.find(q => q.id === taskId);
390
+ const currentQueueStatus = taskNow?.status ?? 'not_in_queue';
391
+
392
+ // Capture workflow DB state
393
+ const workflows = listNocturnalWorkflows();
394
+ const matchingWf = workflows.find(w => {
395
+ try {
396
+ const meta = JSON.parse(w.metadata_json);
397
+ return meta.taskId === taskId;
398
+ } catch { return false; }
399
+ });
400
+ const currentWorkflowState = matchingWf?.state ?? 'not_in_db';
401
+
402
+ // Log state changes
403
+ if (currentQueueStatus !== lastQueueStatus || currentWorkflowState !== lastWorkflowState) {
404
+ logStep(`POLL #${pollCount}`, `queue=${currentQueueStatus}, workflow=${currentWorkflowState}`);
405
+ if (taskNow) logData('queue item', JSON.stringify({ status: taskNow.status, resolution: taskNow.resolution }));
406
+ if (matchingWf) logData('workflow', JSON.stringify({ state: matchingWf.state, type: matchingWf.workflow_type }));
407
+ lastQueueStatus = currentQueueStatus;
408
+ lastWorkflowState = currentWorkflowState;
409
+ } else if (verbose) {
410
+ process.stdout.write('.');
411
+ }
412
+
413
+ // Check for completion
331
414
  const result = verifyWorkflowCompletion(taskId);
332
415
  if (result) {
333
- console.log(`RESULT: workflow=${result.workflowId} state=${result.state} resolution=${result.resolution} taskId=${taskId}`);
416
+ console.log(''); // newline if dots were printed
417
+ logStep('STEP 5', `Workflow completed!`);
418
+ logData('RESULT', `workflowId=${result.workflowId} state=${result.state} resolution=${result.resolution}`);
419
+
420
+ // Check artifact persistence
421
+ if (fs.existsSync(SAMPLES_DIR)) {
422
+ const newSamples = fs.readdirSync(SAMPLES_DIR).filter(f => {
423
+ const stat = fs.statSync(path.join(SAMPLES_DIR, f));
424
+ return stat.isFile() && f.endsWith('.json') && (Date.now() - stat.mtimeMs) < 60000; // created in last minute
425
+ });
426
+ if (newSamples.length > 0) {
427
+ logData('new artifacts', newSamples.join(', '));
428
+ const firstArtifact = safeReadJson(path.join(SAMPLES_DIR, newSamples[0]));
429
+ if (firstArtifact) logData('artifact content (first)', JSON.stringify(firstArtifact).slice(0, 300));
430
+ } else {
431
+ logData('new artifacts', 'none created in last 60s');
432
+ }
433
+ }
434
+
435
+ // Check pain_flag cleanup
436
+ if (fs.existsSync(PAIN_FLAG_PATH)) {
437
+ const flagContent = fs.readFileSync(PAIN_FLAG_PATH, 'utf8');
438
+ if (flagContent.includes('[object Object]')) {
439
+ logStep('⚠️ WARNING', 'pain_flag is corrupted ([object Object])');
440
+ } else {
441
+ logData('.pain_flag (after)', `still exists, ${flagContent.length} bytes`);
442
+ }
443
+ } else {
444
+ logData('.pain_flag (after)', 'cleaned up (file removed)');
445
+ }
334
446
 
335
447
  if (result.resolution === 'MISSING' || result.resolution === 'expired') {
336
448
  console.error('FAIL: resolution not explicit');
@@ -340,13 +452,25 @@ async function main() {
340
452
  console.log('PASS: Live path validation successful');
341
453
  process.exit(0);
342
454
  }
455
+ }
343
456
 
344
- if (verbose) {
345
- process.stdout.write('.');
346
- }
457
+ // Timeout — dump final state for debugging
458
+ console.log('');
459
+ logStep('TIMEOUT', `Poll timeout after ${pollCount} polls (${POLL_TIMEOUT_MS / 1000 / 60}min)`);
460
+ logData('FINAL queue status', lastQueueStatus);
461
+ logData('FINAL workflow state', lastWorkflowState);
462
+
463
+ // Dump full queue for debugging
464
+ const finalQueue = safeReadJson(QUEUE_PATH);
465
+ if (finalQueue) logData('FINAL queue dump', JSON.stringify(finalQueue).slice(0, 500));
466
+
467
+ // Dump full workflow DB for debugging
468
+ const finalWorkflows = listNocturnalWorkflows();
469
+ if (finalWorkflows.length > 0) {
470
+ logData('FINAL workflows', finalWorkflows.map(w => `${w.workflow_id}: state=${w.state}`).join(', '));
347
471
  }
348
472
 
349
- console.error('FAIL: Poll timeout — no completed nocturnal workflow found for taskId');
473
+ console.error('FAIL: No completed nocturnal workflow found for taskId');
350
474
  process.exit(1);
351
475
  }
352
476
 
@@ -95,6 +95,14 @@ export interface NocturnalGateBlock {
95
95
  createdAt: string;
96
96
  }
97
97
 
98
+ /**
99
+ * User correction sample for nocturnal snapshot.
100
+ * #268: Wire correction_samples into nocturnal pipeline.
101
+ */
102
+ export interface NocturnalUserCorrection {
103
+ correctionCue: string | null;
104
+ }
105
+
98
106
  /**
99
107
  * A structured nocturnal session snapshot.
100
108
  * Contains all information needed for a reflector to generate decision-point samples.
@@ -114,6 +122,8 @@ export interface NocturnalSessionSnapshot {
114
122
  toolCalls: NocturnalToolCall[];
115
123
  painEvents: NocturnalPainEvent[];
116
124
  gateBlocks: NocturnalGateBlock[];
125
+ /** #268: User corrections from correction_samples table */
126
+ userCorrections: NocturnalUserCorrection[];
117
127
  /**
118
128
  * Summary statistics for quick triage.
119
129
  * #246: All fields are now number (never null).
@@ -281,6 +291,8 @@ export class NocturnalTrajectoryExtractor {
281
291
  const toolCalls = this.trajectory.listToolCallsForSession(sessionId);
282
292
  const painEvents = this.trajectory.listPainEventsForSession(sessionId);
283
293
  const gateBlocks = this.trajectory.listGateBlocksForSession(sessionId);
294
+ // #268: Fetch correction samples for this session
295
+ const correctionSamples = this.trajectory.listCorrectionSamplesForSession(sessionId);
284
296
 
285
297
  // Map to sanitized structures
286
298
  // SECURITY: Only sanitizedText from assistant turns
@@ -346,6 +358,10 @@ export class NocturnalTrajectoryExtractor {
346
358
  toolCalls: nocturnalToolCalls,
347
359
  painEvents: nocturnalPainEvents,
348
360
  gateBlocks: nocturnalGateBlocks,
361
+ // #268: Map correction samples to nocturnal format
362
+ userCorrections: correctionSamples.map((cs: { correctionCue: string | null }) => ({
363
+ correctionCue: cs.correctionCue,
364
+ })),
349
365
  stats: {
350
366
  totalAssistantTurns: sanitizedAssistantTurns.length,
351
367
  totalToolCalls: nocturnalToolCalls.length,
@@ -420,18 +420,24 @@ export function resolveWorkspaceDirFromApi(
420
420
  if (!api) return undefined;
421
421
 
422
422
  // 1. Official API: api.runtime.agent.resolveAgentWorkspaceDir
423
-
423
+
424
424
  const officialAgent = (api.runtime as { agent?: { resolveAgentWorkspaceDir?: (cfg: unknown, id: string) => string } }).agent;
425
-
425
+
426
426
  if (officialAgent?.resolveAgentWorkspaceDir) {
427
427
  try {
428
428
  return officialAgent.resolveAgentWorkspaceDir(api.config, agentId ?? 'main');
429
429
  } catch {
430
- // Fall through to PathResolver
430
+ // Fall through to config check
431
431
  }
432
432
  }
433
433
 
434
- // 2. Fallback: PathResolver (PD_WORKSPACE_DIR env, config file, default)
434
+ // 2. Direct config workspaceDir (for tests and programmatic usage)
435
+ const cfgWorkspaceDir = (api.config as { workspaceDir?: string })?.workspaceDir;
436
+ if (cfgWorkspaceDir && cfgWorkspaceDir.trim()) {
437
+ return cfgWorkspaceDir.trim();
438
+ }
439
+
440
+ // 3. Fallback: PathResolver (PD_WORKSPACE_DIR env, config file, default)
435
441
  try {
436
442
  const pr = new PathResolver();
437
443
  return pr.getWorkspaceDir();
@@ -935,6 +935,25 @@ export class TrajectoryDatabase {
935
935
  }));
936
936
  }
937
937
 
938
+ /**
939
+ * List correction samples for a specific session.
940
+ * Returns minimal fields for nocturnal use — correction cue only.
941
+ * #268: Wire correction_samples into nocturnal pipeline.
942
+ */
943
+ listCorrectionSamplesForSession(sessionId: string): { correctionCue: string | null }[] {
944
+ const rows = this.db.prepare(`
945
+ SELECT cs.sample_id, ut.correction_cue
946
+ FROM correction_samples cs
947
+ LEFT JOIN user_turns ut ON ut.id = cs.user_correction_turn_id
948
+ WHERE cs.session_id = ?
949
+ ORDER BY cs.created_at DESC
950
+ `).all(sessionId) as Record<string, unknown>[];
951
+
952
+ return rows.map((row) => ({
953
+ correctionCue: row.correction_cue ? String(row.correction_cue) : null,
954
+ }));
955
+ }
956
+
938
957
  reviewCorrectionSample(sampleId: string, status: Exclude<CorrectionSampleReviewStatus, 'pending'>, note?: string): CorrectionSampleRecord {
939
958
  const updatedAt = nowIso();
940
959
  const updated = this.withWrite(() => {
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  PluginHookSubagentContext,
18
18
  } from './openclaw-sdk.js';
19
19
  import * as crypto from 'crypto';
20
+ import * as path from 'path';
20
21
  import type { WorkerProfile } from './core/model-deployment-registry.js';
21
22
  import { classifyTask } from './core/local-worker-routing.js';
22
23
  import { completeShadowObservation, recordShadowRouting } from './core/shadow-observation-registry.js';
@@ -66,6 +67,8 @@ import { extractAgentIdFromSessionKey } from './utils/session-key.js';
66
67
 
67
68
  // Track initialization to avoid repeated calls
68
69
  let workspaceInitialized = false;
70
+ // Track started evolution workers — one per workspace
71
+ const startedWorkspaces = new Set<string>();
69
72
 
70
73
  /**
71
74
  * Resolve workspaceDir for slash commands.
@@ -171,6 +174,23 @@ const plugin = {
171
174
  SystemLogger.log(workspaceDir, 'SYSTEM_BOOT', `Principles Disciple online. Language: ${language}`);
172
175
  workspaceInitialized = true;
173
176
  }
177
+
178
+ // ── Start EvolutionWorker for THIS workspace ──
179
+ // Each agent has its own heartbeat task. When before_prompt_build fires,
180
+ // it fires for the current agent's workspaceDir. Start one EvolutionWorker
181
+ // per workspace so each agent's pain signals are processed independently.
182
+ if (!startedWorkspaces.has(workspaceDir)) {
183
+ startedWorkspaces.add(workspaceDir);
184
+ EvolutionWorkerService.api = api;
185
+ EvolutionWorkerService.start({
186
+ config: api.config,
187
+ workspaceDir,
188
+ stateDir: path.join(workspaceDir, '.state'),
189
+ logger: api.logger,
190
+ });
191
+ api.logger.info(`[PD] EvolutionWorker started for workspace: ${workspaceDir}`);
192
+ }
193
+
174
194
  const result = await handleBeforePromptBuild(event, { ...ctx, api, workspaceDir });
175
195
 
176
196
  // Record success
@@ -17,6 +17,7 @@ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
17
17
  export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
18
18
  import { LockUnavailableError } from '../config/index.js';
19
19
  import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
20
+ import { loadNocturnalConfig } from './nocturnal-config.js';
20
21
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
21
22
  import type { WorkflowRow } from './subagent-workflow/types.js';
22
23
  import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
@@ -407,6 +408,8 @@ function buildFallbackNocturnalSnapshot(
407
408
  toolCalls: [],
408
409
  painEvents: fallbackPainEvents,
409
410
  gateBlocks: [],
411
+ // #268: Empty corrections in fallback path (no trajectory data available)
412
+ userCorrections: [],
410
413
  stats: {
411
414
  totalAssistantTurns: realStats?.totalAssistantTurns ?? 0,
412
415
  totalToolCalls: realStats?.totalToolCalls ?? 0,
@@ -485,14 +488,13 @@ function findRecentDuplicateTask(
485
488
  now: number,
486
489
  reason?: string
487
490
  ): EvolutionQueueItem | undefined {
488
-
489
491
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
490
492
  const key = normalizePainDedupKey(source, preview, reason);
491
493
  return queue.find((task) => {
492
494
  if (task.status === 'completed') return false;
495
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
493
496
  const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
494
497
  if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
495
-
496
498
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
497
499
  return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
498
500
  });
@@ -547,6 +549,7 @@ function normalizePainDedupKey(source: string, preview: string, reason?: string)
547
549
  }
548
550
 
549
551
 
552
+
550
553
  // eslint-disable-next-line @typescript-eslint/max-params
551
554
  export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
552
555
  return !!findRecentDuplicateTask(queue, source, preview, now, reason);
@@ -675,7 +678,7 @@ function shouldSkipForDedup(
675
678
  * Load and migrate the evolution queue. Returns empty array if file doesn't exist.
676
679
  */
677
680
  function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
678
- // eslint-disable-next-line no-useless-assignment
681
+ // eslint-disable-next-line @typescript-eslint/init-declarations, no-useless-assignment
679
682
  let rawQueue: RawQueueItem[] = [];
680
683
  try {
681
684
  rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
@@ -689,6 +692,7 @@ function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
689
692
  /**
690
693
  * Build and persist a new sleep_reflection task.
691
694
  */
695
+
692
696
  // eslint-disable-next-line @typescript-eslint/max-params
693
697
  function enqueueNewSleepReflectionTask(
694
698
  queue: EvolutionQueueItem[],
@@ -1608,10 +1612,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1608
1612
 
1609
1613
  // eslint-disable-next-line @typescript-eslint/init-declarations
1610
1614
  let workflowId: string | undefined;
1611
-
1612
1615
  // eslint-disable-next-line @typescript-eslint/init-declarations
1613
1616
  let nocturnalManager: NocturnalWorkflowManager;
1614
-
1615
1617
  // eslint-disable-next-line @typescript-eslint/init-declarations
1616
1618
  let snapshotData: NocturnalSessionSnapshot | undefined;
1617
1619
 
@@ -2014,6 +2016,7 @@ export async function registerEvolutionTaskSession(
2014
2016
  export interface ExtendedEvolutionWorkerService {
2015
2017
  id: string;
2016
2018
  api: OpenClawPluginApi | null;
2019
+ _startedWorkspaces: Set<string>;
2017
2020
  start: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
2018
2021
  stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
2019
2022
  }
@@ -2085,17 +2088,27 @@ async function processEvolutionQueueWithResult(
2085
2088
  export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2086
2089
  id: 'principles-evolution-worker',
2087
2090
  api: null,
2091
+ _startedWorkspaces: new Set<string>(),
2088
2092
 
2089
2093
  start(ctx: OpenClawPluginServiceContext): void {
2094
+ const workspaceDir = ctx?.workspaceDir;
2090
2095
  const logger = ctx?.logger || console;
2091
2096
  const {api} = this;
2092
- const workspaceDir = ctx?.workspaceDir;
2093
2097
 
2094
2098
  if (!workspaceDir) {
2095
2099
  if (logger) logger.warn('[PD:EvolutionWorker] workspaceDir not found in service config. Evolution cycle disabled.');
2096
2100
  return;
2097
2101
  }
2098
2102
 
2103
+ // Guard: prevent duplicate starts for the SAME workspace
2104
+ const started = EvolutionWorkerService._startedWorkspaces;
2105
+ if (started.has(workspaceDir)) {
2106
+ ctx?.logger?.info?.(`[PD:EvolutionWorker] Already started for ${workspaceDir}, skipping`);
2107
+ return;
2108
+ }
2109
+
2110
+ started.add(workspaceDir);
2111
+
2099
2112
  const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
2100
2113
  if (logger) logger.info(`[PD:EvolutionWorker] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
2101
2114
 
@@ -2109,12 +2122,16 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2109
2122
  const initialDelay = 5000;
2110
2123
  const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
2111
2124
 
2125
+ // Periodic trigger tracking
2126
+ let heartbeatCounter = 0;
2127
+
2112
2128
  async function runCycle(): Promise<void> {
2113
2129
  const cycleStart = Date.now();
2130
+ heartbeatCounter++;
2114
2131
 
2115
2132
  // ──── DEBUG: Verify subagent availability in heartbeat context ────
2116
2133
  const hbSubagent = api?.runtime?.subagent;
2117
- logger?.info?.(`[PD:DEBUG:SubagentCheck:Heartbeat] api_exists=${!!api}, subagent_exists=${!!hbSubagent}, subagent.run_exists=${!!hbSubagent?.run}`);
2134
+ logger?.info?.(`[PD:DEBUG:SubagentCheck:Heartbeat] api_exists=${!!api}, subagent_exists=${!!hbSubagent}, subagent.run_exists=${!!hbSubagent?.run}, heartbeatCounter=${heartbeatCounter}`);
2118
2135
  if (hbSubagent?.run) {
2119
2136
  logger?.info?.('[PD:DEBUG:SubagentCheck:Heartbeat] run entrypoint is callable');
2120
2137
  }
@@ -2135,18 +2152,45 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2135
2152
  };
2136
2153
 
2137
2154
  try {
2155
+ // Load config on each cycle (supports runtime updates)
2156
+ const sleepConfig = loadNocturnalConfig(wctx.stateDir);
2157
+
2138
2158
  const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
2139
- logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt}`);
2140
- if (idleResult.isIdle) {
2141
- logger?.debug?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
2142
- const cooldown = checkCooldown(wctx.stateDir);
2159
+ logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt} triggerMode=${sleepConfig.trigger_mode}`);
2160
+
2161
+ let shouldTrySleepReflection = false;
2162
+
2163
+ // Path 1: Idle-based trigger (default mode)
2164
+ if (idleResult.isIdle && sleepConfig.trigger_mode === 'idle') {
2165
+ logger?.info?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
2166
+ shouldTrySleepReflection = true;
2167
+ }
2168
+
2169
+ // Path 2: Periodic trigger (bypasses idle requirement — for debugging)
2170
+ if (!idleResult.isIdle && sleepConfig.trigger_mode === 'periodic') {
2171
+ if (heartbeatCounter >= sleepConfig.period_heartbeats) {
2172
+ logger?.info?.(`[PD:EvolutionWorker] Periodic trigger: heartbeatCounter=${heartbeatCounter} >= period_heartbeats=${sleepConfig.period_heartbeats}`);
2173
+ shouldTrySleepReflection = true;
2174
+ heartbeatCounter = 0; // Reset counter
2175
+ } else {
2176
+ logger?.info?.(`[PD:EvolutionWorker] Periodic: ${heartbeatCounter}/${sleepConfig.period_heartbeats} heartbeats — waiting`);
2177
+ }
2178
+ }
2179
+
2180
+ if (shouldTrySleepReflection) {
2181
+ const cooldown = checkCooldown(wctx.stateDir, undefined, {
2182
+ maxRunsPerWindow: sleepConfig.max_runs_per_day,
2183
+ quotaWindowMs: 24 * 60 * 60 * 1000,
2184
+ });
2185
+ logger?.info?.(`[PD:EvolutionWorker] Cooldown check: globalCooldownActive=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted} runsRemaining=${cooldown.runsRemaining}`);
2143
2186
  if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
2187
+ logger?.info?.('[PD:EvolutionWorker] Attempting to enqueue sleep_reflection task...');
2144
2188
  enqueueSleepReflectionTask(wctx, logger).catch((err) => {
2145
2189
  logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
2146
2190
  });
2191
+ } else {
2192
+ logger?.info?.(`[PD:EvolutionWorker] Skipping sleep_reflection: globalCooldown=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted}`);
2147
2193
  }
2148
- } else {
2149
- logger?.debug?.(`[PD:EvolutionWorker] Workspace active (last activity ${idleResult.idleForMs}ms ago)`);
2150
2194
  }
2151
2195
 
2152
2196
  const painCheckResult = await checkPainFlag(wctx, logger);
@@ -0,0 +1,72 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ export interface SleepReflectionConfig {
5
+ /** Trigger mode: "idle" (default) or "periodic" */
6
+ trigger_mode: 'idle' | 'periodic';
7
+ /** In periodic mode, trigger every N heartbeat cycles */
8
+ period_heartbeats: number;
9
+ /** Minimum time between runs (ms) */
10
+ cooldown_ms: number;
11
+ /** Maximum runs per 24-hour window */
12
+ max_runs_per_day: number;
13
+ /** Whether sleep_reflection is enabled */
14
+ enabled: boolean;
15
+ }
16
+
17
+ export interface NocturnalConfig {
18
+ sleep_reflection?: Partial<SleepReflectionConfig>;
19
+ }
20
+
21
+ const DEFAULT_SLEEP_REFLECTION: SleepReflectionConfig = {
22
+ trigger_mode: 'idle',
23
+ period_heartbeats: 4, // ~1 hour at 15-min heartbeat interval
24
+ cooldown_ms: 30 * 60 * 1000, // 30 minutes
25
+ max_runs_per_day: 3,
26
+ enabled: true,
27
+ };
28
+
29
+ const CONFIG_FILENAME = 'nocturnal-config.json';
30
+
31
+ /**
32
+ * Resolve the nocturnal config file path.
33
+ */
34
+ function resolveConfigPath(stateDir: string): string {
35
+ return path.join(stateDir, CONFIG_FILENAME);
36
+ }
37
+
38
+ /**
39
+ * Load nocturnal config from .state/nocturnal-config.json.
40
+ * Returns default config if file doesn't exist or is malformed.
41
+ */
42
+ export function loadNocturnalConfig(stateDir: string): SleepReflectionConfig {
43
+ const configPath = resolveConfigPath(stateDir);
44
+ let fileConfig: NocturnalConfig = {};
45
+
46
+ if (fs.existsSync(configPath)) {
47
+ try {
48
+ const raw = fs.readFileSync(configPath, 'utf8');
49
+ fileConfig = JSON.parse(raw);
50
+ } catch {
51
+ // Malformed config — continue with defaults
52
+ }
53
+ }
54
+
55
+ const fileSleep = fileConfig.sleep_reflection || {};
56
+
57
+ return {
58
+ trigger_mode: fileSleep.trigger_mode === 'periodic' ? 'periodic' : DEFAULT_SLEEP_REFLECTION.trigger_mode,
59
+ period_heartbeats: typeof fileSleep.period_heartbeats === 'number' && fileSleep.period_heartbeats > 0
60
+ ? fileSleep.period_heartbeats
61
+ : DEFAULT_SLEEP_REFLECTION.period_heartbeats,
62
+ cooldown_ms: typeof fileSleep.cooldown_ms === 'number' && fileSleep.cooldown_ms >= 0
63
+ ? fileSleep.cooldown_ms
64
+ : DEFAULT_SLEEP_REFLECTION.cooldown_ms,
65
+ max_runs_per_day: typeof fileSleep.max_runs_per_day === 'number' && fileSleep.max_runs_per_day > 0
66
+ ? fileSleep.max_runs_per_day
67
+ : DEFAULT_SLEEP_REFLECTION.max_runs_per_day,
68
+ enabled: typeof fileSleep.enabled === 'boolean'
69
+ ? fileSleep.enabled
70
+ : DEFAULT_SLEEP_REFLECTION.enabled,
71
+ };
72
+ }
@@ -474,7 +474,10 @@ export class NocturnalTargetSelector {
474
474
  toolName: gb.toolName,
475
475
  reason: gb.reason,
476
476
  })),
477
- userCorrections: [],
477
+ // #268: Use actual correction samples from snapshot instead of empty array
478
+ userCorrections: snapshot.userCorrections.map((uc) => ({
479
+ correctionCue: uc.correctionCue ?? undefined,
480
+ })),
478
481
  planApprovals: [],
479
482
  });
480
483
  hasViolation = violationResult.violated;
@@ -40,6 +40,11 @@ describe('Phase 4a: Correction rejected integration', () => {
40
40
  correctionDetected: true, correctionCue: '错了',
41
41
  referencesAssistantTurnId: atId, createdAt: new Date().toISOString(),
42
42
  });
43
+ // Tool call triggers maybeCreateCorrectionSample on success
44
+ trajectory.recordToolCall({
45
+ sessionId: 'corr-session', toolName: 'read', outcome: 'success',
46
+ createdAt: new Date().toISOString(),
47
+ });
43
48
 
44
49
  // Verify sample was created
45
50
  const samples = trajectory.listCorrectionSamples('pending');
@@ -68,6 +73,11 @@ describe('Phase 4a: Correction rejected integration', () => {
68
73
  correctionDetected: true, correctionCue: '改进',
69
74
  referencesAssistantTurnId: atId, createdAt: new Date().toISOString(),
70
75
  });
76
+ // Tool call triggers maybeCreateCorrectionSample on success
77
+ trajectory.recordToolCall({
78
+ sessionId: 'approved-session', toolName: 'read', outcome: 'success',
79
+ createdAt: new Date().toISOString(),
80
+ });
71
81
 
72
82
  const samples = trajectory.listCorrectionSamples('pending');
73
83
  expect(samples.length).toBe(1);
@@ -122,15 +122,28 @@ describe('write_pain_flag tool', () => {
122
122
  expect(api._logs.some((l: any) => l.level === 'warn')).toBe(true);
123
123
  });
124
124
 
125
- it('returns clear error when workspace cannot be resolved', async () => {
126
- const api = createMockApi('') as any;
127
- const tool = createWritePainFlagTool(api);
125
+ it('falls back to PathResolver when config.workspaceDir is not set', async () => {
126
+ // Even without explicit workspaceDir, the tool should succeed
127
+ // by falling back to PathResolver (which finds default workspace)
128
+ const logs: { level: string; message: string }[] = [];
129
+ const api = {
130
+ config: {},
131
+ logger: {
132
+ info: (m: string) => logs.push({ level: 'info', message: m }),
133
+ warn: (m: string) => logs.push({ level: 'warn', message: m }),
134
+ error: (m: string) => logs.push({ level: 'error', message: m }),
135
+ debug: (m: string) => logs.push({ level: 'debug', message: m }),
136
+ },
137
+ runtime: { subagent: null, agent: null },
138
+ _logs: logs,
139
+ } as any;
128
140
 
129
- const result = await tool.execute('test-3', { reason: 'Test error' });
141
+ const tool = createWritePainFlagTool(api);
142
+ const result = await tool.execute('test-3', { reason: 'Test fallback' });
130
143
 
131
- expect(result.content[0].text).toContain('❌');
132
- expect(result.content[0].text).toContain('workspace');
133
- expect(api._logs.some((l: any) => l.level === 'error')).toBe(true);
144
+ // Should succeed via PathResolver fallback
145
+ expect(result.content[0].text).toContain('');
146
+ expect(result.content[0].text).toContain('Test fallback');
134
147
  });
135
148
 
136
149
  // ─────────────────────────────────────────────────────────
@@ -209,16 +222,19 @@ describe('write_pain_flag tool', () => {
209
222
  expect(text).toContain('heartbeat');
210
223
  });
211
224
 
212
- it('provides clear failure feedback with error message', async () => {
225
+ it('handles missing state directory by creating it automatically', async () => {
213
226
  const api = createMockApi(workspaceDir) as any;
214
- // Simulate workspace resolution failure by removing the config
215
- (api as any).config = {};
227
+ // Remove .state directory to test auto-creation
228
+ if (fs.existsSync(stateDir)) {
229
+ fs.rmSync(stateDir, { recursive: true, force: true });
230
+ }
216
231
 
217
232
  const tool = createWritePainFlagTool(api);
218
- const result = await tool.execute('test-7', { reason: 'Should fail' });
233
+ const result = await tool.execute('test-auto', { reason: 'Auto-create state dir' });
219
234
 
220
- expect(result.content[0].text).toContain('');
221
- expect(result.content[0].text).toContain('workspace');
235
+ expect(result.content[0].text).toContain('');
236
+ const painFlagPath = path.join(stateDir, '.pain_flag');
237
+ expect(fs.existsSync(painFlagPath)).toBe(true);
222
238
  });
223
239
 
224
240
  // ─────────────────────────────────────────────────────────