principles-disciple 1.28.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.
@@ -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
@@ -118,7 +118,7 @@ function computeRuntimeShadowTaskFingerprint(event: PluginHookSubagentSpawningEv
118
118
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
119
119
  }
120
120
 
121
- function resolveCommandWorkspaceDirStrict(
121
+ function _resolveCommandWorkspaceDirStrict(
122
122
  api: OpenClawPluginApi,
123
123
  ctx: WorkspaceResolutionContext,
124
124
  ): string {
@@ -382,7 +382,7 @@ function buildFallbackNocturnalSnapshot(
382
382
  // #246-fix: Use minToolCalls=0 to avoid filtering out sessions with 0 tool calls.
383
383
  // The pain-triggering session may have no tool calls but still be worth tracking.
384
384
  const summaries = extractor.listRecentNocturnalCandidateSessions({ limit: 300, minToolCalls: 0 });
385
- const match = summaries.find(s => s.sessionId === painContext.mostRecent!.sessionId);
385
+ const match = summaries.find(s => s.sessionId === painContext.mostRecent?.sessionId);
386
386
  if (match) {
387
387
  realStats = {
388
388
  totalAssistantTurns: match.assistantTurnCount,
@@ -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';
@@ -5,6 +5,7 @@ import {
5
5
  rankCandidates,
6
6
  runTournament,
7
7
  DEFAULT_SCORING_WEIGHTS,
8
+ validateCandidateDiversity,
8
9
  } from '../../src/core/nocturnal-candidate-scoring.js';
9
10
  import type { DreamerCandidate, PhilosopherJudgment } from '../../src/core/nocturnal-trinity.js';
10
11
  import type { ThresholdValues } from '../../src/core/adaptive-thresholds.js';
@@ -398,3 +399,134 @@ describe('DEFAULT_SCORING_WEIGHTS', () => {
398
399
  }
399
400
  });
400
401
  });
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Tests: validateCandidateDiversity
405
+ // ---------------------------------------------------------------------------
406
+
407
+ describe('validateCandidateDiversity', () => {
408
+ it('passes when candidates have 2+ distinct risk levels and low keyword overlap', () => {
409
+ const candidates: DreamerCandidate[] = [
410
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Read config.json to verify settings' }),
411
+ makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Refactor the entire authentication module from scratch' }),
412
+ ];
413
+ const result = validateCandidateDiversity(candidates);
414
+ expect(result.diversityCheckPassed).toBe(true);
415
+ expect(result.riskLevelDiversity).toBe(true);
416
+ expect(result.keywordOverlapPassed).toBe(true);
417
+ });
418
+
419
+ it('fails when all candidates have the same risk level', () => {
420
+ const candidates: DreamerCandidate[] = [
421
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Read file A to check settings' }),
422
+ makeCandidate({ candidateIndex: 1, riskLevel: 'low', betterDecision: 'Review file completely different approach' }),
423
+ makeCandidate({ candidateIndex: 2, riskLevel: 'low', betterDecision: 'Inspect another unique diagnostic method' }),
424
+ ];
425
+ const result = validateCandidateDiversity(candidates);
426
+ expect(result.diversityCheckPassed).toBe(false);
427
+ expect(result.riskLevelDiversity).toBe(false);
428
+ });
429
+
430
+ it('fails when candidate pair has keyword overlap > 0.8', () => {
431
+ const candidates: DreamerCandidate[] = [
432
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Review the authentication configuration file before making any changes to the system' }),
433
+ makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Review the authentication configuration file before making any changes to the system' }),
434
+ ];
435
+ const result = validateCandidateDiversity(candidates);
436
+ expect(result.diversityCheckPassed).toBe(false);
437
+ expect(result.keywordOverlapPassed).toBe(false);
438
+ expect(result.maxOverlapScore).toBeGreaterThan(0.8);
439
+ });
440
+
441
+ it('passes for single candidate', () => {
442
+ const candidates: DreamerCandidate[] = [
443
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low' }),
444
+ ];
445
+ const result = validateCandidateDiversity(candidates);
446
+ expect(result.diversityCheckPassed).toBe(true);
447
+ expect(result.details).toContain('Single candidate');
448
+ });
449
+
450
+ it('passes for empty array', () => {
451
+ const result = validateCandidateDiversity([]);
452
+ expect(result.diversityCheckPassed).toBe(true);
453
+ expect(result.details).toContain('No candidates');
454
+ });
455
+
456
+ it('passes when candidates lack riskLevel (graceful degradation)', () => {
457
+ const candidates: DreamerCandidate[] = [
458
+ makeCandidate({ candidateIndex: 0, betterDecision: 'Read config.json to verify settings' }),
459
+ makeCandidate({ candidateIndex: 1, betterDecision: 'Refactor the entire authentication module from scratch' }),
460
+ ];
461
+ // No riskLevel on any candidate - should pass (no risk levels to check)
462
+ const result = validateCandidateDiversity(candidates);
463
+ expect(result.diversityCheckPassed).toBe(true);
464
+ expect(result.riskLevelDiversity).toBe(true);
465
+ });
466
+
467
+ it('fails when some candidates have riskLevel but fewer than 2 distinct values', () => {
468
+ const candidates: DreamerCandidate[] = [
469
+ makeCandidate({ candidateIndex: 0, riskLevel: 'medium', betterDecision: 'Read config.json to verify settings' }),
470
+ makeCandidate({ candidateIndex: 1, betterDecision: 'Refactor the entire authentication module from scratch' }),
471
+ ];
472
+ // Only 1 candidate has riskLevel, so only 1 distinct value → fail
473
+ const result = validateCandidateDiversity(candidates);
474
+ expect(result.diversityCheckPassed).toBe(false);
475
+ expect(result.riskLevelDiversity).toBe(false);
476
+ });
477
+
478
+ it('uses max(|A|, |B|) as denominator for keyword overlap', () => {
479
+ // Short text A, long text B - overlap should use max as denominator
480
+ const candidates: DreamerCandidate[] = [
481
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'review authentication configuration' }),
482
+ makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'review authentication configuration before proceeding with changes to the deployment pipeline infrastructure' }),
483
+ ];
484
+ const result = validateCandidateDiversity(candidates);
485
+ // "review", "authentication", "configuration" overlap in both
486
+ // Set A = {review, authentication, configuration} = 3
487
+ // Set B = {review, authentication, configuration, before, proceeding, with, changes, deployment, pipeline, infrastructure} = 10
488
+ // intersection = 3, max(3, 10) = 10, overlap = 3/10 = 0.3
489
+ expect(result.maxOverlapScore).toBeLessThanOrEqual(0.4);
490
+ });
491
+
492
+ it('ignores words <= 3 characters in keyword overlap', () => {
493
+ const candidates: DreamerCandidate[] = [
494
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'the and but for' }),
495
+ makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'the and but for' }),
496
+ ];
497
+ // All words are <= 3 chars, so no keywords extracted → overlap = 0
498
+ const result = validateCandidateDiversity(candidates);
499
+ expect(result.keywordOverlapPassed).toBe(true);
500
+ expect(result.maxOverlapScore).toBe(0);
501
+ });
502
+
503
+ it('never throws on malformed input', () => {
504
+ // Undefined candidates
505
+ expect(() => validateCandidateDiversity(undefined as unknown as DreamerCandidate[])).not.toThrow();
506
+ // Null candidates
507
+ expect(() => validateCandidateDiversity(null as unknown as DreamerCandidate[])).not.toThrow();
508
+ // Candidates with undefined fields
509
+ expect(() => validateCandidateDiversity([
510
+ { candidateIndex: 0 } as DreamerCandidate,
511
+ ])).not.toThrow();
512
+ // Mixed valid and malformed
513
+ expect(() => validateCandidateDiversity([
514
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low' }),
515
+ { candidateIndex: 1 } as DreamerCandidate,
516
+ ])).not.toThrow();
517
+ });
518
+
519
+ it('returns correct maxOverlapScore rounded to 2 decimal places', () => {
520
+ const candidates: DreamerCandidate[] = [
521
+ makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Review configuration settings before deployment' }),
522
+ makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Review configuration settings before deployment testing' }),
523
+ ];
524
+ const result = validateCandidateDiversity(candidates);
525
+ // Verify the maxOverlapScore is a number with at most 2 decimal places
526
+ const decimalPart = result.maxOverlapScore.toString().split('.')[1];
527
+ if (decimalPart) {
528
+ expect(decimalPart.length).toBeLessThanOrEqual(2);
529
+ }
530
+ expect(typeof result.maxOverlapScore).toBe('number');
531
+ });
532
+ });