pi-background-tasks 0.9.0 → 1.0.4
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.
- package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
- package/PUBLISHING.md +43 -29
- package/README.md +233 -441
- package/TESTING.md +16 -10
- package/TEST_PLAN.md +43 -17
- package/docs/INDEX.md +157 -0
- package/docs/api/eventbus-v1.md +166 -0
- package/docs/assets/architecture.svg +78 -0
- package/docs/assets/footer-dock.svg +47 -0
- package/docs/assets/logo.svg +49 -0
- package/docs/attestations.json +189 -0
- package/docs/choose-a-workflow.md +98 -0
- package/docs/commands/bg-clear.md +70 -0
- package/docs/commands/bg-update.md +82 -0
- package/docs/commands/bg.md +90 -0
- package/docs/commands/fusion-models.md +70 -0
- package/docs/commands/fusion.md +69 -0
- package/docs/commands/jobs.md +74 -0
- package/docs/commands/kill.md +82 -0
- package/docs/commands/logs.md +90 -0
- package/docs/commands/task-manager.md +109 -0
- package/docs/concepts/completion-delivery.md +66 -0
- package/docs/concepts/context-projection-and-budgeting.md +79 -0
- package/docs/getting-started.md +122 -0
- package/docs/manifest.json +1825 -0
- package/docs/operations/configuration.md +110 -0
- package/docs/operations/releasing.md +67 -0
- package/docs/operations/testing.md +101 -0
- package/docs/operations/troubleshooting.md +38 -0
- package/docs/read-before-edit.md +94 -0
- package/docs/reference/runtime-contracts.md +213 -0
- package/docs/reference/shortcuts-and-dock.md +70 -0
- package/docs/subsystems/attested-pi-runs.md +141 -0
- package/docs/subsystems/background-task-runtime.md +85 -0
- package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
- package/docs/subsystems/delegation.md +190 -0
- package/docs/subsystems/docs-freshness-gate.md +26 -0
- package/docs/subsystems/fusion.md +123 -0
- package/docs/subsystems/host-ui-and-telemetry.md +83 -0
- package/docs/tools/bg_delegate.md +193 -0
- package/docs/tools/bg_kill.md +114 -0
- package/docs/tools/bg_logs.md +133 -0
- package/docs/tools/bg_result.md +120 -0
- package/docs/tools/bg_run.md +168 -0
- package/docs/tools/bg_run_pi_attested.md +170 -0
- package/docs/tools/bg_status.md +111 -0
- package/docs/tools/fusion_investigate.md +116 -0
- package/docs/tools/fusion_reason.md +75 -0
- package/docs/tools/fusion_research.md +162 -0
- package/docs/tools/fusion_validate.md +206 -0
- package/logo.png +0 -0
- package/package.json +27 -9
- package/src/core/delegate/budget.ts +1 -1
- package/src/core/delegate/launch.ts +5 -0
- package/src/core/fusion/artifacts.ts +34 -4
- package/src/core/fusion/budget.ts +112 -20
- package/src/core/fusion/child-protocol.ts +82 -0
- package/src/core/fusion/clean-context.ts +91 -0
- package/src/core/fusion/config.ts +124 -35
- package/src/core/fusion/context.ts +29 -7
- package/src/core/fusion/evaluation.ts +392 -15
- package/src/core/fusion/orchestrator.ts +217 -23
- package/src/core/fusion/pi-child.ts +227 -24
- package/src/core/fusion/prompts.ts +39 -26
- package/src/core/fusion/source-policy.ts +257 -0
- package/src/core/fusion/types.ts +156 -11
- package/src/core/fusion/web-fetch.ts +104 -15
- package/src/core/fusion/workflows.ts +119 -65
- package/src/extension.ts +3 -3
- package/src/fusion-child-extension.ts +375 -141
- package/src/fusion-extension.ts +585 -240
- 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 {
|
|
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 {
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|