pi-background-tasks 0.9.0 → 1.0.3

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 (72) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
  2. package/PUBLISHING.md +43 -29
  3. package/README.md +233 -441
  4. package/TESTING.md +15 -9
  5. package/TEST_PLAN.md +43 -17
  6. package/docs/INDEX.md +157 -0
  7. package/docs/api/eventbus-v1.md +166 -0
  8. package/docs/assets/architecture.svg +78 -0
  9. package/docs/assets/footer-dock.svg +47 -0
  10. package/docs/assets/logo.svg +49 -0
  11. package/docs/attestations.json +189 -0
  12. package/docs/choose-a-workflow.md +98 -0
  13. package/docs/commands/bg-clear.md +70 -0
  14. package/docs/commands/bg-update.md +82 -0
  15. package/docs/commands/bg.md +90 -0
  16. package/docs/commands/fusion-models.md +70 -0
  17. package/docs/commands/fusion.md +69 -0
  18. package/docs/commands/jobs.md +74 -0
  19. package/docs/commands/kill.md +82 -0
  20. package/docs/commands/logs.md +90 -0
  21. package/docs/commands/task-manager.md +109 -0
  22. package/docs/concepts/completion-delivery.md +66 -0
  23. package/docs/concepts/context-projection-and-budgeting.md +79 -0
  24. package/docs/getting-started.md +122 -0
  25. package/docs/manifest.json +1825 -0
  26. package/docs/operations/configuration.md +110 -0
  27. package/docs/operations/releasing.md +67 -0
  28. package/docs/operations/testing.md +101 -0
  29. package/docs/operations/troubleshooting.md +38 -0
  30. package/docs/read-before-edit.md +94 -0
  31. package/docs/reference/runtime-contracts.md +213 -0
  32. package/docs/reference/shortcuts-and-dock.md +70 -0
  33. package/docs/subsystems/attested-pi-runs.md +141 -0
  34. package/docs/subsystems/background-task-runtime.md +85 -0
  35. package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
  36. package/docs/subsystems/delegation.md +190 -0
  37. package/docs/subsystems/docs-freshness-gate.md +26 -0
  38. package/docs/subsystems/fusion.md +121 -0
  39. package/docs/subsystems/host-ui-and-telemetry.md +83 -0
  40. package/docs/tools/bg_delegate.md +193 -0
  41. package/docs/tools/bg_kill.md +114 -0
  42. package/docs/tools/bg_logs.md +133 -0
  43. package/docs/tools/bg_result.md +120 -0
  44. package/docs/tools/bg_run.md +168 -0
  45. package/docs/tools/bg_run_pi_attested.md +170 -0
  46. package/docs/tools/bg_status.md +111 -0
  47. package/docs/tools/fusion_investigate.md +116 -0
  48. package/docs/tools/fusion_reason.md +75 -0
  49. package/docs/tools/fusion_research.md +162 -0
  50. package/docs/tools/fusion_validate.md +206 -0
  51. package/logo.png +0 -0
  52. package/package.json +25 -7
  53. package/src/core/delegate/budget.ts +1 -1
  54. package/src/core/delegate/launch.ts +5 -0
  55. package/src/core/fusion/artifacts.ts +34 -4
  56. package/src/core/fusion/budget.ts +112 -20
  57. package/src/core/fusion/child-protocol.ts +82 -0
  58. package/src/core/fusion/clean-context.ts +91 -0
  59. package/src/core/fusion/config.ts +124 -35
  60. package/src/core/fusion/context.ts +29 -7
  61. package/src/core/fusion/evaluation.ts +392 -15
  62. package/src/core/fusion/orchestrator.ts +217 -23
  63. package/src/core/fusion/pi-child.ts +183 -23
  64. package/src/core/fusion/prompts.ts +39 -26
  65. package/src/core/fusion/source-policy.ts +257 -0
  66. package/src/core/fusion/types.ts +156 -11
  67. package/src/core/fusion/web-fetch.ts +104 -15
  68. package/src/core/fusion/workflows.ts +119 -65
  69. package/src/extension.ts +3 -3
  70. package/src/fusion-child-extension.ts +159 -120
  71. package/src/fusion-extension.ts +585 -240
  72. package/src/testing/normalize.ts +0 -22
@@ -13,7 +13,7 @@ import {
13
13
  type ReadonlyParentSessionManager,
14
14
  } from '../context/parent-snapshot.js';
15
15
  import type { Message } from '@earendil-works/pi-ai';
16
- import { FUSION_BRAINSTORM_TOOL_NAME as FUSION_BRAINSTORM_TOOL_NAME_VALUE } from './workflows.js';
16
+ import { FUSION_REASON_TOOL_NAME } from './workflows.js';
17
17
  import {
18
18
  FUSION_BRANCH_FILTER_ID,
19
19
  FUSION_COMMAND_CONTEXT_POLICY_ID,
@@ -23,8 +23,9 @@ import {
23
23
  FUSION_TOOL_CONTEXT_POLICY_ID,
24
24
  FusionError,
25
25
  type FusionBranchFilterDescriptor,
26
- type FusionCanonicalInputV3,
27
26
  type FusionCanonicalRequestV3,
27
+ type FusionSessionProjectionCanonicalInputV5,
28
+ type FusionWorkflowId,
28
29
  type FusionContextOmissionLedgerV2,
29
30
  type FusionContextPolicyDescriptor,
30
31
  type FusionConversationProjectionV3,
@@ -38,7 +39,13 @@ import {
38
39
  * Re-exported from the workflow registry, which owns every workflow's tool name.
39
40
  * Kept here so existing importers of this module keep working unchanged.
40
41
  */
41
- export { FUSION_BRAINSTORM_TOOL_NAME, FUSION_VALIDATE_TOOL_NAME } from './workflows.js';
42
+ export {
43
+ FUSION_BRAINSTORM_TOOL_NAME,
44
+ FUSION_REASON_TOOL_NAME,
45
+ FUSION_INVESTIGATE_TOOL_NAME,
46
+ FUSION_RESEARCH_TOOL_NAME,
47
+ FUSION_VALIDATE_TOOL_NAME,
48
+ } from './workflows.js';
42
49
 
43
50
  /** Retained for source compatibility; Fusion's session access is the shared adapter. */
44
51
  export type FusionReadonlySessionManager = ReadonlyParentSessionManager;
@@ -49,10 +56,11 @@ export interface BuildFusionCanonicalInputOptions {
49
56
  request: string;
50
57
  toolCallId?: string;
51
58
  toolName?: string;
59
+ workflow?: FusionWorkflowId;
52
60
  }
53
61
 
54
62
  export interface BuiltFusionCanonicalInput {
55
- input: FusionCanonicalInputV3;
63
+ input: FusionSessionProjectionCanonicalInputV5;
56
64
  serialized: string;
57
65
  ledger: FusionContextOmissionLedgerV2;
58
66
  transcriptLeafId: string | null;
@@ -207,7 +215,14 @@ export function buildFusionCanonicalInput(
207
215
  childCreated: false,
208
216
  });
209
217
  }
210
- const toolName = options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME_VALUE;
218
+ const workflow = options.workflow ?? 'reason';
219
+ if (workflow !== 'reason') {
220
+ throw new FusionError('parent session projection is available only to the reason workflow', {
221
+ code: 'context_capture_failed',
222
+ childCreated: false,
223
+ });
224
+ }
225
+ const toolName = options.toolName ?? FUSION_REASON_TOOL_NAME;
211
226
  const snapshotOptions: ParentSnapshotOptions = {
212
227
  toolName,
213
228
  excludeActiveToolCallLeaf: options.source === 'tool',
@@ -227,12 +242,19 @@ export function buildFusionCanonicalInput(
227
242
  text: options.request,
228
243
  sha256: sha256Text(options.request),
229
244
  };
230
- const input: FusionCanonicalInputV3 = {
245
+ const input: FusionSessionProjectionCanonicalInputV5 = {
231
246
  schema_version: FUSION_INPUT_SCHEMA_VERSION,
247
+ workflow: 'reason',
232
248
  cwd: ctx.cwd,
233
- system_prompt: ctx.getSystemPrompt(),
234
249
  request,
250
+ system_prompt: ctx.getSystemPrompt(),
235
251
  conversation_projection: projected.projection,
252
+ context: {
253
+ kind: 'session_projection',
254
+ policy_id: 'fusion-session-projection-v1',
255
+ system_prompt: ctx.getSystemPrompt(),
256
+ conversation_projection: projected.projection,
257
+ },
236
258
  };
237
259
  return {
238
260
  input,
@@ -2,6 +2,7 @@ import { parseJsonText, type JsonObject } from '../common.js';
2
2
  import {
3
3
  FUSION_CANDIDATE_IDS,
4
4
  FUSION_EVALUATION_SCHEMA_VERSION,
5
+ FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
5
6
  FusionError,
6
7
  type CandidateAssessment,
7
8
  type FusionCandidateId,
@@ -10,6 +11,11 @@ import {
10
11
  type FusionEvaluationV1,
11
12
  type FusionSynthesisContribution,
12
13
  type FusionSynthesisPlan,
14
+ type FusionValidationFindingAccounting,
15
+ type FusionValidationFindingDecision,
16
+ type FusionValidationFindingGroup,
17
+ type FusionValidationFindingRecord,
18
+ type FusionValidationSeverity,
13
19
  } from './types.js';
14
20
 
15
21
  const MAX_REPAIR_ERROR_CHARS = 500;
@@ -24,6 +30,10 @@ function isRecord(value: unknown): value is JsonObject {
24
30
  return typeof value === 'object' && value !== null && !Array.isArray(value);
25
31
  }
26
32
 
33
+ function errorText(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
36
+
27
37
  function closed(
28
38
  record: JsonObject,
29
39
  keys: readonly string[],
@@ -269,15 +279,145 @@ function parseConflictList(
269
279
  return out;
270
280
  }
271
281
 
282
+ function parseValidationGroups(
283
+ value: unknown,
284
+ label: string,
285
+ errors: string[],
286
+ ): readonly FusionValidationFindingGroup[] {
287
+ if (!Array.isArray(value)) {
288
+ errors.push(`${label} must be an array`);
289
+ return [];
290
+ }
291
+ const groups: FusionValidationFindingGroup[] = [];
292
+ for (const [index, item] of value.entries()) {
293
+ const itemLabel = `${label}[${String(index)}]`;
294
+ if (!isRecord(item)) {
295
+ errors.push(`${itemLabel} must be an object`);
296
+ continue;
297
+ }
298
+ closed(
299
+ item,
300
+ ['group_id', 'source_ids', 'severity', 'location', 'evidence', 'impact', 'summary', 'rationale'],
301
+ itemLabel,
302
+ errors,
303
+ );
304
+ const groupId = nonBlankString(item['group_id'], `${itemLabel}.group_id`, errors);
305
+ const sourceIds = stringList(item['source_ids'], `${itemLabel}.source_ids`, errors);
306
+ const severity = nonBlankString(item['severity'], `${itemLabel}.severity`, errors) as
307
+ | FusionValidationSeverity
308
+ | undefined;
309
+ const location = nonBlankString(item['location'], `${itemLabel}.location`, errors);
310
+ const evidence = nonBlankString(item['evidence'], `${itemLabel}.evidence`, errors);
311
+ const impact = nonBlankString(item['impact'], `${itemLabel}.impact`, errors);
312
+ const summary = nonBlankString(item['summary'], `${itemLabel}.summary`, errors);
313
+ const rationale = nonBlankString(item['rationale'], `${itemLabel}.rationale`, errors);
314
+ if (sourceIds !== undefined && sourceIds.length === 0) {
315
+ errors.push(`${itemLabel}.source_ids must not be empty`);
316
+ }
317
+ if (severity !== undefined && !['critical', 'high', 'minor'].includes(severity)) {
318
+ errors.push(`${itemLabel}.severity invalid`);
319
+ }
320
+ if (
321
+ groupId !== undefined &&
322
+ sourceIds !== undefined &&
323
+ sourceIds.length > 0 &&
324
+ severity !== undefined &&
325
+ location !== undefined &&
326
+ evidence !== undefined &&
327
+ impact !== undefined &&
328
+ summary !== undefined &&
329
+ rationale !== undefined
330
+ ) {
331
+ groups.push({
332
+ group_id: groupId,
333
+ source_ids: sourceIds,
334
+ severity,
335
+ location,
336
+ evidence,
337
+ impact,
338
+ summary,
339
+ rationale,
340
+ });
341
+ }
342
+ }
343
+ return groups;
344
+ }
345
+
346
+ function parseValidationAccounting(value: unknown, label: string, errors: string[]): FusionValidationFindingAccounting | undefined {
347
+ if (!isRecord(value)) {
348
+ errors.push(`${label} must be an object`);
349
+ return undefined;
350
+ }
351
+ closed(value, ['findings', 'decisions', 'groups'], label, errors);
352
+ const findingsRaw = value['findings'];
353
+ const decisionsRaw = value['decisions'];
354
+ const groups = parseValidationGroups(value['groups'], `${label}.groups`, errors);
355
+ const findings: FusionValidationFindingRecord[] = [];
356
+ if (!Array.isArray(findingsRaw)) errors.push(`${label}.findings must be an array`);
357
+ else {
358
+ for (const [index, item] of findingsRaw.entries()) {
359
+ const itemLabel = `${label}.findings[${String(index)}]`;
360
+ if (!isRecord(item)) {
361
+ errors.push(`${itemLabel} must be an object`);
362
+ continue;
363
+ }
364
+ closed(item, ['id', 'candidate_id', 'severity', 'location', 'evidence', 'impact', 'summary'], itemLabel, errors);
365
+ const id = nonBlankString(item['id'], `${itemLabel}.id`, errors);
366
+ const candidate = candidateId(item['candidate_id'], `${itemLabel}.candidate_id`, errors);
367
+ const severity = nonBlankString(item['severity'], `${itemLabel}.severity`, errors) as FusionValidationSeverity | undefined;
368
+ const location = nonBlankString(item['location'], `${itemLabel}.location`, errors);
369
+ const evidence = nonBlankString(item['evidence'], `${itemLabel}.evidence`, errors);
370
+ const impact = nonBlankString(item['impact'], `${itemLabel}.impact`, errors);
371
+ const summary = nonBlankString(item['summary'], `${itemLabel}.summary`, errors);
372
+ if (severity !== undefined && !['critical', 'high', 'minor'].includes(severity)) errors.push(`${itemLabel}.severity invalid`);
373
+ if (id !== undefined && candidate !== undefined && severity !== undefined && location !== undefined && evidence !== undefined && impact !== undefined && summary !== undefined) {
374
+ findings.push({ id, candidate_id: candidate, severity, location, evidence, impact, summary });
375
+ }
376
+ }
377
+ }
378
+ const decisions: FusionValidationFindingDecision[] = [];
379
+ if (!Array.isArray(decisionsRaw)) errors.push(`${label}.decisions must be an array`);
380
+ else {
381
+ for (const [index, item] of decisionsRaw.entries()) {
382
+ const itemLabel = `${label}.decisions[${String(index)}]`;
383
+ if (!isRecord(item)) {
384
+ errors.push(`${itemLabel} must be an object`);
385
+ continue;
386
+ }
387
+ const allowedDecisionKeys = new Set(['source_id', 'disposition', 'rationale', 'group_id']);
388
+ for (const key of Object.keys(item)) {
389
+ if (!allowedDecisionKeys.has(key)) errors.push(`${itemLabel} contains unknown key ${key}`);
390
+ }
391
+ for (const key of ['source_id', 'disposition', 'rationale'] as const) {
392
+ if (!Object.prototype.hasOwnProperty.call(item, key)) errors.push(`${itemLabel} is missing key ${key}`);
393
+ }
394
+ const sourceId = nonBlankString(item['source_id'], `${itemLabel}.source_id`, errors);
395
+ const disposition = nonBlankString(item['disposition'], `${itemLabel}.disposition`, errors);
396
+ const rationale = nonBlankString(item['rationale'], `${itemLabel}.rationale`, errors);
397
+ const group = item['group_id'] === undefined ? undefined : nonBlankString(item['group_id'], `${itemLabel}.group_id`, errors);
398
+ if (disposition !== undefined && disposition !== 'include' && disposition !== 'exclude') errors.push(`${itemLabel}.disposition invalid`);
399
+ if (sourceId !== undefined && (disposition === 'include' || disposition === 'exclude') && rationale !== undefined) {
400
+ const decision: FusionValidationFindingDecision = { source_id: sourceId, disposition, rationale };
401
+ if (group !== undefined) decision.group_id = group;
402
+ decisions.push(decision);
403
+ }
404
+ }
405
+ }
406
+ const accounting: FusionValidationFindingAccounting = { findings, decisions, groups };
407
+ errors.push(...validateFusionFindingAccounting(accounting));
408
+ return accounting;
409
+ }
410
+
272
411
  export function validateFusionEvaluation(value: unknown): FusionEvaluationValidationResult {
273
412
  const errors: string[] = [];
274
413
  if (!isRecord(value)) return { ok: false, errors: ['evaluation must be a JSON object'] };
275
- closed(
276
- value,
277
- ['schema_version', 'candidate_assessments', 'agreements', 'conflicts', 'synthesis_plan'],
278
- 'evaluation',
279
- errors,
280
- );
414
+ const evaluationAllowed = new Set(['schema_version', 'candidate_assessments', 'agreements', 'conflicts', 'synthesis_plan', 'validation_accounting']);
415
+ for (const key of Object.keys(value)) {
416
+ if (!evaluationAllowed.has(key)) errors.push(`evaluation contains unknown key ${key}`);
417
+ }
418
+ for (const key of ['schema_version', 'candidate_assessments', 'agreements', 'conflicts', 'synthesis_plan'] as const) {
419
+ if (!Object.prototype.hasOwnProperty.call(value, key)) errors.push(`evaluation is missing key ${key}`);
420
+ }
281
421
  if (value['schema_version'] !== FUSION_EVALUATION_SCHEMA_VERSION) {
282
422
  errors.push('evaluation.schema_version mismatch');
283
423
  }
@@ -289,6 +429,9 @@ export function validateFusionEvaluation(value: unknown): FusionEvaluationValida
289
429
  const agreements = stringList(value['agreements'], 'evaluation.agreements', errors);
290
430
  const conflicts = parseConflictList(value['conflicts'], 'evaluation.conflicts', errors);
291
431
  const plan = parseSynthesisPlan(value['synthesis_plan'], 'evaluation.synthesis_plan', errors);
432
+ const validationAccounting = Object.prototype.hasOwnProperty.call(value, 'validation_accounting')
433
+ ? parseValidationAccounting(value['validation_accounting'], 'evaluation.validation_accounting', errors)
434
+ : undefined;
292
435
  if (
293
436
  errors.length > 0 ||
294
437
  assessments === undefined ||
@@ -298,16 +441,15 @@ export function validateFusionEvaluation(value: unknown): FusionEvaluationValida
298
441
  ) {
299
442
  return { ok: false, errors };
300
443
  }
301
- return {
302
- ok: true,
303
- value: {
304
- schema_version: FUSION_EVALUATION_SCHEMA_VERSION,
305
- candidate_assessments: assessments,
306
- agreements,
307
- conflicts,
308
- synthesis_plan: plan,
309
- },
444
+ const parsedValue: FusionEvaluationV1 = {
445
+ schema_version: FUSION_EVALUATION_SCHEMA_VERSION,
446
+ candidate_assessments: assessments,
447
+ agreements,
448
+ conflicts,
449
+ synthesis_plan: plan,
310
450
  };
451
+ if (validationAccounting !== undefined) parsedValue.validation_accounting = validationAccounting;
452
+ return { ok: true, value: parsedValue };
311
453
  }
312
454
 
313
455
  export function parseFusionEvaluation(text: string): FusionEvaluationV1 {
@@ -360,3 +502,238 @@ export function boundedEvaluationErrors(errors: readonly string[]): readonly str
360
502
  export function formatEvaluationErrors(errors: readonly string[]): string {
361
503
  return boundedEvaluationErrors(errors).join('; ');
362
504
  }
505
+
506
+ function parseValidationCandidateFinding(value: unknown, label: string, errors: string[]): Omit<FusionValidationFindingRecord, 'id' | 'candidate_id'> | undefined {
507
+ if (!isRecord(value)) {
508
+ errors.push(`${label} must be an object`);
509
+ return undefined;
510
+ }
511
+ closed(value, ['severity', 'location', 'evidence', 'impact', 'summary'], label, errors);
512
+ const severity = nonBlankString(value['severity'], `${label}.severity`, errors) as FusionValidationSeverity | undefined;
513
+ const location = nonBlankString(value['location'], `${label}.location`, errors);
514
+ const evidence = nonBlankString(value['evidence'], `${label}.evidence`, errors);
515
+ const impact = nonBlankString(value['impact'], `${label}.impact`, errors);
516
+ const summary = nonBlankString(value['summary'], `${label}.summary`, errors);
517
+ if (severity !== undefined && !['critical', 'high', 'minor'].includes(severity)) errors.push(`${label}.severity invalid`);
518
+ if (severity === undefined || location === undefined || evidence === undefined || impact === undefined || summary === undefined) return undefined;
519
+ return { severity, location, evidence, impact, summary };
520
+ }
521
+
522
+ export interface ParsedFusionValidationCandidateReport {
523
+ findings: readonly FusionValidationFindingRecord[];
524
+ verified: readonly string[];
525
+ limitations: readonly string[];
526
+ }
527
+
528
+ export function parseFusionValidationCandidateReport(text: string, candidateId: FusionCandidateId): ParsedFusionValidationCandidateReport {
529
+ let parsed: unknown;
530
+ try {
531
+ parsed = parseJsonText(text);
532
+ } catch (error) {
533
+ throw new FusionError(
534
+ `validation candidate ${candidateId} output must be structured JSON only: ${errorText(error)}`,
535
+ { code: 'evaluation_invalid', stage: 'candidate' },
536
+ );
537
+ }
538
+ const errors: string[] = [];
539
+ if (!isRecord(parsed)) {
540
+ errors.push('validation candidate report must be an object');
541
+ } else {
542
+ closed(parsed, ['schema_version', 'findings', 'verified', 'limitations'], 'validation candidate report', errors);
543
+ }
544
+ if (!isRecord(parsed)) {
545
+ throw new FusionError(`validation candidate ${candidateId} output failed schema validation: ${formatEvaluationErrors(errors)}`, {
546
+ code: 'evaluation_invalid',
547
+ stage: 'candidate',
548
+ });
549
+ }
550
+ if (parsed['schema_version'] !== FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION) errors.push('validation candidate report.schema_version mismatch');
551
+ const rawFindings = parsed['findings'];
552
+ const findings: Array<Omit<FusionValidationFindingRecord, 'id' | 'candidate_id'>> = [];
553
+ if (!Array.isArray(rawFindings)) errors.push('validation candidate report.findings must be an array');
554
+ else {
555
+ for (const [index, item] of rawFindings.entries()) {
556
+ const finding = parseValidationCandidateFinding(item, `validation candidate report.findings[${String(index)}]`, errors);
557
+ if (finding !== undefined) findings.push(finding);
558
+ }
559
+ }
560
+ const verified = stringList(parsed['verified'], 'validation candidate report.verified', errors);
561
+ const limitations = stringList(parsed['limitations'], 'validation candidate report.limitations', errors);
562
+ if (errors.length > 0) {
563
+ throw new FusionError(`validation candidate ${candidateId} output failed schema validation: ${formatEvaluationErrors(errors)}`, {
564
+ code: 'evaluation_invalid',
565
+ stage: 'candidate',
566
+ });
567
+ }
568
+ return {
569
+ findings: findings.map((finding, index) => ({
570
+ id: stableFusionFindingId(candidateId, index + 1),
571
+ candidate_id: candidateId,
572
+ ...finding,
573
+ })),
574
+ verified: verified ?? [],
575
+ limitations: limitations ?? [],
576
+ };
577
+ }
578
+
579
+ export function stableFusionFindingId(candidateId: FusionCandidateId, ordinal: number): string {
580
+ if (!Number.isSafeInteger(ordinal) || ordinal <= 0) {
581
+ throw new FusionError('validation finding ordinal must be a positive integer', {
582
+ code: 'evaluation_invalid',
583
+ stage: 'evaluation',
584
+ });
585
+ }
586
+ return `${candidateId}-F${String(ordinal).padStart(3, '0')}`;
587
+ }
588
+
589
+ export function validateFusionFindingAccounting(
590
+ accounting: FusionValidationFindingAccounting,
591
+ ): readonly string[] {
592
+ const errors: string[] = [];
593
+ const sourceIds = new Set<string>();
594
+ const perCandidateOrdinal: Record<FusionCandidateId, number> = { A: 0, B: 0, C: 0 };
595
+ for (const [index, finding] of accounting.findings.entries()) {
596
+ const label = `finding[${String(index)}]`;
597
+ perCandidateOrdinal[finding.candidate_id] += 1;
598
+ if (finding.id !== stableFusionFindingId(finding.candidate_id, perCandidateOrdinal[finding.candidate_id])) {
599
+ errors.push(`${label}.id must be the stable host id for its candidate and ordinal`);
600
+ }
601
+ if (!['critical', 'high', 'minor'].includes(finding.severity)) errors.push(`${label}.severity invalid`);
602
+ for (const key of ['location', 'evidence', 'impact', 'summary'] as const) {
603
+ if (finding[key].trim().length === 0) errors.push(`${label}.${key} must be non-blank`);
604
+ }
605
+ if (sourceIds.has(finding.id)) errors.push(`${label}.id duplicate`);
606
+ sourceIds.add(finding.id);
607
+ }
608
+ const accounted = new Set<string>();
609
+ for (const [index, decision] of accounting.decisions.entries()) {
610
+ const label = `decision[${String(index)}]`;
611
+ if (!sourceIds.has(decision.source_id)) errors.push(`${label}.source_id does not name a candidate finding`);
612
+ if (accounted.has(decision.source_id)) errors.push(`${label}.source_id accounted more than once`);
613
+ accounted.add(decision.source_id);
614
+ if (decision.disposition !== 'include' && decision.disposition !== 'exclude') errors.push(`${label}.disposition invalid`);
615
+ if (decision.rationale.trim().length === 0) errors.push(`${label}.rationale must be non-blank`);
616
+ if (decision.disposition === 'include' && (decision.group_id === undefined || decision.group_id.trim().length === 0)) {
617
+ errors.push(`${label}.group_id required for included findings`);
618
+ }
619
+ if (decision.disposition === 'exclude' && decision.group_id !== undefined) {
620
+ errors.push(`${label}.group_id must be omitted for excluded findings`);
621
+ }
622
+ }
623
+ for (const id of sourceIds) {
624
+ if (!accounted.has(id)) errors.push(`source finding ${id} was not accounted exactly once`);
625
+ }
626
+
627
+ const groupsById = new Map<string, FusionValidationFindingGroup>();
628
+ for (const [index, group] of accounting.groups.entries()) {
629
+ const label = `group[${String(index)}]`;
630
+ if (groupsById.has(group.group_id)) errors.push(`${label}.group_id duplicate`);
631
+ groupsById.set(group.group_id, group);
632
+ if (!['critical', 'high', 'minor'].includes(group.severity)) errors.push(`${label}.severity invalid`);
633
+ for (const key of ['location', 'evidence', 'impact', 'summary', 'rationale'] as const) {
634
+ if (group[key].trim().length === 0) errors.push(`${label}.${key} must be non-blank`);
635
+ }
636
+ if (group.source_ids.length === 0) errors.push(`${label}.source_ids must not be empty`);
637
+ const groupSourceIds = new Set<string>();
638
+ for (const sourceId of group.source_ids) {
639
+ if (!sourceIds.has(sourceId)) errors.push(`${label}.source_ids contains unknown finding ${sourceId}`);
640
+ if (groupSourceIds.has(sourceId)) errors.push(`${label}.source_ids contains duplicate ${sourceId}`);
641
+ groupSourceIds.add(sourceId);
642
+ }
643
+ }
644
+
645
+ const includedByGroup = new Map<string, Set<string>>();
646
+ for (const decision of accounting.decisions) {
647
+ if (decision.disposition !== 'include' || decision.group_id === undefined) continue;
648
+ const group = groupsById.get(decision.group_id);
649
+ if (group === undefined) {
650
+ errors.push(`included source finding ${decision.source_id} references unknown group ${decision.group_id}`);
651
+ continue;
652
+ }
653
+ const members = includedByGroup.get(decision.group_id) ?? new Set<string>();
654
+ members.add(decision.source_id);
655
+ includedByGroup.set(decision.group_id, members);
656
+ }
657
+ for (const group of accounting.groups) {
658
+ const expected = [...group.source_ids].sort();
659
+ const actual = [...(includedByGroup.get(group.group_id) ?? new Set<string>())].sort();
660
+ if (expected.length !== actual.length || expected.some((id, index) => id !== actual[index])) {
661
+ errors.push(`group ${group.group_id} source_ids must exactly match included decisions assigned to that group`);
662
+ }
663
+ }
664
+ return errors;
665
+ }
666
+
667
+ function sanitizeValidationRationale(value: string): string {
668
+ return value
669
+ .replace(/\b[ABC]-F\d{3}\b/gu, 'source finding')
670
+ .replace(/\bcandidate [ABC]\b/giu, 'one reviewer')
671
+ .replace(/\b[ABC]:\s*/gu, '');
672
+ }
673
+
674
+ export function renderValidatedFusionValidationReport(
675
+ accounting: FusionValidationFindingAccounting,
676
+ coverage?: { verified: readonly string[]; limitations: readonly string[] } | undefined,
677
+ ): string {
678
+ const errors = validateFusionFindingAccounting(accounting);
679
+ if (errors.length > 0) {
680
+ throw new FusionError(`validation accounting invalid before render: ${formatEvaluationErrors(errors)}`, {
681
+ code: 'evaluation_invalid',
682
+ stage: 'merge',
683
+ });
684
+ }
685
+ const severityOrder = { critical: 0, high: 1, minor: 2 } as const;
686
+ const renderedFindings = [...accounting.groups].sort((left, right) =>
687
+ severityOrder[left.severity] - severityOrder[right.severity] ||
688
+ left.location.localeCompare(right.location) ||
689
+ left.group_id.localeCompare(right.group_id),
690
+ );
691
+ const lines: string[] = ['# Validation report', ''];
692
+ if (renderedFindings.length === 0) {
693
+ lines.push('No included findings were identified by the validated accounting.', '');
694
+ } else {
695
+ lines.push('## Findings', '');
696
+ for (const finding of renderedFindings) {
697
+ lines.push(`### ${finding.severity}: ${finding.summary}`, '');
698
+ lines.push(`- Location: ${finding.location}`);
699
+ lines.push(`- Evidence: ${finding.evidence}`);
700
+ lines.push(`- Impact: ${finding.impact}`);
701
+ lines.push(`- Inclusion rationale: ${sanitizeValidationRationale(finding.rationale)}`, '');
702
+ }
703
+ }
704
+ const exclusions = accounting.decisions
705
+ .filter((decision) => decision.disposition === 'exclude')
706
+ .sort((left, right) => left.source_id.localeCompare(right.source_id));
707
+ if (exclusions.length > 0) {
708
+ lines.push('## Excluded source findings', '');
709
+ for (const decision of exclusions) lines.push(`- ${sanitizeValidationRationale(decision.rationale)}`);
710
+ lines.push('');
711
+ }
712
+ if (coverage !== undefined) {
713
+ lines.push('## Verified', '');
714
+ if (coverage.verified.length === 0) lines.push('- No verification statements were provided.');
715
+ else for (const item of coverage.verified) lines.push(`- ${item}`);
716
+ lines.push('', '## Limitations', '');
717
+ if (coverage.limitations.length === 0) lines.push('- No limitations were provided.');
718
+ else for (const item of coverage.limitations) lines.push(`- ${item}`);
719
+ lines.push('');
720
+ }
721
+ return lines.join('\n').trimEnd();
722
+ }
723
+
724
+ export function assertMergerFindingCoverage(
725
+ accounting: FusionValidationFindingAccounting,
726
+ renderedGroupIds: readonly string[],
727
+ ): void {
728
+ const errors = [...validateFusionFindingAccounting(accounting)];
729
+ const included = new Set(accounting.groups.map((group) => group.group_id));
730
+ const rendered = new Set(renderedGroupIds);
731
+ for (const id of included) if (!rendered.has(id)) errors.push(`merger dropped included group ${id}`);
732
+ for (const id of rendered) if (!included.has(id)) errors.push(`merger invented or revived group ${id}`);
733
+ if (errors.length > 0) {
734
+ throw new FusionError(`validation finding preservation failed: ${formatEvaluationErrors(errors)}`, {
735
+ code: 'evaluation_invalid',
736
+ stage: 'merge',
737
+ });
738
+ }
739
+ }