principles-disciple 1.27.0 → 1.28.1

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 (33) hide show
  1. package/openclaw.plugin.json +4 -4
  2. package/package.json +4 -4
  3. package/scripts/diagnose-nocturnal.mjs +139 -2
  4. package/scripts/seed-nocturnal-scenarios.mjs +377 -0
  5. package/scripts/validate-live-path.ts +18 -18
  6. package/src/commands/nocturnal-train.ts +4 -6
  7. package/src/commands/pain.ts +8 -11
  8. package/src/commands/pd-reflect.ts +1 -1
  9. package/src/core/bootstrap-rules.ts +3 -3
  10. package/src/core/merge-gate-audit.ts +1 -1
  11. package/src/core/nocturnal-candidate-scoring.ts +131 -0
  12. package/src/core/nocturnal-reasoning-deriver.ts +337 -0
  13. package/src/core/nocturnal-trinity.ts +462 -25
  14. package/src/core/pain-context-extractor.ts +1 -3
  15. package/src/core/principle-tree-migration.ts +2 -4
  16. package/src/core/thinking-os-parser.ts +3 -3
  17. package/src/hooks/bash-risk.ts +1 -1
  18. package/src/hooks/gfi-gate.ts +1 -1
  19. package/src/hooks/pain.ts +1 -1
  20. package/src/hooks/prompt.ts +36 -2
  21. package/src/hooks/subagent.ts +1 -1
  22. package/src/index.ts +3 -1
  23. package/src/service/evolution-worker.ts +138 -44
  24. package/src/service/health-query-service.ts +15 -6
  25. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +0 -1
  26. package/src/tools/write-pain-flag.ts +191 -0
  27. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +34 -20
  28. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +34 -20
  29. package/tests/core/nocturnal-candidate-scoring.test.ts +132 -0
  30. package/tests/core/nocturnal-e2e.test.ts +224 -0
  31. package/tests/core/nocturnal-reasoning-deriver.test.ts +372 -0
  32. package/tests/core/nocturnal-trinity.test.ts +791 -0
  33. package/tests/tools/write-pain-flag.test.ts +240 -0
@@ -235,11 +235,9 @@ export async function extractRecentConversation(
235
235
  /**
236
236
  * Extracts failed tool call context with argument correlation.
237
237
  */
238
- // Reason: breaking API change - default param must precede required params for type inference compatibility
239
-
240
238
  export async function extractFailedToolContext(
241
239
  sessionId: string,
242
- agentId = 'main',
240
+ agentId: string,
243
241
  toolName: string,
244
242
  filePath?: string,
245
243
  ): Promise<string> {
@@ -9,8 +9,6 @@
9
9
  * - Or run manually: node scripts/migrate-principle-tree.mjs <workspace-dir>
10
10
  */
11
11
 
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
12
  import {
15
13
  loadLedger,
16
14
  saveLedger,
@@ -23,11 +21,11 @@ export interface PrincipleTreeMigrationResult {
23
21
  migratedCount: number;
24
22
  skippedCount: number;
25
23
  errorCount: number;
26
- details: Array<{
24
+ details: {
27
25
  principleId: string;
28
26
  status: 'migrated' | 'skipped' | 'error';
29
27
  reason?: string;
30
- }>;
28
+ }[];
31
29
  }
32
30
 
33
31
  /**
@@ -45,10 +45,10 @@ export function parseThinkingOsMd(content: string): ThinkingOsDirective[] {
45
45
  // Match all <directive ...> ... </directive> blocks
46
46
  const directiveRegex = /<directive\s+([^>]*)>([\s\S]*?)<\/directive>/gi;
47
47
 
48
- let match: RegExpExecArray | null = null;
48
+ let _match: RegExpExecArray | null = null;
49
49
 
50
- while ((match = directiveRegex.exec(content)) !== null) {
51
- const [, attrs, body] = match;
50
+ while ((_match = directiveRegex.exec(content)) !== null) {
51
+ const [, attrs, body] = _match;
52
52
 
53
53
  const idMatch = /id="([^"]+)"/i.exec(attrs);
54
54
  const nameMatch = /name="([^"]+)"/i.exec(attrs);
@@ -66,7 +66,7 @@ export function analyzeBashCommand(
66
66
  // - Word joiner (U+2060)
67
67
  // - Zero-width invisible separator (U+FEFF)
68
68
 
69
- const ZERO_WIDTH_CHARS = /[\u200B\u200C\u200D\u2060\uFEFF]/g;
69
+ const ZERO_WIDTH_CHARS = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
70
70
  if (ZERO_WIDTH_CHARS.test(command)) {
71
71
  logger?.warn?.(`[PD_GATE] Bash command contains zero-width characters — blocking as dangerous`);
72
72
  return 'dangerous'; // Fail-closed: zero-width chars are suspicious
@@ -65,7 +65,7 @@ function block(
65
65
  sessionId,
66
66
  blockSource: 'gfi-gate',
67
67
  }, logger ||
68
- { warn: () => {}, error: () => {} } as const);
68
+ { warn: () => { /* no-op */ }, error: () => { /* no-op */ } } as const);
69
69
  }
70
70
 
71
71
 
package/src/hooks/pain.ts CHANGED
@@ -191,7 +191,7 @@ export function handleAfterToolCall(
191
191
  // Only reduce tool_failure source GFI by 50%, preserve user_empathy and other sources
192
192
  // This prevents "read file success" from wiping user frustration signals
193
193
  const session = getSession(sessionId);
194
- const toolFailureGfi = session?.gfiBySource?.['tool_failure'] || 0;
194
+ const toolFailureGfi = session?.gfiBySource?.tool_failure || 0;
195
195
 
196
196
  let resetState: SessionState;
197
197
  if (toolFailureGfi > 0) {
@@ -10,6 +10,7 @@ import { extractSummary, getHistoryVersions, parseWorkingMemorySection, workingM
10
10
  import { EmpathyObserverWorkflowManager, empathyObserverWorkflowSpec, isExpectedSubagentError } from '../service/subagent-workflow/index.js';
11
11
  import { PathResolver } from '../core/path-resolver.js';
12
12
  import { isSubagentRuntimeAvailable } from '../utils/subagent-probe.js';
13
+ import { getPendingDiagnosticianTasks } from '../core/diagnostician-task-store.js';
13
14
  import {
14
15
  matchEmpathyKeywords,
15
16
  loadKeywordStore,
@@ -366,7 +367,7 @@ export async function handleBeforePromptBuild(
366
367
  // prependContext: Only short dynamic directives: evolutionDirective + heartbeat
367
368
 
368
369
 
369
- let prependSystemContext = '';
370
+ let prependSystemContext: string;
370
371
  let prependContext = '';
371
372
  let appendSystemContext = '';
372
373
 
@@ -644,11 +645,44 @@ ACTION: Run self-audit. If stable, reply ONLY with "HEARTBEAT_OK".
644
645
  logger?.error(`[PD:Prompt] Failed to read HEARTBEAT: ${String(e)}`);
645
646
  }
646
647
  }
648
+
649
+ // ──── 4b. Inject pending diagnostician tasks ────
650
+ // FIX (#283): The evolution worker writes pain diagnosis tasks to
651
+ // diagnostician_tasks.json. The heartbeat prompt hook must read and inject
652
+ // them so the LLM (acting as diagnostician) can process them.
653
+ try {
654
+ const pendingTasks = getPendingDiagnosticianTasks(wctx.stateDir);
655
+ if (pendingTasks.length > 0) {
656
+ const taskBlocks = pendingTasks
657
+ .slice(0, 3)
658
+ .map(({ id, task }) => `<diagnostician_task id="${id}">\n${task.prompt}\n</diagnostician_task>`)
659
+ .join('\n\n');
660
+
661
+ const pendingCount = pendingTasks.length;
662
+ const processingNote = pendingCount > 3
663
+ ? `\n\nNOTE: ${pendingCount - 3} more tasks are queued. Process these 3 first; remaining tasks will be handled on subsequent heartbeats.`
664
+ : '';
665
+
666
+ prependContext += `<diagnostician_tasks pending="${pendingCount}">
667
+ You are acting as a **Pain Diagnostician**. Process the following task(s) by:
668
+ 1. Analyzing the pain signal and its context
669
+ 2. Identifying the root cause and violated principles
670
+ 3. Writing a completion marker file: .evolution_complete_<TASK_ID>
671
+ 4. Writing a diagnostic report: .diagnostician_report_<TASK_ID>.json
672
+
673
+ ${taskBlocks}${processingNote}
674
+ </diagnostician_tasks>\n`;
675
+
676
+ logger?.info?.(`[PD:Prompt] Injected ${Math.min(pendingCount, 3)}/${pendingCount} pending diagnostician task(s) into heartbeat prompt`);
677
+ }
678
+ } catch (e) {
679
+ logger?.warn?.(`[PD:Prompt] Failed to read diagnostician tasks: ${String(e)}`);
680
+ }
647
681
  }
648
682
 
649
683
  // ──── 6. Dynamic Attitude Matrix (based on GFI) ────
650
684
 
651
- let attitudeDirective = '';
685
+ let attitudeDirective: string;
652
686
  const currentGfi = session?.currentGfi || 0;
653
687
 
654
688
  if (currentGfi >= 70) {
@@ -25,7 +25,7 @@ function createWorkflowManagerForType(
25
25
  warn: (m: string) => logger.warn(String(m)),
26
26
  error: (m: string) => logger.error(String(m)),
27
27
 
28
- debug: () => {},
28
+ debug: () => { /* no-op */ },
29
29
  } as unknown as PluginLogger;
30
30
 
31
31
  switch (workflowType) {
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ import { ensureWorkspaceTemplates } from './core/init.js';
57
57
  import { migrateDirectoryStructure } from './core/migration.js';
58
58
  import { SystemLogger } from './core/system-logger.js';
59
59
  import { createDeepReflectTool } from './tools/deep-reflect.js';
60
+ import { createWritePainFlagTool } from './tools/write-pain-flag.js';
60
61
  import { PathResolver, resolveWorkspaceDirFromApi } from './core/path-resolver.js';
61
62
  import { validateWorkspaceDir } from './core/workspace-dir-validation.js';
62
63
  import { resolveRequiredWorkspaceDir, resolveWorkspaceDir, type WorkspaceResolutionContext } from './core/workspace-dir-service.js';
@@ -117,7 +118,7 @@ function computeRuntimeShadowTaskFingerprint(event: PluginHookSubagentSpawningEv
117
118
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
118
119
  }
119
120
 
120
- function resolveCommandWorkspaceDirStrict(
121
+ function _resolveCommandWorkspaceDirStrict(
121
122
  api: OpenClawPluginApi,
122
123
  ctx: WorkspaceResolutionContext,
123
124
  ): string {
@@ -773,6 +774,7 @@ const plugin = {
773
774
  });
774
775
 
775
776
  api.registerTool(createDeepReflectTool(api));
777
+ api.registerTool(createWritePainFlagTool(api));
776
778
  }
777
779
  };
778
780
 
@@ -356,7 +356,8 @@ function isSessionAtOrBeforeTriggerTime(
356
356
 
357
357
  function buildFallbackNocturnalSnapshot(
358
358
  sleepTask: EvolutionQueueItem,
359
- extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null
359
+ extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
360
+ logger?: { warn?: (message: string) => void }
360
361
  ): NocturnalSessionSnapshot | null {
361
362
  const painContext = sleepTask.recentPainContext;
362
363
  if (!painContext) {
@@ -381,7 +382,7 @@ function buildFallbackNocturnalSnapshot(
381
382
  // #246-fix: Use minToolCalls=0 to avoid filtering out sessions with 0 tool calls.
382
383
  // The pain-triggering session may have no tool calls but still be worth tracking.
383
384
  const summaries = extractor.listRecentNocturnalCandidateSessions({ limit: 300, minToolCalls: 0 });
384
- const match = summaries.find(s => s.sessionId === painContext.mostRecent!.sessionId);
385
+ const match = summaries.find(s => s.sessionId === painContext.mostRecent?.sessionId);
385
386
  if (match) {
386
387
  realStats = {
387
388
  totalAssistantTurns: match.assistantTurnCount,
@@ -390,8 +391,10 @@ function buildFallbackNocturnalSnapshot(
390
391
  totalGateBlocks: match.gateBlockCount,
391
392
  };
392
393
  }
393
- } catch {
394
- // Best effortnon-fatal
394
+ } catch (err) {
395
+ // #260: Log extraction failures silent swallowing makes debugging impossible
396
+ // and can mask systemic trajectory DB issues.
397
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to extract real stats for session ${painContext.mostRecent?.sessionId} (falling back to zeros): ${String(err)}`);
395
398
  }
396
399
  }
397
400
 
@@ -590,63 +593,154 @@ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext
590
593
  return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
591
594
  }
592
595
 
596
+ /**
597
+ * Build a dedup key from pain context.
598
+ * Returns null when no pain context is available (bypasses dedup).
599
+ */
600
+ function buildPainSourceKey(
601
+ painCtx: ReturnType<typeof readRecentPainContext>,
602
+ ): string | null {
603
+ if (!painCtx.mostRecent) return null;
604
+ return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
605
+ }
606
+
607
+ /**
608
+ * Check whether a similar sleep_reflection task completed recently.
609
+ * Phase 3c: Prevents redundant reflections of the same underlying issue.
610
+ */
611
+ function hasRecentSimilarReflection(
612
+ queue: EvolutionQueueItem[],
613
+ painSourceKey: string,
614
+ now: number,
615
+ ): EvolutionQueueItem | null {
616
+ const DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
617
+ return queue.find((t) => {
618
+ if (t.taskKind !== 'sleep_reflection') return false;
619
+ // Only match completed tasks (exclude failed to allow retries)
620
+ if (t.status !== 'completed') return false;
621
+ if (!t.completed_at) return false;
622
+ const age = now - new Date(t.completed_at).getTime();
623
+ if (age > DEDUP_WINDOW_MS) return false;
624
+ const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
625
+ // If either side has no pain context, they don't match
626
+ if (!taskPainKey) return false;
627
+ return taskPainKey === painSourceKey;
628
+ }) ?? null;
629
+ }
630
+
631
+ /**
632
+ * Check whether a specific task kind has a pending or in-progress entry.
633
+ */
634
+ function hasPendingTask(queue: EvolutionQueueItem[], taskKind: string): boolean {
635
+ return queue.some(
636
+ (t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
637
+ );
638
+ }
639
+
640
+ /**
641
+ * Decide whether to skip enqueuing due to a recent similar reflection.
642
+ * Returns true if skipped (with log), false if should proceed.
643
+ */
644
+ function shouldSkipForDedup(
645
+ queue: EvolutionQueueItem[],
646
+ wctx: WorkspaceContext,
647
+ logger: PluginLogger,
648
+ ): boolean {
649
+ const recentPainContext = readRecentPainContext(wctx);
650
+ const painSourceKey = buildPainSourceKey(recentPainContext);
651
+
652
+ // Bypass dedup when there is no pain context — general idle reflections
653
+ // should not be throttled by the 'no_pain_context' sentinel.
654
+ if (!painSourceKey) return false;
655
+
656
+ const now = Date.now();
657
+ const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
658
+
659
+ if (recentSimilarReflection) {
660
+ const completedTime = new Date(recentSimilarReflection.completed_at!).getTime();
661
+ logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
662
+ return true;
663
+ }
664
+ return false;
665
+ }
666
+
667
+ /**
668
+ * Load and migrate the evolution queue. Returns empty array if file doesn't exist.
669
+ */
670
+ function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
671
+ let rawQueue: RawQueueItem[] = [];
672
+ try {
673
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
674
+ } catch {
675
+ // Queue doesn't exist yet - create empty array
676
+ rawQueue = [];
677
+ }
678
+ return migrateQueueToV2(rawQueue);
679
+ }
680
+
681
+ /**
682
+ * Build and persist a new sleep_reflection task.
683
+ */
684
+ function enqueueNewSleepReflectionTask(
685
+ queue: EvolutionQueueItem[],
686
+ recentPainContext: ReturnType<typeof readRecentPainContext>,
687
+ queuePath: string,
688
+ logger: PluginLogger,
689
+ ): void {
690
+ const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
691
+ const nowIso = new Date().toISOString();
692
+
693
+ queue.push({
694
+ id: taskId,
695
+ taskKind: 'sleep_reflection',
696
+ priority: 'medium',
697
+ score: 50,
698
+ source: 'nocturnal',
699
+ reason: 'Sleep-mode reflection triggered by idle workspace',
700
+ trigger_text_preview: 'Idle workspace detected',
701
+ timestamp: nowIso,
702
+ enqueued_at: nowIso,
703
+ status: 'pending',
704
+ traceId: taskId,
705
+ retryCount: 0,
706
+ maxRetries: 1, // sleep_reflection doesn't retry
707
+ recentPainContext,
708
+ });
709
+
710
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
711
+ logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
712
+ }
713
+
593
714
  /**
594
715
  * Enqueue a sleep_reflection task if one is not already pending.
595
716
  * Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
717
+ * Phase 3c: Dedup checks recent sleep_reflection tasks by pain source pattern
718
+ * to prevent redundant reflections of the same underlying issue.
596
719
  */
597
720
  async function enqueueSleepReflectionTask(
598
721
  wctx: WorkspaceContext,
599
- logger: PluginLogger
722
+ logger: PluginLogger,
600
723
  ): Promise<void> {
601
724
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
602
725
  const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
603
726
 
604
727
  try {
605
- let rawQueue: RawQueueItem[] = [];
606
- try {
607
- rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
608
- } catch {
609
- // Queue doesn't exist yet - create empty array
610
- rawQueue = [];
611
- }
612
-
613
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
728
+ const queue = loadEvolutionQueue(queuePath);
614
729
 
615
- // Check if a sleep_reflection task is already pending
616
- const hasPendingSleepReflection = queue.some(
617
- t => t.taskKind === 'sleep_reflection' && (t.status === 'pending' || t.status === 'in_progress')
618
- );
619
- if (hasPendingSleepReflection) {
730
+ // Guard 1: Skip if a sleep_reflection task is already pending/in-progress
731
+ if (hasPendingTask(queue, 'sleep_reflection')) {
620
732
  logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
621
733
  return;
622
734
  }
623
735
 
624
- const now = Date.now();
625
- const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', now);
626
- const nowIso = new Date(now).toISOString();
736
+ // Guard 2: Dedup — skip if similar reflection completed recently
737
+ if (shouldSkipForDedup(queue, wctx, logger)) {
738
+ return;
739
+ }
627
740
 
628
- // Attach recent pain context if available
741
+ // Enqueue the new task
629
742
  const recentPainContext = readRecentPainContext(wctx);
630
-
631
- queue.push({
632
- id: taskId,
633
- taskKind: 'sleep_reflection',
634
- priority: 'medium',
635
- score: 50,
636
- source: 'nocturnal',
637
- reason: 'Sleep-mode reflection triggered by idle workspace',
638
- trigger_text_preview: 'Idle workspace detected',
639
- timestamp: nowIso,
640
- enqueued_at: nowIso,
641
- status: 'pending',
642
- traceId: taskId,
643
- retryCount: 0,
644
- maxRetries: 1, // sleep_reflection doesn't retry
645
- recentPainContext,
646
- });
647
-
648
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
649
- logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
743
+ enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
650
744
  } finally {
651
745
  releaseLock();
652
746
  }
@@ -1565,7 +1659,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1565
1659
  // Phase 2: If no trajectory data, try pain-context fallback
1566
1660
  if (!snapshotData && sleepTask.recentPainContext) {
1567
1661
  logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory snapshot unavailable, will try session summary from extractor`);
1568
- snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor) ?? undefined;
1662
+ snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor, logger) ?? undefined;
1569
1663
  }
1570
1664
 
1571
1665
  const snapshotValidation = validateNocturnalSnapshotIngress(snapshotData);
@@ -553,8 +553,8 @@ export class HealthQueryService {
553
553
  const streamPath = resolvePdPath(this.workspaceDir, 'EVOLUTION_STREAM');
554
554
  if (!fs.existsSync(streamPath)) return [];
555
555
 
556
-
557
- let lines: string[] = [];
556
+ // eslint-disable-next-line @typescript-eslint/init-declarations
557
+ let lines: string[];
558
558
  try {
559
559
  const raw = fs.readFileSync(streamPath, 'utf8').trim();
560
560
  if (!raw) return [];
@@ -565,8 +565,9 @@ export class HealthQueryService {
565
565
 
566
566
  const records: RecentPrincipleChange[] = [];
567
567
  for (const line of lines) {
568
-
569
- let event: EvolutionStreamRecord | null = null;
568
+
569
+ // eslint-disable-next-line @typescript-eslint/init-declarations
570
+ let event: EvolutionStreamRecord | null;
570
571
  try {
571
572
  event = JSON.parse(line) as EvolutionStreamRecord;
572
573
  } catch {
@@ -784,6 +785,7 @@ export class HealthQueryService {
784
785
  }
785
786
 
786
787
 
788
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
787
789
  private getEventDedupKey(entry: EventLogEntry): string {
788
790
  const eventId = typeof entry.data?.eventId === 'string' ? entry.data.eventId : null;
789
791
  if (eventId) {
@@ -854,6 +856,7 @@ export class HealthQueryService {
854
856
  }
855
857
 
856
858
 
859
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
857
860
  private resolveGateType(row: GateBlockRow): string {
858
861
  if (typeof row.gate_type === 'string' && row.gate_type.trim().length > 0) {
859
862
  return row.gate_type;
@@ -878,6 +881,7 @@ export class HealthQueryService {
878
881
  }
879
882
 
880
883
 
884
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
881
885
  private scoreToStatus(score: number): string {
882
886
  if (score >= 70) return 'healthy';
883
887
  if (score >= 40) return 'warning';
@@ -885,6 +889,7 @@ export class HealthQueryService {
885
889
  }
886
890
 
887
891
 
892
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
888
893
  private evolutionToStatus(tier: string, points: number): string {
889
894
  const lower = tier.toLowerCase();
890
895
  if (lower === 'forest' || lower === 'tree') return 'healthy';
@@ -893,6 +898,7 @@ export class HealthQueryService {
893
898
  }
894
899
 
895
900
 
901
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
896
902
  private safeListFiles(dirPath: string, predicate: (_name: string) => boolean): string[] {
897
903
  if (!fs.existsSync(dirPath)) return [];
898
904
  try {
@@ -905,6 +911,7 @@ export class HealthQueryService {
905
911
  }
906
912
 
907
913
 
914
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
908
915
  private readJsonFile<T>(filePath: string, fallback: T): T {
909
916
  if (!fs.existsSync(filePath)) return fallback;
910
917
  try {
@@ -915,11 +922,13 @@ export class HealthQueryService {
915
922
  }
916
923
 
917
924
 
925
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
918
926
  private asNumber(value: unknown, fallback: number): number {
919
927
  return Number.isFinite(value) ? Number(value) : fallback;
920
928
  }
921
929
 
922
930
 
931
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
923
932
  private asNullableNumber(value: unknown): number | null {
924
933
  if (Number.isFinite(value)) return Number(value);
925
934
  if (typeof value === 'string' && value.trim().length > 0) {
@@ -954,7 +963,7 @@ export class HealthQueryService {
954
963
  dailyGfiPeak,
955
964
  today,
956
965
  );
957
- } catch (err) {
966
+ } catch {
958
967
  // Non-critical: GFI sync failure should not block queries
959
968
  }
960
969
  }
@@ -1015,7 +1024,7 @@ export class HealthQueryService {
1015
1024
  }
1016
1025
 
1017
1026
  return latest;
1018
- } catch (err) {
1027
+ } catch {
1019
1028
  // Non-critical: failure to read session files should not crash the service
1020
1029
  return null;
1021
1030
  }
@@ -36,7 +36,6 @@ import {
36
36
  } from '../nocturnal-service.js';
37
37
  import { type TrinityStageFailure, type TrinityResult } from '../../core/nocturnal-trinity.js';
38
38
  import type { TrinityRuntimeAdapter } from '../../core/nocturnal-trinity.js';
39
- import type { NocturnalSessionSnapshot } from '../../core/nocturnal-trajectory-extractor.js';
40
39
  import type { RecentPainContext } from '../evolution-worker.js';
41
40
  import * as fs from 'fs';
42
41
  import * as path from 'path';