principles-disciple 1.29.0 → 1.31.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.29.0",
5
+ "version": "1.31.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -76,8 +76,8 @@
76
76
  }
77
77
  },
78
78
  "buildFingerprint": {
79
- "gitSha": "25bfc2bb209f",
80
- "bundleMd5": "b735aa483374dd2c7071295b11161676",
81
- "builtAt": "2026-04-13T14:25:24.799Z"
79
+ "gitSha": "15c19a4dc3f2",
80
+ "bundleMd5": "684e47fd5c521d722150a93813fddd02",
81
+ "builtAt": "2026-04-14T03:01:11.396Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.29.0",
3
+ "version": "1.31.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -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,
@@ -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,46 @@ 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 (fires regardless of idle state)
2170
+ if (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
+ globalCooldownMs: sleepConfig.cooldown_ms,
2183
+ maxRunsPerWindow: sleepConfig.max_runs_per_day,
2184
+ quotaWindowMs: 24 * 60 * 60 * 1000,
2185
+ });
2186
+ logger?.info?.(`[PD:EvolutionWorker] Cooldown check: globalCooldownActive=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted} runsRemaining=${cooldown.runsRemaining}`);
2143
2187
  if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
2188
+ logger?.info?.('[PD:EvolutionWorker] Attempting to enqueue sleep_reflection task...');
2144
2189
  enqueueSleepReflectionTask(wctx, logger).catch((err) => {
2145
2190
  logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
2146
2191
  });
2192
+ } else {
2193
+ logger?.info?.(`[PD:EvolutionWorker] Skipping sleep_reflection: globalCooldown=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted}`);
2147
2194
  }
2148
- } else {
2149
- logger?.debug?.(`[PD:EvolutionWorker] Workspace active (last activity ${idleResult.idleForMs}ms ago)`);
2150
2195
  }
2151
2196
 
2152
2197
  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
+ }
@@ -436,10 +436,12 @@ export function checkCooldown(
436
436
  *
437
437
  * @param stateDir - State directory
438
438
  * @param principleId - Target principle ID for this run
439
+ * @param cooldownMs - Global cooldown duration in ms (default: 1 hour)
439
440
  */
440
441
  export async function recordRunStart(
441
442
  stateDir: string,
442
- principleId: string
443
+ principleId: string,
444
+ cooldownMs: number = DEFAULT_GLOBAL_COOLDOWN_MS
443
445
  ): Promise<void> {
444
446
  const state = await readState(stateDir);
445
447
  const now = new Date().toISOString();
@@ -450,8 +452,8 @@ export async function recordRunStart(
450
452
  status: 'skipped', // Will be updated on completion
451
453
  };
452
454
 
453
- // Set global cooldown
454
- const cooldownUntil = new Date(Date.now() + DEFAULT_GLOBAL_COOLDOWN_MS).toISOString();
455
+ // Set global cooldown (use configured value, not hardcoded default)
456
+ const cooldownUntil = new Date(Date.now() + cooldownMs).toISOString();
455
457
  state.globalCooldownUntil = cooldownUntil;
456
458
 
457
459
  // Add to recent runs for quota tracking
@@ -96,6 +96,7 @@ import {
96
96
  type IdleCheckResult,
97
97
  type PreflightCheckResult,
98
98
  } from './nocturnal-runtime.js';
99
+ import { loadNocturnalConfig } from './nocturnal-config.js';
99
100
  import { NocturnalPathResolver } from '../core/nocturnal-paths.js';
100
101
  import { registerSample } from '../core/nocturnal-dataset.js';
101
102
  import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
@@ -721,7 +722,8 @@ export function executeNocturnalReflection(
721
722
  if (!preflight.canRun) {
722
723
  return {
723
724
  success: false,
724
- noTargetSelected: false,
725
+ noTargetSelected: true,
726
+ skipReason: 'preflight_blocked',
725
727
  validationFailed: false,
726
728
  validationFailures: [],
727
729
  diagnostics,
@@ -784,7 +786,8 @@ export function executeNocturnalReflection(
784
786
  // -------------------------------------------------------------------------
785
787
  // Note: We use a sync approximation here since this is called from sync context
786
788
  // The async version would be used in real worker integration
787
- void recordRunStart(stateDir, selectedPrincipleId).catch((err) => {
789
+ const config = loadNocturnalConfig(stateDir);
790
+ void recordRunStart(stateDir, selectedPrincipleId, config.cooldown_ms).catch((err) => {
788
791
  console.warn(`[nocturnal-service] Failed to record run start: ${String(err)}`);
789
792
  });
790
793
 
@@ -1195,7 +1198,14 @@ async function executeNocturnalReflectionWithAdapter(
1195
1198
  diagnostics.preflight = preflight;
1196
1199
 
1197
1200
  if (!preflight.canRun) {
1198
- return { success: false, noTargetSelected: false, validationFailed: false, validationFailures: [], diagnostics };
1201
+ return {
1202
+ success: false,
1203
+ noTargetSelected: true,
1204
+ skipReason: 'preflight_blocked',
1205
+ validationFailed: false,
1206
+ validationFailures: [],
1207
+ diagnostics
1208
+ };
1199
1209
  }
1200
1210
 
1201
1211
  // Step 2: Target selection (or use override to skip)
@@ -1317,7 +1327,8 @@ async function executeNocturnalReflectionWithAdapter(
1317
1327
  }
1318
1328
 
1319
1329
  // Step 3: Record run start
1320
- void recordRunStart(stateDir, selectedPrincipleId).catch((err) => {
1330
+ const config = loadNocturnalConfig(stateDir);
1331
+ void recordRunStart(stateDir, selectedPrincipleId, config.cooldown_ms).catch((err) => {
1321
1332
  console.warn(`[nocturnal-service] Failed to record run start: ${String(err)}`);
1322
1333
  });
1323
1334
 
@@ -63,7 +63,8 @@ export type SkipReason =
63
63
  | 'workspace_not_idle' // Workspace is active, nocturnal not allowed
64
64
  | 'quota_exhausted' // Max runs per quota window reached
65
65
  | 'insufficient_snapshot_data' // Sessions exist but lack tool calls / events
66
- | 'global_cooldown_active'; // Global cooldown is in effect
66
+ | 'global_cooldown_active' // Global cooldown is in effect
67
+ | 'preflight_blocked'; // Preflight check blocked (idle/cooldown/quota)
67
68
 
68
69
  export interface SelectionDiagnostics {
69
70
  /** Total evaluable principles found */
@@ -474,7 +475,10 @@ export class NocturnalTargetSelector {
474
475
  toolName: gb.toolName,
475
476
  reason: gb.reason,
476
477
  })),
477
- userCorrections: [],
478
+ // #268: Use actual correction samples from snapshot instead of empty array
479
+ userCorrections: snapshot.userCorrections.map((uc) => ({
480
+ correctionCue: uc.correctionCue ?? undefined,
481
+ })),
478
482
  planApprovals: [],
479
483
  });
480
484
  hasViolation = violationResult.violated;
@@ -262,8 +262,10 @@ export class NocturnalWorkflowManager implements WorkflowManager {
262
262
  painContext,
263
263
  // #244: Only skip preflight idle gate for manual/test triggers.
264
264
  // Automatic triggers must go through normal idle check.
265
+ // #292: Periodic triggers (source='nocturnal') also bypass idle check for debugging
265
266
  ...(((options.metadata)?.triggerSource === 'manual' ||
266
- (options.metadata)?.triggerSource === 'test')
267
+ (options.metadata)?.triggerSource === 'test' ||
268
+ (options.metadata)?.triggerSource === 'nocturnal')
267
269
  ? {
268
270
  idleCheckOverride: {
269
271
  isIdle: true,
@@ -272,7 +274,7 @@ export class NocturnalWorkflowManager implements WorkflowManager {
272
274
  userActiveSessions: 0,
273
275
  abandonedSessionIds: [],
274
276
  trajectoryGuardrailConfirmsIdle: true,
275
- reason: 'manual/test override',
277
+ reason: `${(options.metadata)?.triggerSource ?? 'manual'}.test override`,
276
278
  },
277
279
  }
278
280
  : {}),
@@ -293,7 +295,28 @@ export class NocturnalWorkflowManager implements WorkflowManager {
293
295
  this.completedWorkflows.set(workflowId, Date.now());
294
296
  } else {
295
297
  const reason = result.noTargetSelected ? 'no_target_selected' : 'validation_failed';
296
- this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline failed: reason=${reason}, noTargetSelected=${result.noTargetSelected}, skipReason=${result.skipReason ?? 'none'}, validationFailures=${result.validationFailures?.length ?? 0}`);
298
+ const failuresSummary = result.validationFailures?.length > 0
299
+ ? result.validationFailures.join('; ')
300
+ : (result.skipReason ?? 'none');
301
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline failed: reason=${reason}, noTargetSelected=${result.noTargetSelected}, skipReason=${result.skipReason ?? 'none'}, validationFailures=${result.validationFailures?.length ?? 0}, details=${failuresSummary}`);
302
+
303
+ // Log full result structure for debugging
304
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Full result: success=${result.success}, validationFailed=${result.validationFailed}, noTargetSelected=${result.noTargetSelected}`);
305
+
306
+ // Log diagnostics for debugging
307
+ if (result.diagnostics?.trinityResult) {
308
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Trinity result: success=${result.diagnostics.trinityResult.success}, chainMode=${result.diagnostics.chainModeUsed ?? 'unknown'}`);
309
+ if (!result.diagnostics.trinityResult.success) {
310
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Trinity failures: ${result.diagnostics.trinityResult.failures.map(f => `${f.stage}: ${f.reason}`).join('; ')}`);
311
+ }
312
+ }
313
+ if (result.diagnostics?.arbiterResult) {
314
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Arbiter result: passed=${result.diagnostics.arbiterResult.passed}, failures=${result.diagnostics.arbiterResult.failures.map(f => f.reason).join('; ')}`);
315
+ }
316
+ if (result.diagnostics?.selection) {
317
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Selection: decision=${result.diagnostics.selection.decision}, principleId=${result.diagnostics.selection.selectedPrincipleId ?? 'none'}, sessionId=${result.diagnostics.selection.selectedSessionId ?? 'none'}`);
318
+ }
319
+
297
320
  this.store.updateWorkflowState(workflowId, 'terminal_error');
298
321
  this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', reason, {
299
322
  failures: result.validationFailures,