@principles/core 1.131.0 → 1.132.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.
@@ -0,0 +1,733 @@
1
+ /**
2
+ * EvidenceChainContract — shared core contracts and assembly mapper for
3
+ * the Pain Evidence Chain (PRI-385).
4
+ *
5
+ * This file is purely logical (no fs, db, or network dependencies) and
6
+ * lives inside principles-core to be shared by Console read models,
7
+ * CLI commands, and test fixtures.
8
+ */
9
+ import { Type } from '@sinclair/typebox';
10
+ export const EvidenceChainStateSchema = Type.Union([
11
+ Type.Literal('recorded-only'),
12
+ Type.Literal('evidence-only'),
13
+ Type.Literal('diagnosis-queued'),
14
+ Type.Literal('diagnosis-running'),
15
+ Type.Literal('diagnosis-succeeded'),
16
+ Type.Literal('diagnosis-failed'),
17
+ Type.Literal('diagnosis-retry-wait'),
18
+ Type.Literal('candidate-generated'),
19
+ Type.Literal('internalization-missing'),
20
+ Type.Literal('internalization-pending'),
21
+ Type.Literal('internalization-running'),
22
+ Type.Literal('internalization-failed'),
23
+ Type.Literal('internalization-succeeded'),
24
+ Type.Literal('owner-reviewable'),
25
+ Type.Literal('malformed'),
26
+ Type.Literal('degraded'),
27
+ ]);
28
+ export const EvidenceChainRecordSchema = Type.Object({
29
+ id: Type.String(),
30
+ sourceKind: Type.String(),
31
+ observedAt: Type.String(),
32
+ state: EvidenceChainStateSchema,
33
+ summary: Type.String(),
34
+ admissionDecision: Type.Optional(Type.String()),
35
+ linkedPainId: Type.Optional(Type.String()),
36
+ linkedTaskId: Type.Optional(Type.String()),
37
+ linkedTaskStatus: Type.Optional(Type.String()),
38
+ linkedCandidateId: Type.Optional(Type.String()),
39
+ linkedPrincipleId: Type.Optional(Type.String()),
40
+ failureReason: Type.Optional(Type.String()),
41
+ degradedReason: Type.Optional(Type.String()),
42
+ nextAction: Type.Optional(Type.String()),
43
+ candidateTitle: Type.Optional(Type.String()),
44
+ candidateSummary: Type.Optional(Type.String()),
45
+ rootCauseSummary: Type.Optional(Type.String()),
46
+ confidence: Type.Optional(Type.Number()),
47
+ recommendationKind: Type.Optional(Type.String()),
48
+ internalizationTaskId: Type.Optional(Type.String()),
49
+ dreamerTaskStatus: Type.Optional(Type.String()),
50
+ });
51
+ export const EvidenceChainResponseSchema = Type.Object({
52
+ records: Type.Array(EvidenceChainRecordSchema),
53
+ generatedAt: Type.String(),
54
+ degradedReason: Type.Optional(Type.String()),
55
+ nextAction: Type.Optional(Type.String()),
56
+ note: Type.Optional(Type.String()),
57
+ });
58
+ // ── Internal Helpers & Type Guards ───────────────────────────────────────────
59
+ function isRecord(value) {
60
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
61
+ }
62
+ function isString(value) {
63
+ return typeof value === 'string';
64
+ }
65
+ function getOwnValue(record, key) {
66
+ return Object.hasOwn(record, key) ? record[key] : undefined;
67
+ }
68
+ function readOwnString(record, key) {
69
+ const value = getOwnValue(record, key);
70
+ return isString(value) ? value : undefined;
71
+ }
72
+ function _readOwnStringArray(record, key) {
73
+ const value = getOwnValue(record, key);
74
+ if (!Array.isArray(value))
75
+ return [];
76
+ return value.filter(isString);
77
+ }
78
+ function coerceToString(value) {
79
+ if (typeof value === 'string')
80
+ return value;
81
+ if (typeof value === 'number' && Number.isFinite(value))
82
+ return String(value);
83
+ return '';
84
+ }
85
+ // ── Mapping & State Resolution ────────────────────────────────────────────────
86
+ export function mapSourceKind(source) {
87
+ switch (source) {
88
+ case 'manual':
89
+ case 'owner_explicit_cli':
90
+ case 'owner_explicit_chat':
91
+ return 'manual';
92
+ case 'tool_call':
93
+ case 'hook':
94
+ return 'tool_call';
95
+ case 'rulehost':
96
+ case 'gate':
97
+ return 'rulehost';
98
+ case 'empathy':
99
+ case 'empathy_inferred':
100
+ return 'empathy_inferred';
101
+ case 'review':
102
+ case 'review_finding':
103
+ return 'review';
104
+ default:
105
+ return source || 'unknown';
106
+ }
107
+ }
108
+ export function inferAdmissionDecision(sourceKind) {
109
+ switch (sourceKind) {
110
+ case 'manual':
111
+ case 'review':
112
+ return 'store_signal';
113
+ case 'empathy_inferred':
114
+ return 'owner_confirmation_required';
115
+ default:
116
+ return 'evidence_only';
117
+ }
118
+ }
119
+ export function determineState(params) {
120
+ const { sourceKind, taskStatus, hasCandidate, dreamerStatus, ledgerPrincipleStatus } = params;
121
+ if (ledgerPrincipleStatus === 'active' || ledgerPrincipleStatus === 'probation') {
122
+ return 'internalization-succeeded';
123
+ }
124
+ if (ledgerPrincipleStatus === 'candidate') {
125
+ return 'owner-reviewable';
126
+ }
127
+ if (hasCandidate) {
128
+ if (!dreamerStatus) {
129
+ return 'internalization-missing';
130
+ }
131
+ switch (dreamerStatus) {
132
+ case 'pending':
133
+ case 'queued':
134
+ return 'internalization-pending';
135
+ case 'running':
136
+ return 'internalization-running';
137
+ case 'failed':
138
+ return 'internalization-failed';
139
+ case 'succeeded':
140
+ return 'internalization-succeeded';
141
+ default:
142
+ return 'candidate-generated';
143
+ }
144
+ }
145
+ if (taskStatus) {
146
+ switch (taskStatus) {
147
+ case 'pending':
148
+ case 'queued':
149
+ return 'diagnosis-queued';
150
+ case 'running':
151
+ return 'diagnosis-running';
152
+ case 'succeeded':
153
+ return 'diagnosis-succeeded';
154
+ case 'failed':
155
+ case 'needs_human_review':
156
+ return 'diagnosis-failed';
157
+ case 'retry_wait':
158
+ return 'diagnosis-retry-wait';
159
+ default:
160
+ return 'diagnosis-queued';
161
+ }
162
+ }
163
+ const admission = inferAdmissionDecision(sourceKind);
164
+ if (admission === 'evidence_only') {
165
+ // Observed evidence that has NOT entered the governance chain (tool_call / hook /
166
+ // rulehost observations). Kept distinct from `recorded-only` so owners do not
167
+ // mistake passive observations for active governance. (PRI-385 P1-2)
168
+ return 'evidence-only';
169
+ }
170
+ // store_signal (manual/review) and owner_confirmation_required (empathy_inferred)
171
+ // are owner-admitted signals awaiting the pipeline → active chain.
172
+ return 'recorded-only';
173
+ }
174
+ /**
175
+ * Derive the raw painId accepted by `pd pain retry --pain-id` from a diagnostician
176
+ * task_id. Convention (PainToPrincipleService): `task_id = diagnosis_${painId}`, so the
177
+ * painId is `task_id` with a single leading `diagnosis_` stripped — and it must round-trip
178
+ * (`diagnosis_` + painId === original linkedTaskId).
179
+ *
180
+ * Only the canonical `diagnosis_<painId>` form is safe: sub-run ids like
181
+ * `diag_router-diagnosis_*` do NOT start with `diagnosis_`, so they fall through to the
182
+ * `pd diagnose run --task-id` fallback. The record display id (`pain_*` / `manual_*`) is
183
+ * intentionally NOT used here — it does not satisfy the `diagnosis_${painId}` convention.
184
+ * (ERR-008: lineage/painId must come from one consistent source = the real task_id.)
185
+ */
186
+ function deriveRetryPainId(linkedTaskId) {
187
+ if (!linkedTaskId || !linkedTaskId.startsWith('diagnosis_'))
188
+ return undefined;
189
+ const painId = linkedTaskId.slice('diagnosis_'.length);
190
+ // Reject empty remainder or a remainder that still carries the prefix (ambiguous).
191
+ if (!painId || painId.startsWith('diagnosis_'))
192
+ return undefined;
193
+ return painId;
194
+ }
195
+ /**
196
+ * Build an executable recovery command for failed/retry-wait diagnosis.
197
+ *
198
+ * Priority (PRI-385 P1-1):
199
+ * 1. Safe raw painId → `pd pain retry --pain-id <painId> --workspace "<ws>" --runtime <kind>`
200
+ * 2. Any linkedTaskId → `pd diagnose run --task-id <linkedTaskId> --workspace "<ws>" --runtime <kind>`
201
+ * 3. Neither → returns undefined (caller emits a reason-style nextAction instead).
202
+ *
203
+ * `--workspace` is always explicit (per PRI-385 owner note: recovery commands must
204
+ * preserve workspace identity). `--runtime <kind>` is shown as a required placeholder
205
+ * because `pd pain retry` / `pd diagnose run` refuse without an explicit runtime.
206
+ */
207
+ function buildRetryCommand(linkedTaskId, ws) {
208
+ const painId = deriveRetryPainId(linkedTaskId);
209
+ if (painId) {
210
+ return `pd pain retry --pain-id ${painId} --workspace "${ws}" --runtime <kind>`;
211
+ }
212
+ if (linkedTaskId) {
213
+ return `pd diagnose run --task-id ${linkedTaskId} --workspace "${ws}" --runtime <kind>`;
214
+ }
215
+ return undefined;
216
+ }
217
+ export function determineNextAction(ctx) {
218
+ const { state, workspaceDir: ws, linkedTaskId } = ctx;
219
+ switch (state) {
220
+ case 'diagnosis-retry-wait': {
221
+ const cmd = buildRetryCommand(linkedTaskId, ws);
222
+ return cmd
223
+ ? `Diagnosis is waiting for automatic retry. Force retry: ${cmd}`
224
+ : 'Diagnosis is waiting for automatic retry, but no retryable task id is linked. Check Runtime V2 pipeline status.';
225
+ }
226
+ case 'diagnosis-failed': {
227
+ const cmd = buildRetryCommand(linkedTaskId, ws);
228
+ return cmd
229
+ ? `Diagnosis failed. Retry: ${cmd}`
230
+ : 'Diagnosis failed, but no retryable task id is linked. Check the failure details and Runtime V2 pipeline status.';
231
+ }
232
+ case 'diagnosis-succeeded':
233
+ return 'Diagnosis completed. A principle candidate may be generated shortly.';
234
+ case 'candidate-generated':
235
+ case 'internalization-missing':
236
+ return `Candidate generated. Waiting for internalization pipeline. Run: pd runtime internalization run-once --workspace "${ws}"`;
237
+ case 'internalization-pending':
238
+ return 'Candidate generated. Internalization task is pending — wait for dreamer to complete.';
239
+ case 'internalization-running':
240
+ return 'Internalization in progress — dreamer task is running.';
241
+ case 'internalization-failed':
242
+ return `Internalization task failed. Check dreamer task error or run: pd runtime internalization run-once --workspace "${ws}" to retry.`;
243
+ case 'owner-reviewable':
244
+ return 'Principle candidate is ready for owner review.';
245
+ case 'internalization-succeeded':
246
+ return 'Internalization task completed. Check for owner-reviewable principle.';
247
+ default:
248
+ return undefined;
249
+ }
250
+ }
251
+ export function resolveSummary(fields) {
252
+ if (fields.candidateTitle)
253
+ return fields.candidateTitle;
254
+ if (fields.rootCauseSummary)
255
+ return fields.rootCauseSummary;
256
+ if (fields.painText)
257
+ return fields.painText;
258
+ if (fields.painReason)
259
+ return fields.painReason;
260
+ return fields.fallback;
261
+ }
262
+ export function normalizeDiagnosticianTaskId(taskId) {
263
+ if (!taskId) {
264
+ return {
265
+ success: false,
266
+ reason: 'Diagnostician task ID is empty or missing.',
267
+ nextAction: 'Verify that the principle candidates table contains valid task IDs.',
268
+ };
269
+ }
270
+ if (!taskId.includes('diagnosis_')) {
271
+ return { success: true, normalized: taskId };
272
+ }
273
+ if (taskId.startsWith('diagnosis_')) {
274
+ return { success: true, normalized: taskId };
275
+ }
276
+ const idx = taskId.indexOf('diagnosis_');
277
+ if (idx > 0 && taskId[idx - 1] === '-') {
278
+ const normalized = taskId.slice(idx);
279
+ return { success: true, normalized };
280
+ }
281
+ return {
282
+ success: false,
283
+ reason: `Malformed diagnostician task ID: "${taskId}". Unable to normalize to canonical "diagnosis_*" format.`,
284
+ nextAction: 'Ensure task IDs follow the format "diagnosis_*" or "<stage>-diagnosis_*".',
285
+ };
286
+ }
287
+ export function crossReferenceByTimestamp(painEvents, taskMap, coveredPainIds) {
288
+ const CROSS_REF_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
289
+ const result = new Map();
290
+ const usedTaskIds = new Set();
291
+ const unmatchedEvents = painEvents.filter(e => !coveredPainIds.has(e.painId));
292
+ if (unmatchedEvents.length === 0)
293
+ return result;
294
+ const availableTasks = [];
295
+ for (const [key, entry] of taskMap.entries()) {
296
+ if (!coveredPainIds.has(key)) {
297
+ availableTasks.push([key, entry]);
298
+ }
299
+ }
300
+ unmatchedEvents.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
301
+ for (const event of unmatchedEvents) {
302
+ const eventTime = new Date(event.createdAt).getTime();
303
+ if (Number.isNaN(eventTime))
304
+ continue;
305
+ let bestMatch = null;
306
+ let bestDelta = Infinity;
307
+ for (const [taskKey, taskEntry] of availableTasks) {
308
+ if (usedTaskIds.has(taskKey))
309
+ continue;
310
+ const taskTime = new Date(taskEntry.createdAt).getTime();
311
+ if (Number.isNaN(taskTime))
312
+ continue;
313
+ const delta = Math.abs(eventTime - taskTime);
314
+ if (delta <= CROSS_REF_WINDOW_MS && delta < bestDelta) {
315
+ bestDelta = delta;
316
+ bestMatch = [taskKey, taskEntry];
317
+ }
318
+ }
319
+ if (bestMatch) {
320
+ usedTaskIds.add(bestMatch[0]);
321
+ result.set(event.painId, bestMatch[1]);
322
+ }
323
+ }
324
+ return result;
325
+ }
326
+ export function assembleEvidenceChain(params) {
327
+ const { workspaceDir, painEvents: rawPainEvents, tasks: rawTasks, candidates: rawCandidates, dreamerTasks: rawDreamerTasks, ledgerPrinciples, trajectoryDbAvailable, stateDbAvailable, } = params;
328
+ const generatedAt = new Date().toISOString();
329
+ const records = [];
330
+ const degradedReasons = params.degradedReasons ? [...params.degradedReasons] : [];
331
+ const degradedNextActions = params.degradedNextActions ? [...params.degradedNextActions] : [];
332
+ // Coerce untrusted arrays using element-wise guards (ERR-001/005/007)
333
+ const painEvents = rawPainEvents.filter(isRecord);
334
+ const tasks = rawTasks.filter(isRecord);
335
+ const candidates = rawCandidates.filter(isRecord);
336
+ const dreamerTasks = rawDreamerTasks.filter(isRecord);
337
+ // 1. Process tasks into a map
338
+ const taskMap = new Map();
339
+ for (const task of tasks) {
340
+ const taskId = readOwnString(task, 'task_id') ?? '';
341
+ const inputRefRaw = getOwnValue(task, 'input_ref');
342
+ const inputRef = isString(inputRefRaw) ? inputRefRaw : undefined;
343
+ let painId;
344
+ if (inputRef) {
345
+ painId = /^\d+$/.test(inputRef) ? `pain_${inputRef}` : inputRef;
346
+ }
347
+ else {
348
+ painId = taskId.startsWith('diagnosis_') ? taskId.slice('diagnosis_'.length) : taskId;
349
+ }
350
+ let rootCauseSummary;
351
+ let diagnosticJsonDegraded = false;
352
+ const dj = getOwnValue(task, 'diagnostic_json');
353
+ if (isString(dj)) {
354
+ try {
355
+ const parsed = JSON.parse(dj);
356
+ if (isRecord(parsed) && Object.hasOwn(parsed, 'rootCause') && isString(parsed.rootCause)) {
357
+ rootCauseSummary = parsed.rootCause;
358
+ }
359
+ }
360
+ catch {
361
+ diagnosticJsonDegraded = true;
362
+ }
363
+ }
364
+ taskMap.set(painId, {
365
+ taskId,
366
+ status: readOwnString(task, 'status') ?? 'unknown',
367
+ lastError: readOwnString(task, 'last_error') ?? null,
368
+ createdAt: readOwnString(task, 'created_at') ?? '',
369
+ rootCauseSummary,
370
+ diagnosticJsonDegraded,
371
+ inputRef,
372
+ });
373
+ }
374
+ // 2. Build dreamer map
375
+ const dreamerMap = new Map();
376
+ for (const task of dreamerTasks) {
377
+ const taskId = readOwnString(task, 'task_id') ?? '';
378
+ const status = readOwnString(task, 'status') ?? 'unknown';
379
+ if (taskId.startsWith('dreamer-')) {
380
+ const rest = taskId.slice('dreamer-'.length);
381
+ const lastDash = rest.lastIndexOf('-');
382
+ if (lastDash > 0) {
383
+ const candidateId = rest.slice(0, lastDash);
384
+ if (candidateId && !dreamerMap.has(candidateId)) {
385
+ dreamerMap.set(candidateId, { taskId, status });
386
+ }
387
+ }
388
+ }
389
+ }
390
+ // 3. Build candidates map and reverse lookups
391
+ const taskIdToPainId = new Map();
392
+ for (const [pId, entry] of taskMap) {
393
+ taskIdToPainId.set(entry.taskId, pId);
394
+ const normResult = normalizeDiagnosticianTaskId(entry.taskId);
395
+ if (normResult.success && normResult.normalized) {
396
+ taskIdToPainId.set(normResult.normalized, pId);
397
+ }
398
+ }
399
+ const candidateMap = new Map();
400
+ const candidateByTaskId = new Map();
401
+ for (const c of candidates) {
402
+ const candidateId = readOwnString(c, 'candidate_id') ?? '';
403
+ if (!candidateId)
404
+ continue;
405
+ const rawTaskId = readOwnString(c, 'task_id') ?? '';
406
+ const normResult = normalizeDiagnosticianTaskId(rawTaskId);
407
+ if (!normResult.success || !normResult.normalized) {
408
+ if (normResult.reason)
409
+ degradedReasons.push(normResult.reason);
410
+ if (normResult.nextAction)
411
+ degradedNextActions.push(normResult.nextAction);
412
+ continue;
413
+ }
414
+ const taskId = normResult.normalized;
415
+ let painId = taskIdToPainId.get(taskId) ?? '';
416
+ if (!painId && taskId.startsWith('diagnosis_')) {
417
+ painId = taskId.slice('diagnosis_'.length);
418
+ }
419
+ const info = {
420
+ candidateId,
421
+ title: readOwnString(c, 'title'),
422
+ description: readOwnString(c, 'description'),
423
+ confidence: typeof c.confidence === 'number' && Number.isFinite(c.confidence) ? c.confidence : undefined,
424
+ recommendationKind: readOwnString(c, 'recommendation_kind'),
425
+ };
426
+ if (painId)
427
+ candidateMap.set(painId, info);
428
+ if (taskId)
429
+ candidateByTaskId.set(taskId, info);
430
+ }
431
+ // 4. Map ledger principles to painId
432
+ const painToPrincipleMap = new Map();
433
+ const principleStatusMap = new Map();
434
+ for (const p of ledgerPrinciples) {
435
+ if (p.status) {
436
+ principleStatusMap.set(p.id, p.status);
437
+ }
438
+ for (const pId of p.derivedFromPainIds ?? []) {
439
+ painToPrincipleMap.set(pId, p.id);
440
+ }
441
+ }
442
+ // 5. Assemble records
443
+ const painEventMeta = [];
444
+ const directMatchedPainIds = new Set();
445
+ for (const event of painEvents) {
446
+ const eventId = coerceToString(event.id);
447
+ if (!eventId)
448
+ continue;
449
+ const source = readOwnString(event, 'source') ?? 'unknown';
450
+ const reason = readOwnString(event, 'reason') ?? '';
451
+ const text = readOwnString(event, 'text') ?? '';
452
+ const createdAt = readOwnString(event, 'created_at') ?? '';
453
+ const score = typeof event.score === 'number' ? event.score : 0;
454
+ const sourceKind = mapSourceKind(source);
455
+ const painId = `pain_${eventId}`;
456
+ painEventMeta.push({ painId, createdAt, source });
457
+ const linkedTask = taskMap.get(painId);
458
+ if (linkedTask) {
459
+ directMatchedPainIds.add(painId);
460
+ }
461
+ const linkedCandidate = candidateMap.get(painId);
462
+ const linkedPrincipleId = painToPrincipleMap.get(painId);
463
+ const ledgerPrincipleStatus = linkedPrincipleId ? principleStatusMap.get(linkedPrincipleId) : undefined;
464
+ const dreamerInfo = linkedCandidate ? dreamerMap.get(linkedCandidate.candidateId) : undefined;
465
+ const state = determineState({
466
+ sourceKind,
467
+ taskStatus: linkedTask?.status,
468
+ hasCandidate: !!linkedCandidate,
469
+ dreamerStatus: dreamerInfo?.status,
470
+ ledgerPrincipleStatus,
471
+ });
472
+ const rawSummary = resolveSummary({
473
+ candidateTitle: linkedCandidate?.title,
474
+ rootCauseSummary: linkedTask?.rootCauseSummary,
475
+ painText: text,
476
+ painReason: reason,
477
+ fallback: `Pain signal (source: ${source}, score: ${score})`,
478
+ });
479
+ const record = {
480
+ id: painId,
481
+ sourceKind,
482
+ observedAt: createdAt,
483
+ state,
484
+ summary: rawSummary,
485
+ admissionDecision: inferAdmissionDecision(sourceKind),
486
+ };
487
+ if (linkedCandidate) {
488
+ record.linkedCandidateId = linkedCandidate.candidateId;
489
+ if (linkedCandidate.title)
490
+ record.candidateTitle = linkedCandidate.title;
491
+ if (linkedCandidate.description)
492
+ record.candidateSummary = linkedCandidate.description;
493
+ if (linkedCandidate.confidence !== undefined)
494
+ record.confidence = linkedCandidate.confidence;
495
+ if (linkedCandidate.recommendationKind)
496
+ record.recommendationKind = linkedCandidate.recommendationKind;
497
+ }
498
+ if (linkedTask) {
499
+ record.linkedTaskId = linkedTask.taskId;
500
+ record.linkedTaskStatus = linkedTask.status;
501
+ if (linkedTask.rootCauseSummary) {
502
+ record.rootCauseSummary = linkedTask.rootCauseSummary;
503
+ }
504
+ if (linkedTask.lastError && (state === 'diagnosis-failed' || state === 'diagnosis-retry-wait')) {
505
+ record.failureReason = linkedTask.lastError;
506
+ }
507
+ if (linkedTask.diagnosticJsonDegraded) {
508
+ record.degradedReason = 'Diagnostic data for this record could not be parsed';
509
+ }
510
+ }
511
+ if (dreamerInfo) {
512
+ record.internalizationTaskId = dreamerInfo.taskId;
513
+ record.dreamerTaskStatus = dreamerInfo.status;
514
+ }
515
+ if (linkedPrincipleId) {
516
+ record.linkedPrincipleId = linkedPrincipleId;
517
+ }
518
+ // Generate nextAction from state
519
+ record.nextAction = determineNextAction({ state, workspaceDir, recordId: painId, linkedTaskId: linkedTask?.taskId });
520
+ if (!linkedTask && stateDbAvailable && taskMap.size > 0) {
521
+ continue;
522
+ }
523
+ records.push(record);
524
+ }
525
+ // 5b. Proximity Cross-Referencing for Unmatched Pain Events
526
+ const crossRefMap = crossReferenceByTimestamp(painEventMeta, taskMap, directMatchedPainIds);
527
+ for (const event of painEvents) {
528
+ const eventId = coerceToString(event.id);
529
+ if (!eventId)
530
+ continue;
531
+ const painId = `pain_${eventId}`;
532
+ if (directMatchedPainIds.has(painId))
533
+ continue;
534
+ const crossRefTask = crossRefMap.get(painId);
535
+ if (!crossRefTask) {
536
+ if (stateDbAvailable && taskMap.size > 0) {
537
+ const source = readOwnString(event, 'source') ?? 'unknown';
538
+ const reason = readOwnString(event, 'reason') ?? '';
539
+ const text = readOwnString(event, 'text') ?? '';
540
+ const createdAt = readOwnString(event, 'created_at') ?? '';
541
+ const score = typeof event.score === 'number' ? event.score : 0;
542
+ const sourceKind = mapSourceKind(source);
543
+ // No linked task/candidate: state is admission-driven. tool_call/hook observations
544
+ // are `evidence-only`; manual/review are `recorded-only`. (PRI-385 P1-2)
545
+ const unmatchedState = determineState({ sourceKind, hasCandidate: false });
546
+ const record = {
547
+ id: painId,
548
+ sourceKind,
549
+ observedAt: createdAt,
550
+ state: unmatchedState,
551
+ summary: text || reason || `Pain signal (source: ${source}, score: ${score})`,
552
+ admissionDecision: inferAdmissionDecision(sourceKind),
553
+ degradedReason: 'Could not link this pain event to a diagnostician task. The chain may be incomplete.',
554
+ nextAction: `Check Runtime V2 pipeline status. The diagnostician task may have a different pain ID format.`,
555
+ };
556
+ degradedReasons.push(`Pain event ${painId} could not be linked to a diagnostician task.`);
557
+ degradedNextActions.push('Check Runtime V2 pipeline status for unmatched pain ID formats.');
558
+ records.push(record);
559
+ }
560
+ continue;
561
+ }
562
+ const source = readOwnString(event, 'source') ?? 'unknown';
563
+ const reason = readOwnString(event, 'reason') ?? '';
564
+ const text = readOwnString(event, 'text') ?? '';
565
+ const createdAt = readOwnString(event, 'created_at') ?? '';
566
+ const score = typeof event.score === 'number' ? event.score : 0;
567
+ const sourceKind = mapSourceKind(source);
568
+ const linkedCandidate = candidateMap.get(painId) || candidateByTaskId.get(crossRefTask.taskId);
569
+ const linkedPrincipleId = painToPrincipleMap.get(painId);
570
+ const ledgerPrincipleStatus = linkedPrincipleId ? principleStatusMap.get(linkedPrincipleId) : undefined;
571
+ const dreamerInfo = linkedCandidate ? dreamerMap.get(linkedCandidate.candidateId) : undefined;
572
+ const state = determineState({
573
+ sourceKind,
574
+ taskStatus: crossRefTask.status,
575
+ hasCandidate: !!linkedCandidate,
576
+ dreamerStatus: dreamerInfo?.status,
577
+ ledgerPrincipleStatus,
578
+ });
579
+ const rawSummary = resolveSummary({
580
+ candidateTitle: linkedCandidate?.title,
581
+ rootCauseSummary: crossRefTask.rootCauseSummary,
582
+ painText: text,
583
+ painReason: reason,
584
+ fallback: `Pain signal (source: ${source}, score: ${score})`,
585
+ });
586
+ const record = {
587
+ id: painId,
588
+ sourceKind,
589
+ observedAt: createdAt,
590
+ state,
591
+ summary: rawSummary,
592
+ admissionDecision: inferAdmissionDecision(sourceKind),
593
+ linkedTaskId: crossRefTask.taskId,
594
+ linkedTaskStatus: crossRefTask.status,
595
+ };
596
+ if (linkedCandidate) {
597
+ record.linkedCandidateId = linkedCandidate.candidateId;
598
+ if (linkedCandidate.title)
599
+ record.candidateTitle = linkedCandidate.title;
600
+ if (linkedCandidate.description)
601
+ record.candidateSummary = linkedCandidate.description;
602
+ if (linkedCandidate.confidence !== undefined)
603
+ record.confidence = linkedCandidate.confidence;
604
+ if (linkedCandidate.recommendationKind)
605
+ record.recommendationKind = linkedCandidate.recommendationKind;
606
+ }
607
+ if (crossRefTask.rootCauseSummary) {
608
+ record.rootCauseSummary = crossRefTask.rootCauseSummary;
609
+ }
610
+ if (crossRefTask.lastError && (state === 'diagnosis-failed' || state === 'diagnosis-retry-wait')) {
611
+ record.failureReason = crossRefTask.lastError;
612
+ }
613
+ if (dreamerInfo) {
614
+ record.internalizationTaskId = dreamerInfo.taskId;
615
+ record.dreamerTaskStatus = dreamerInfo.status;
616
+ }
617
+ if (linkedPrincipleId) {
618
+ record.linkedPrincipleId = linkedPrincipleId;
619
+ }
620
+ record.nextAction = determineNextAction({ state, workspaceDir, recordId: painId, linkedTaskId: crossRefTask.taskId });
621
+ if (crossRefTask.diagnosticJsonDegraded) {
622
+ record.degradedReason = 'Diagnostic data for this record could not be parsed';
623
+ }
624
+ records.push(record);
625
+ }
626
+ // 6. Include tasks that have no matching pain_event (e.g. CLI direct records)
627
+ const coveredPainIds = new Set(records.map(r => r.id));
628
+ const crossRefTaskIds = new Set();
629
+ for (const entry of crossRefMap.values()) {
630
+ crossRefTaskIds.add(entry.taskId);
631
+ }
632
+ for (const [painId, task] of taskMap.entries()) {
633
+ if (coveredPainIds.has(painId))
634
+ continue;
635
+ if (crossRefTaskIds.has(task.taskId))
636
+ continue;
637
+ const linkedCandidate = candidateMap.get(painId);
638
+ const linkedPrincipleId = painToPrincipleMap.get(painId);
639
+ const ledgerPrincipleStatus = linkedPrincipleId ? principleStatusMap.get(linkedPrincipleId) : undefined;
640
+ const dreamerInfo = linkedCandidate ? dreamerMap.get(linkedCandidate.candidateId) : undefined;
641
+ const state = determineState({
642
+ sourceKind: 'manual',
643
+ taskStatus: task.status,
644
+ hasCandidate: !!linkedCandidate,
645
+ dreamerStatus: dreamerInfo?.status,
646
+ ledgerPrincipleStatus,
647
+ });
648
+ const rawSummary = resolveSummary({
649
+ candidateTitle: linkedCandidate?.title,
650
+ rootCauseSummary: task.rootCauseSummary,
651
+ fallback: `Manual pain signal (task: ${task.taskId})`,
652
+ });
653
+ const record = {
654
+ id: painId,
655
+ sourceKind: 'manual',
656
+ observedAt: task.createdAt,
657
+ state,
658
+ summary: rawSummary,
659
+ admissionDecision: 'store_signal',
660
+ linkedTaskId: task.taskId,
661
+ linkedTaskStatus: task.status,
662
+ };
663
+ if (linkedCandidate) {
664
+ record.linkedCandidateId = linkedCandidate.candidateId;
665
+ if (linkedCandidate.title)
666
+ record.candidateTitle = linkedCandidate.title;
667
+ if (linkedCandidate.description)
668
+ record.candidateSummary = linkedCandidate.description;
669
+ if (linkedCandidate.confidence !== undefined)
670
+ record.confidence = linkedCandidate.confidence;
671
+ if (linkedCandidate.recommendationKind)
672
+ record.recommendationKind = linkedCandidate.recommendationKind;
673
+ }
674
+ if (task.rootCauseSummary) {
675
+ record.rootCauseSummary = task.rootCauseSummary;
676
+ }
677
+ if (task.lastError && (state === 'diagnosis-failed' || state === 'diagnosis-retry-wait')) {
678
+ record.failureReason = task.lastError;
679
+ }
680
+ if (dreamerInfo) {
681
+ record.internalizationTaskId = dreamerInfo.taskId;
682
+ record.dreamerTaskStatus = dreamerInfo.status;
683
+ }
684
+ if (linkedPrincipleId) {
685
+ record.linkedPrincipleId = linkedPrincipleId;
686
+ }
687
+ record.nextAction = determineNextAction({ state, workspaceDir, recordId: painId, linkedTaskId: task.taskId });
688
+ if (task.diagnosticJsonDegraded) {
689
+ record.degradedReason = 'Diagnostic data for this record could not be parsed';
690
+ }
691
+ records.push(record);
692
+ }
693
+ // Sort by observedAt descending
694
+ records.sort((a, b) => b.observedAt.localeCompare(a.observedAt));
695
+ const response = {
696
+ records,
697
+ generatedAt,
698
+ };
699
+ // Group unmatched pain event warnings (PRI-382)
700
+ if (degradedReasons.length > 0) {
701
+ const unmatchedPainIds = [];
702
+ const otherReasons = [];
703
+ const unmatchedPainRegex = /^Pain event (.*) could not be linked to a diagnostician task\.$/;
704
+ for (const reason of degradedReasons) {
705
+ const match = unmatchedPainRegex.exec(reason);
706
+ if (match && match[1]) {
707
+ unmatchedPainIds.push(match[1]);
708
+ }
709
+ else {
710
+ otherReasons.push(reason);
711
+ }
712
+ }
713
+ const finalReasons = [];
714
+ if (unmatchedPainIds.length > 0) {
715
+ if (unmatchedPainIds.length === 1) {
716
+ finalReasons.push(`Pain event ${unmatchedPainIds[0]} could not be linked to a diagnostician task.`);
717
+ }
718
+ else {
719
+ finalReasons.push(`${unmatchedPainIds.length} evidence records could not be linked to diagnostician tasks. Showing per-record details below.`);
720
+ }
721
+ }
722
+ const uniqueOtherReasons = Array.from(new Set(otherReasons));
723
+ finalReasons.push(...uniqueOtherReasons);
724
+ response.degradedReason = finalReasons.join('; ');
725
+ const uniqueNextActions = Array.from(new Set(degradedNextActions));
726
+ response.nextAction = uniqueNextActions.join(' ');
727
+ }
728
+ if (records.length === 0 && trajectoryDbAvailable && stateDbAvailable) {
729
+ response.note = 'PD has not captured any displayable behavior evidence in this workspace yet.';
730
+ }
731
+ return response;
732
+ }
733
+ //# sourceMappingURL=evidence-chain-contract.js.map