principles-disciple 1.19.0 → 1.20.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.19.0",
5
+ "version": "1.20.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.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -14,8 +14,9 @@ function isNonEmptyString(value: unknown): value is string {
14
14
  return typeof value === 'string' && value.trim().length > 0;
15
15
  }
16
16
 
17
- function isNumberOrNull(value: unknown): value is number | null {
18
- return value === null || (typeof value === 'number' && Number.isFinite(value));
17
+ /** #246: Stats fields must now be finite numbers — null is no longer accepted. */
18
+ function isFiniteNumber(value: unknown): value is number {
19
+ return typeof value === 'number' && Number.isFinite(value);
19
20
  }
20
21
 
21
22
  export function validateNocturnalSnapshotIngress(
@@ -56,20 +57,20 @@ export function validateNocturnalSnapshotIngress(
56
57
  if (!isObjectRecord(stats)) {
57
58
  reasons.push('snapshot.stats must be an object');
58
59
  } else {
59
- if (!isNumberOrNull(stats.totalAssistantTurns)) {
60
- reasons.push('snapshot.stats.totalAssistantTurns must be a number or null');
60
+ if (!isFiniteNumber(stats.totalAssistantTurns)) {
61
+ reasons.push('snapshot.stats.totalAssistantTurns must be a finite number');
61
62
  }
62
- if (!isNumberOrNull(stats.totalToolCalls)) {
63
- reasons.push('snapshot.stats.totalToolCalls must be a number or null');
63
+ if (!isFiniteNumber(stats.totalToolCalls)) {
64
+ reasons.push('snapshot.stats.totalToolCalls must be a finite number');
64
65
  }
65
- if (typeof stats.totalPainEvents !== 'number' || !Number.isFinite(stats.totalPainEvents)) {
66
+ if (!isFiniteNumber(stats.totalPainEvents)) {
66
67
  reasons.push('snapshot.stats.totalPainEvents must be a finite number');
67
68
  }
68
- if (!isNumberOrNull(stats.totalGateBlocks)) {
69
- reasons.push('snapshot.stats.totalGateBlocks must be a number or null');
69
+ if (!isFiniteNumber(stats.totalGateBlocks)) {
70
+ reasons.push('snapshot.stats.totalGateBlocks must be a finite number');
70
71
  }
71
- if (!isNumberOrNull(stats.failureCount)) {
72
- reasons.push('snapshot.stats.failureCount must be a number or null');
72
+ if (!isFiniteNumber(stats.failureCount)) {
73
+ reasons.push('snapshot.stats.failureCount must be a finite number');
73
74
  }
74
75
  }
75
76
 
@@ -85,21 +86,6 @@ export function validateNocturnalSnapshotIngress(
85
86
  }
86
87
  }
87
88
 
88
- if (!isFallback && isObjectRecord(stats)) {
89
- if (stats.totalAssistantTurns === null) {
90
- reasons.push('non-fallback snapshot.stats.totalAssistantTurns must be a number');
91
- }
92
- if (stats.totalToolCalls === null) {
93
- reasons.push('non-fallback snapshot.stats.totalToolCalls must be a number');
94
- }
95
- if (stats.totalGateBlocks === null) {
96
- reasons.push('non-fallback snapshot.stats.totalGateBlocks must be a number');
97
- }
98
- if (stats.failureCount === null) {
99
- reasons.push('non-fallback snapshot.stats.failureCount must be a number');
100
- }
101
- }
102
-
103
89
  if (reasons.length > 0) {
104
90
  return { status: 'invalid', reasons };
105
91
  }
@@ -116,15 +116,18 @@ export interface NocturnalSessionSnapshot {
116
116
  gateBlocks: NocturnalGateBlock[];
117
117
  /**
118
118
  * Summary statistics for quick triage.
119
- * When _dataSource is 'pain_context_fallback', these fields are null
120
- * to distinguish "no data" from "data is zero".
119
+ * #246: All fields are now number (never null).
120
+ * Previously null was used to mean "no trajectory data", but this caused
121
+ * downstream consumers to crash on arithmetic. The fallback path now
122
+ * queries the trajectory extractor for real data and falls back to 0.
123
+ * Use _dataSource === 'pain_context_fallback' to detect partial data.
121
124
  */
122
125
  stats: {
123
- totalAssistantTurns: number | null;
124
- totalToolCalls: number | null;
126
+ totalAssistantTurns: number;
127
+ totalToolCalls: number;
125
128
  totalPainEvents: number;
126
- totalGateBlocks: number | null;
127
- failureCount: number | null;
129
+ totalGateBlocks: number;
130
+ failureCount: number;
128
131
  };
129
132
  /**
130
133
  * #219: Marker for data source to identify fallback/partial stats.
@@ -132,9 +132,17 @@ async function runWorkflowWatchdog(
132
132
  if (dataSource === 'pain_context_fallback') {
133
133
  details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
134
134
  }
135
- const stats = snapshot.stats as Record<string, number | null> | undefined;
136
- if (stats && stats.totalAssistantTurns === null && stats.totalToolCalls === null && stats.totalPainEvents === 0 && stats.totalGateBlocks === null) {
137
- details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has null stats (data unavailable)`);
135
+ const stats = snapshot.stats as Record<string, number> | undefined;
136
+ // #246: Stats are now always number (never null). Detect "empty" fallback:
137
+ // fallback + all counts zero means no real data was available.
138
+ // NOTE: totalAssistantTurns may be 0 even for valid sessions because
139
+ // listRecentNocturnalCandidateSessions (used in fallback path) does not
140
+ // populate assistantTurnCount (only getNocturnalSessionSnapshot does).
141
+ // We use totalToolCalls=0 as the primary indicator instead.
142
+ if (stats && dataSource === 'pain_context_fallback' &&
143
+ stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 &&
144
+ stats.failureCount === 0) {
145
+ details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
138
146
  }
139
147
  }
140
148
  } catch { /* ignore malformed metadata */ }
@@ -335,7 +343,10 @@ function isSessionAtOrBeforeTriggerTime(
335
343
  return true;
336
344
  }
337
345
 
338
- function buildFallbackNocturnalSnapshot(sleepTask: EvolutionQueueItem): NocturnalSessionSnapshot | null {
346
+ function buildFallbackNocturnalSnapshot(
347
+ sleepTask: EvolutionQueueItem,
348
+ extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null
349
+ ): NocturnalSessionSnapshot | null {
339
350
  const painContext = sleepTask.recentPainContext;
340
351
  if (!painContext) {
341
352
  return null;
@@ -349,6 +360,30 @@ function buildFallbackNocturnalSnapshot(sleepTask: EvolutionQueueItem): Nocturna
349
360
  createdAt: painContext.mostRecent.timestamp,
350
361
  }] : [];
351
362
 
363
+ // #246: Try to extract real session stats from trajectory DB for the pain session.
364
+ // The main path tries getNocturnalSessionSnapshot which returns null when no session
365
+ // exists. Here we attempt a lighter query via listRecentNocturnalCandidateSessions
366
+ // to at least get summary counts for the pain-triggering session.
367
+ let realStats: { totalAssistantTurns: number; totalToolCalls: number; failureCount: number; totalGateBlocks: number } | null = null;
368
+ if (extractor && painContext.mostRecent?.sessionId) {
369
+ try {
370
+ // #246-fix: Use minToolCalls=0 to avoid filtering out sessions with 0 tool calls.
371
+ // The pain-triggering session may have no tool calls but still be worth tracking.
372
+ const summaries = extractor.listRecentNocturnalCandidateSessions({ limit: 300, minToolCalls: 0 });
373
+ const match = summaries.find(s => s.sessionId === painContext.mostRecent!.sessionId);
374
+ if (match) {
375
+ realStats = {
376
+ totalAssistantTurns: match.assistantTurnCount,
377
+ totalToolCalls: match.toolCallCount,
378
+ failureCount: match.failureCount,
379
+ totalGateBlocks: match.gateBlockCount,
380
+ };
381
+ }
382
+ } catch {
383
+ // Best effort — non-fatal
384
+ }
385
+ }
386
+
352
387
  return {
353
388
  sessionId: painContext.mostRecent?.sessionId || sleepTask.id,
354
389
  startedAt: sleepTask.timestamp,
@@ -359,11 +394,11 @@ function buildFallbackNocturnalSnapshot(sleepTask: EvolutionQueueItem): Nocturna
359
394
  painEvents: fallbackPainEvents,
360
395
  gateBlocks: [],
361
396
  stats: {
362
- totalAssistantTurns: null,
363
- totalToolCalls: null,
364
- failureCount: null,
397
+ totalAssistantTurns: realStats?.totalAssistantTurns ?? 0,
398
+ totalToolCalls: realStats?.totalToolCalls ?? 0,
399
+ failureCount: realStats?.failureCount ?? 0,
365
400
  totalPainEvents: painContext.recentPainCount,
366
- totalGateBlocks: null,
401
+ totalGateBlocks: realStats?.totalGateBlocks ?? 0,
367
402
  },
368
403
  _dataSource: 'pain_context_fallback',
369
404
  };
@@ -1466,8 +1501,9 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1466
1501
  } else {
1467
1502
  // Phase 1: Build trajectory snapshot for Nocturnal pipeline
1468
1503
  // Priority: Pain signal sessionId → Task ID → Recent session with violations
1504
+ let extractor: ReturnType<typeof createNocturnalTrajectoryExtractor> | null = null;
1469
1505
  try {
1470
- const extractor = createNocturnalTrajectoryExtractor(wctx.workspaceDir);
1506
+ extractor = createNocturnalTrajectoryExtractor(wctx.workspaceDir);
1471
1507
 
1472
1508
  // 1. Try exact session ID from pain signal (most accurate)
1473
1509
  const painSessionId = sleepTask.recentPainContext?.mostRecent?.sessionId;
@@ -1516,8 +1552,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1516
1552
 
1517
1553
  // Phase 2: If no trajectory data, try pain-context fallback
1518
1554
  if (!snapshotData && sleepTask.recentPainContext) {
1519
- logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory stats unavailable (stats will be null)`);
1520
- snapshotData = buildFallbackNocturnalSnapshot(sleepTask) ?? undefined;
1555
+ logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory snapshot unavailable, will try session summary from extractor`);
1556
+ snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor) ?? undefined;
1521
1557
  }
1522
1558
 
1523
1559
  const snapshotValidation = validateNocturnalSnapshotIngress(snapshotData);
@@ -54,10 +54,35 @@ describe('validateNocturnalSnapshotIngress', () => {
54
54
  toolCalls: [],
55
55
  painEvents: [],
56
56
  gateBlocks: [],
57
+ stats: {
58
+ totalAssistantTurns: 0,
59
+ totalToolCalls: 0,
60
+ totalPainEvents: 0,
61
+ totalGateBlocks: 0,
62
+ failureCount: 0,
63
+ },
64
+ _dataSource: 'pain_context_fallback',
65
+ });
66
+
67
+ expect(result.status).toBe('invalid');
68
+ expect(result.reasons).toContain('fallback snapshot must contain at least one pain signal');
69
+ });
70
+
71
+ // #246: null stats fields should now be rejected (they used to be accepted for fallback)
72
+ it('rejects null values in stats fields', () => {
73
+ const result = validateNocturnalSnapshotIngress({
74
+ sessionId: 'session-1',
75
+ startedAt: '2026-04-10T00:00:00.000Z',
76
+ updatedAt: '2026-04-10T00:00:00.000Z',
77
+ assistantTurns: [],
78
+ userTurns: [],
79
+ toolCalls: [],
80
+ painEvents: [{ source: 'test', score: 5, severity: 'high', reason: 'test', createdAt: '2026-04-10T00:00:00.000Z' }],
81
+ gateBlocks: [],
57
82
  stats: {
58
83
  totalAssistantTurns: null,
59
84
  totalToolCalls: null,
60
- totalPainEvents: 0,
85
+ totalPainEvents: 1,
61
86
  totalGateBlocks: null,
62
87
  failureCount: null,
63
88
  },
@@ -65,6 +90,32 @@ describe('validateNocturnalSnapshotIngress', () => {
65
90
  });
66
91
 
67
92
  expect(result.status).toBe('invalid');
68
- expect(result.reasons).toContain('fallback snapshot must contain at least one pain signal');
93
+ expect(result.reasons).toContain('snapshot.stats.totalAssistantTurns must be a finite number');
94
+ expect(result.reasons).toContain('snapshot.stats.totalToolCalls must be a finite number');
95
+ expect(result.reasons).toContain('snapshot.stats.totalGateBlocks must be a finite number');
96
+ expect(result.reasons).toContain('snapshot.stats.failureCount must be a finite number');
97
+ });
98
+
99
+ it('accepts fallback snapshot with valid stats and pain signal', () => {
100
+ const result = validateNocturnalSnapshotIngress({
101
+ sessionId: 'session-1',
102
+ startedAt: '2026-04-10T00:00:00.000Z',
103
+ updatedAt: '2026-04-10T00:00:00.000Z',
104
+ assistantTurns: [],
105
+ userTurns: [],
106
+ toolCalls: [],
107
+ painEvents: [{ source: 'test', score: 5, severity: 'high', reason: 'test', createdAt: '2026-04-10T00:00:00.000Z' }],
108
+ gateBlocks: [],
109
+ stats: {
110
+ totalAssistantTurns: 0,
111
+ totalToolCalls: 0,
112
+ totalPainEvents: 1,
113
+ totalGateBlocks: 0,
114
+ failureCount: 0,
115
+ },
116
+ _dataSource: 'pain_context_fallback',
117
+ });
118
+
119
+ expect(result.status).toBe('valid');
69
120
  });
70
121
  });