principles-disciple 1.19.0 → 1.21.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.21.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.21.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -126,7 +126,11 @@ function _handleArchiveImpl(
126
126
  updateImplementation(stateDir, implId, {
127
127
  archivedAt: new Date().toISOString(),
128
128
  });
129
- refreshPrincipleLifecycle(workspaceDir, stateDir);
129
+ try {
130
+ refreshPrincipleLifecycle(workspaceDir, stateDir);
131
+ } catch (err) {
132
+ console.warn('[archive-impl] Lifecycle refresh failed:', err instanceof Error ? err.stack : err);
133
+ }
130
134
 
131
135
  return {
132
136
  text: isZh
@@ -109,7 +109,11 @@ function _handleDisableImpl(
109
109
  disabledBy: sessionId || 'manual',
110
110
  disabledReason: reasonText,
111
111
  });
112
- refreshPrincipleLifecycle(workspaceDir, stateDir);
112
+ try {
113
+ refreshPrincipleLifecycle(workspaceDir, stateDir);
114
+ } catch (err) {
115
+ console.warn('[disable-impl] Lifecycle refresh failed:', err instanceof Error ? err.stack : err);
116
+ }
113
117
 
114
118
  return {
115
119
  text: isZh
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import * as fs from 'fs';
25
+ import * as path from 'path';
25
26
  import {
26
27
  listDatasetRecords,
27
28
  getDatasetRecord,
@@ -31,6 +32,7 @@ import {
31
32
  type NocturnalDatasetRecord,
32
33
  type NocturnalReviewStatus,
33
34
  } from '../core/nocturnal-dataset.js';
35
+ import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
34
36
  import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
35
37
 
36
38
  function isZh(ctx: PluginCommandContext): boolean {
@@ -194,6 +196,16 @@ export function handleNocturnalReviewCommand(ctx: PluginCommandContext): PluginC
194
196
 
195
197
  const updated = updateReviewStatus(workspaceDir, fingerprint, 'approved_for_training', reason);
196
198
 
199
+ // #251: Sync approvedSampleCount in training store
200
+ try {
201
+ const stateDir = path.join(workspaceDir, '.state');
202
+ const state = getPrincipleState(stateDir, updated.principleId);
203
+ state.approvedSampleCount += 1;
204
+ setPrincipleState(stateDir, state);
205
+ } catch (err) {
206
+ console.warn(`[nocturnal-review] Failed to sync approvedSampleCount for ${updated.principleId}:`, err instanceof Error ? err.stack : err);
207
+ }
208
+
197
209
  return {
198
210
  text: zh
199
211
  ? `样本已批准用于训练:\n fingerprint: ${updated.sampleFingerprint.substring(0, 16)}...\n principleId: ${updated.principleId}\n targetModelFamily: ${updated.targetModelFamily ?? '(none)'}\n reason: ${updated.reviewReason}`
@@ -172,7 +172,11 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
172
172
  disabledBy: undefined,
173
173
  disabledReason: undefined,
174
174
  });
175
- refreshPrincipleLifecycle(workspaceDir, stateDir);
175
+ try {
176
+ refreshPrincipleLifecycle(workspaceDir, stateDir);
177
+ } catch (err) {
178
+ console.warn('[promote-impl] Lifecycle refresh failed (re-enable):', err instanceof Error ? err.stack : err);
179
+ }
176
180
 
177
181
  output += isZh
178
182
  ? `\n✅ 实现已重新启用: ${implId}\n 状态: disabled -> active`
@@ -225,7 +229,11 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
225
229
  withLock(eventPath, () => {
226
230
  fs.writeFileSync(eventPath, JSON.stringify(promotionEvent, null, 2), 'utf-8');
227
231
  });
228
- refreshPrincipleLifecycle(workspaceDir, stateDir);
232
+ try {
233
+ refreshPrincipleLifecycle(workspaceDir, stateDir);
234
+ } catch (err) {
235
+ console.warn('[promote-impl] Lifecycle refresh failed (promotion):', err instanceof Error ? err.stack : err);
236
+ }
229
237
 
230
238
  output += isZh
231
239
  ? `\n\n✅ 实现已晋升: ${implId}\n 状态: candidate -> active`
@@ -185,7 +185,11 @@ function _handleRollbackImpl(
185
185
  withLock(rollbackPath, () => {
186
186
  fs.writeFileSync(rollbackPath, JSON.stringify(rollbackRecord, null, 2), 'utf-8');
187
187
  });
188
- refreshPrincipleLifecycle(workspaceDir, stateDir);
188
+ try {
189
+ refreshPrincipleLifecycle(workspaceDir, stateDir);
190
+ } catch (err) {
191
+ console.warn('[rollback-impl] Lifecycle refresh failed:', err instanceof Error ? err.stack : err);
192
+ }
189
193
 
190
194
  let output = isZh
191
195
  ? `\n\u2705 \u56de\u6eda\u5b8c\u6210: ${implId}\n \u72b6\u6001: active -> disabled\n \u539f\u56e0: ${reasonText}`
@@ -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.
@@ -121,14 +121,14 @@ export function recommendInternalizationRoute(
121
121
  principle.summary.repeatedErrorSignal === 0;
122
122
 
123
123
  if (principle.rules.length === 0) {
124
- reasonCodes.push('no_material_rules');
124
+ reasonCodes.push('insufficient_data', 'no_material_rules');
125
125
  return {
126
126
  principleId: principle.principle.id,
127
127
  route: 'defer',
128
- confidence: 95,
128
+ confidence: 50,
129
129
  reasonCodes,
130
130
  evidenceSummary,
131
- nextAction: 'Define at least one concrete rule before choosing an internalization route.',
131
+ nextAction: 'No rules defined for this principle. Create at least one rule via pain→principle→rule pipeline before internalization routing can produce meaningful recommendations.',
132
132
  };
133
133
  }
134
134
 
@@ -11,6 +11,8 @@ export interface RuleMetricResult {
11
11
  }
12
12
 
13
13
  export interface PrincipleAdherenceResult {
14
+ /** True when no rules exist — all numeric fields are defaults, not computed values */
15
+ insufficientData?: boolean;
14
16
  adherenceRate: number;
15
17
  averageRuleCoverage: number;
16
18
  averageFalsePositiveRate: number;
@@ -108,6 +110,7 @@ export function computePrincipleAdherence(
108
110
 
109
111
  if (principle.rules.length === 0) {
110
112
  return {
113
+ insufficientData: true,
111
114
  adherenceRate: 0,
112
115
  averageRuleCoverage: 0,
113
116
  averageFalsePositiveRate: 0,
@@ -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);
@@ -98,9 +98,24 @@ import {
98
98
  } from './nocturnal-runtime.js';
99
99
  import { NocturnalPathResolver } from '../core/nocturnal-paths.js';
100
100
  import { registerSample } from '../core/nocturnal-dataset.js';
101
+ import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
101
102
  import type { Implementation } from '../types/principle-tree-schema.js';
102
103
  import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
103
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // #251: Sync trainingStore sample counts after registration
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function incrementGeneratedSampleCount(stateDir: string, principleId: string): void {
110
+ try {
111
+ const state = getPrincipleState(stateDir, principleId);
112
+ state.generatedSampleCount += 1;
113
+ setPrincipleState(stateDir, state);
114
+ } catch (err) {
115
+ console.warn(`[nocturnal-service] Failed to sync generatedSampleCount for ${principleId}:`, err instanceof Error ? err.stack : err);
116
+ }
117
+ }
118
+
104
119
  // ---------------------------------------------------------------------------
105
120
  // Types
106
121
  // ---------------------------------------------------------------------------
@@ -467,7 +482,11 @@ function persistCodeCandidate(
467
482
  implementationId,
468
483
  createdAt: now,
469
484
  });
470
- refreshPrincipleLifecycle(workspaceDir, stateDir);
485
+ try {
486
+ refreshPrincipleLifecycle(workspaceDir, stateDir);
487
+ } catch (err) {
488
+ console.warn('[nocturnal-service] Lifecycle refresh failed after code candidate persistence:', err instanceof Error ? err.stack : err);
489
+ }
471
490
  return {
472
491
  status: 'persisted_candidate',
473
492
  ruleResolution: {
@@ -993,7 +1012,10 @@ export function executeNocturnalReflection(
993
1012
  // Approved artifacts must enter the dataset registry so they can be reviewed
994
1013
  // before export. Without this, new samples never appear in the review queue.
995
1014
  try {
996
- registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
1015
+ const regResult = registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
1016
+ if (regResult.isNew) {
1017
+ incrementGeneratedSampleCount(stateDir, arbiterResult.artifact.principleId);
1018
+ }
997
1019
  } catch (err) {
998
1020
  // Non-fatal: artifact is persisted, registry is secondary.
999
1021
  // Log but don't fail the run.
@@ -1380,7 +1402,10 @@ async function executeNocturnalReflectionWithAdapter(
1380
1402
 
1381
1403
  // Step 8: Register in dataset lineage
1382
1404
  try {
1383
- registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
1405
+ const regResult = registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
1406
+ if (regResult.isNew) {
1407
+ incrementGeneratedSampleCount(stateDir, arbiterResult.artifact.principleId);
1408
+ }
1384
1409
  } catch (err) {
1385
1410
  console.warn(`[nocturnal-service] Failed to register sample in dataset registry: ${String(err)}`);
1386
1411
  }
@@ -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
  });