pi-background-tasks 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
  | Fact | Value |
27
27
  | --- | --- |
28
28
  | Package | `pi-background-tasks` |
29
- | Version | `1.0.6` |
29
+ | Version | `1.0.7` |
30
30
  | Node engine | `>=22.19.0` |
31
31
  | Pi entrypoint | `./extensions/background-tasks.ts` |
32
32
  | Package image | [logo.png](https://raw.githubusercontent.com/ismailsaleekh/pi-background-tasks/main/logo.png) |
@@ -89,7 +89,7 @@
89
89
  "state": "pass"
90
90
  },
91
91
  {
92
- "authored_body_sha256": "sha256:77a2a9bfeb73a4cda9c65b2559a06e141096ff496d732e1e3d9c4eb1a0ba8616",
92
+ "authored_body_sha256": "sha256:2cea3292dac7fcb890bb05eb0c0783310671e11c1953c981251ec433b1099cb9",
93
93
  "covers_sources": [
94
94
  "extensions/fusion-child.ts",
95
95
  "src/core/fusion/artifacts.ts",
@@ -788,7 +788,7 @@
788
788
  "image": "https://raw.githubusercontent.com/ismailsaleekh/pi-background-tasks/main/logo.png",
789
789
  "name": "pi-background-tasks",
790
790
  "type": "module",
791
- "version": "1.0.6"
791
+ "version": "1.0.7"
792
792
  },
793
793
  "public_surface_ids": [
794
794
  "command:bg",
@@ -49,10 +49,10 @@ This generated registry lists production environment-variable references, runtim
49
49
  | `PI_BG_SHELL` | read | `src/core/common.ts:679` |
50
50
  | `PI_BG_SHELL_PATH` | read | `src/core/common.ts:680` |
51
51
  | `PI_CACHE_RETENTION` | read | `src/core/fusion/claude-cache.ts:56` |
52
- | `PI_FUSION_RESEARCH_ENABLED` | read, remove, write | `src/core/fusion/pi-child.ts:1807`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:673` |
53
- | `PI_FUSION_SOURCE_POLICY_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:1808`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:629` |
54
- | `PI_FUSION_SOURCE_POLICY_SHA256` | read, remove, write | `src/core/fusion/pi-child.ts:1809`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:630` |
55
- | `PI_FUSION_TOOL_CALL_LOG_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:1796`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:672` |
52
+ | `PI_FUSION_RESEARCH_ENABLED` | read, remove, write | `src/core/fusion/pi-child.ts:1811`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:673` |
53
+ | `PI_FUSION_SOURCE_POLICY_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:1812`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:629` |
54
+ | `PI_FUSION_SOURCE_POLICY_SHA256` | read, remove, write | `src/core/fusion/pi-child.ts:1813`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:630` |
55
+ | `PI_FUSION_TOOL_CALL_LOG_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:1800`<br>`src/core/fusion/pi-child.ts:92`<br>`src/fusion-child-extension.ts:672` |
56
56
  | `PI_MODEL` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:92` |
57
57
  | `PI_OFFLINE` | read | `src/extension.ts:445` |
58
58
  | `PI_PROVIDER` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:92` |
@@ -81,26 +81,26 @@ This generated registry lists production environment-variable references, runtim
81
81
  | delegate-artifact | `seed.json` | `src/core/delegate/artifacts.ts:40` |
82
82
  | delegate-artifact | `spill/<receipt-named-file>` | `src/core/delegate/artifacts.ts:53` |
83
83
  | directory | `.pi/delegate/<session-id>-<pid>/<task-id>/` | `src/core/delegate/artifacts.ts:157` |
84
- | directory | `.pi/fusion/<session-id>-<pid>/<run-id>/` | `src/core/fusion/artifacts.ts:251` |
84
+ | directory | `.pi/fusion/<session-id>-<pid>/<run-id>/` | `src/core/fusion/artifacts.ts:277` |
85
85
  | directory | `.pi/tasks/<session-id>-<pid>/` | `src/core/registry.ts:780` |
86
- | fusion-artifact | `<attempt-prefix> = candidate-<slot>.attempt-<n> \| evaluation.attempt-<n> \| merge.attempt-<n>` | `src/core/fusion/artifacts.ts:193` |
87
- | fusion-artifact | `<attempt-prefix>.calibration-violation.json` | `src/core/fusion/artifacts.ts:208` |
88
- | fusion-artifact | `<attempt-prefix>.events.jsonl` | `src/core/fusion/artifacts.ts:402` |
89
- | fusion-artifact | `<attempt-prefix>.prompt.txt` | `src/core/fusion/artifacts.ts:401` |
90
- | fusion-artifact | `<attempt-prefix>.stderr.txt` | `src/core/fusion/artifacts.ts:403` |
91
- | fusion-artifact | `blind-candidates.json` | `src/core/fusion/artifacts.ts:373` |
92
- | fusion-artifact | `budget-plan.json` | `src/core/fusion/artifacts.ts:369` |
93
- | fusion-artifact | `candidate-<slot>.attempt-<n>.response.md \| candidate-<slot>.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:203` |
94
- | fusion-artifact | `candidate-<slot>.attempt-<n>.tool-calls.jsonl` | `src/core/fusion/artifacts.ts:301` |
86
+ | fusion-artifact | `<attempt-prefix> = candidate-<slot>.attempt-<n> \| evaluation.attempt-<n> \| merge.attempt-<n>` | `src/core/fusion/artifacts.ts:219` |
87
+ | fusion-artifact | `<attempt-prefix>.calibration-violation.json` | `src/core/fusion/artifacts.ts:234` |
88
+ | fusion-artifact | `<attempt-prefix>.events.jsonl` | `src/core/fusion/artifacts.ts:429` |
89
+ | fusion-artifact | `<attempt-prefix>.prompt.txt` | `src/core/fusion/artifacts.ts:428` |
90
+ | fusion-artifact | `<attempt-prefix>.stderr.txt` | `src/core/fusion/artifacts.ts:430` |
91
+ | fusion-artifact | `blind-candidates.json` | `src/core/fusion/artifacts.ts:399` |
92
+ | fusion-artifact | `budget-plan.json` | `src/core/fusion/artifacts.ts:395` |
93
+ | fusion-artifact | `candidate-<slot>.attempt-<n>.response.md \| candidate-<slot>.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:229` |
94
+ | fusion-artifact | `candidate-<slot>.attempt-<n>.tool-calls.jsonl` | `src/core/fusion/artifacts.ts:327` |
95
95
  | fusion-artifact | `candidate-<slot>.attempt-<n>.tool-calls.jsonl.seal.json` | `src/core/fusion/child-protocol.ts:17` |
96
- | fusion-artifact | `canonical-input.json` | `src/core/fusion/artifacts.ts:339` |
97
- | fusion-artifact | `context-omission-ledger.json` | `src/core/fusion/artifacts.ts:348` |
98
- | fusion-artifact | `error.json` | `src/core/fusion/artifacts.ts:385` |
99
- | fusion-artifact | `evaluation.attempt-<n>.response.txt \| evaluation.attempt-<n>.response.partial.txt` | `src/core/fusion/artifacts.ts:203` |
100
- | fusion-artifact | `evaluation.json` | `src/core/fusion/artifacts.ts:377` |
101
- | fusion-artifact | `merge.attempt-<n>.response.md \| merge.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:203` |
102
- | fusion-artifact | `merged.md` | `src/core/fusion/artifacts.ts:316` |
103
- | fusion-artifact | `source-policy.private.json` | `src/core/fusion/artifacts.ts:355` |
96
+ | fusion-artifact | `canonical-input.json` | `src/core/fusion/artifacts.ts:365` |
97
+ | fusion-artifact | `context-omission-ledger.json` | `src/core/fusion/artifacts.ts:374` |
98
+ | fusion-artifact | `error.json` | `src/core/fusion/artifacts.ts:411` |
99
+ | fusion-artifact | `evaluation.attempt-<n>.response.txt \| evaluation.attempt-<n>.response.partial.txt` | `src/core/fusion/artifacts.ts:229` |
100
+ | fusion-artifact | `evaluation.json` | `src/core/fusion/artifacts.ts:403` |
101
+ | fusion-artifact | `merge.attempt-<n>.response.md \| merge.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:229` |
102
+ | fusion-artifact | `merged.md` | `src/core/fusion/artifacts.ts:342` |
103
+ | fusion-artifact | `source-policy.private.json` | `src/core/fusion/artifacts.ts:381` |
104
104
  | task-file | `.pi/tasks/<session-id>-<pid>/<task-id>.attestation.json` | `src/core/attested-pi-run.ts:583` |
105
105
  | task-file | `.pi/tasks/<session-id>-<pid>/<task-id>.json` | `src/core/registry.ts:808` |
106
106
  | task-file | `.pi/tasks/<session-id>-<pid>/<task-id>.output` | `src/core/registry.ts:807` |
@@ -127,20 +127,20 @@ This generated registry lists production environment-variable references, runtim
127
127
  | `pi-background-tasks.extension-request.v1` | `src/core/extension-api.ts:15` |
128
128
  | `pi-background-tasks.extension-response.v1` | `src/core/extension-api.ts:16` |
129
129
  | `pi-background-tasks.extension-terminal.v1` | `src/core/extension-api.ts:17` |
130
- | `pi-background-tasks.fusion-blind-candidates.v1` | `src/core/fusion/prompts.ts:299` |
130
+ | `pi-background-tasks.fusion-blind-candidates.v1` | `src/core/fusion/prompts.ts:300` |
131
131
  | `pi-background-tasks.fusion-budget-plan.v4` | `src/core/fusion/types.ts:25` |
132
- | `pi-background-tasks.fusion-calibration-violation.v1` | `src/core/fusion/types.ts:27` |
132
+ | `pi-background-tasks.fusion-calibration-violation.v2` | `src/core/fusion/types.ts:27` |
133
133
  | `pi-background-tasks.fusion-child-result.v3` | `src/core/fusion/child-protocol.ts:6` |
134
134
  | `pi-background-tasks.fusion-child-settlement.v2` | `src/core/fusion/child-protocol.ts:9` |
135
135
  | `pi-background-tasks.fusion-claude-cache-observation.v1` | `src/core/fusion/claude-cache.ts:4` |
136
136
  | `pi-background-tasks.fusion-context-ledger.v2` | `src/core/fusion/types.ts:23` |
137
- | `pi-background-tasks.fusion-evaluation-repair-input.v1` | `src/core/fusion/prompts.ts:279` |
137
+ | `pi-background-tasks.fusion-evaluation-repair-input.v1` | `src/core/fusion/prompts.ts:280` |
138
138
  | `pi-background-tasks.fusion-evaluation.v1` | `src/core/fusion/types.ts:16` |
139
139
  | `pi-background-tasks.fusion-input.v4` | `src/core/fusion/types.ts:14` |
140
140
  | `pi-background-tasks.fusion-input.v5` | `src/core/fusion/types.ts:15` |
141
141
  | `pi-background-tasks.fusion-manifest.v3` | `src/core/fusion/types.ts:21` |
142
142
  | `pi-background-tasks.fusion-manifest.v4` | `src/core/fusion/types.ts:22` |
143
- | `pi-background-tasks.fusion-merge-input.v1` | `src/core/fusion/prompts.ts:325` |
143
+ | `pi-background-tasks.fusion-merge-input.v1` | `src/core/fusion/prompts.ts:326` |
144
144
  | `pi-background-tasks.fusion-models.v1` | `src/core/fusion/types.ts:13` |
145
145
  | `pi-background-tasks.fusion-progress.v1` | `src/fusion-extension.ts:57` |
146
146
  | `pi-background-tasks.fusion-request.v1` | `src/fusion-extension.ts:58` |
@@ -149,7 +149,8 @@ This generated registry lists production environment-variable references, runtim
149
149
  | `pi-background-tasks.fusion-runtime-guard.v1` | `src/core/fusion/child-protocol.ts:19` |
150
150
  | `pi-background-tasks.fusion-source-policy.v1` | `src/core/fusion/types.ts:24` |
151
151
  | `pi-background-tasks.fusion-tool-call-seal.v1` | `src/core/fusion/child-protocol.ts:16` |
152
- | `pi-background-tasks.fusion-tool-call.v1` | `src/core/fusion/types.ts:28` |
152
+ | `pi-background-tasks.fusion-tool-call.v1` | `src/core/fusion/types.ts:30` |
153
+ | `pi-background-tasks.fusion-validation-candidate-contract-event.v1` | `src/core/fusion/types.ts:29` |
153
154
  | `pi-background-tasks.fusion-validation-candidate.v1` | `src/core/fusion/types.ts:18` |
154
155
  | `pi-background-tasks.input-token-calibration.v1` | `src/core/context/token-budget.ts:18` |
155
156
 
@@ -12,12 +12,12 @@ covers_sources: []
12
12
  This authored section defines the boundary: documentation facts are extracted from package metadata and TypeScript ASTs, then generated into docs and the manifest. Unsupported syntax fails the gate rather than falling back to regex or stale hand-maintained inventories. Public registrations must remain unconditional top-level direct calls or use the one validated local tool-wrapper shape; host/method aliases, computed access, nested or conditional registration, wrapper chaining/passing, constructor helpers, ambiguous public metadata, destructured Pi parameters, and repeated imported registrars are rejected.
13
13
 
14
14
  <!-- pi-docs:begin name="docs-freshness-gate" generator="scripts/docs/generate.mjs" -->
15
- - Canonical package version: `1.0.6`
15
+ - Canonical package version: `1.0.7`
16
16
  - Governed markdown docs: 40
17
17
  - Public surfaces extracted: 30
18
18
  - Governed production sources: 45
19
19
  - Tool contracts extracted: 11
20
- - Schema IDs extracted: 40
20
+ - Schema IDs extracted: 41
21
21
  - Environment variable references extracted: 47
22
22
  - Behavioral attestation receipts not passing: 1
23
23
  - Receipt store: `docs/attestations.json`
@@ -72,7 +72,7 @@ Evaluator, evaluator-repair, and merger always use capability `reason` and empty
72
72
 
73
73
  ## Validation specifics
74
74
 
75
- `fusion_validate` enforces a strict public verification contract: `provided` requires non-empty evidence and no reason; `not_run` requires a reason and empty/omitted evidence. Reviewers return closed candidate-report JSON. The host assigns stable finding ids after anonymization, the evaluator must account for every source finding exactly once, and the host renders the final report from validated accounting after the merger. Validation is advisory and read-only: it never edits files, runs tests, gates a release, or replaces builds, linters, scanners, or human review.
75
+ `fusion_validate` enforces a strict public verification contract: `provided` requires non-empty evidence and no reason; `not_run` requires a reason and empty/omitted evidence. Reviewers return exactly one bare, closed candidate-report JSON object. The host keeps its shared JSON parser strict; a single complete `json` fence can be removed only by the validation-specific audited recovery path, which writes a contract-event artifact and surfaces a limitation. One irrecoverable minority report is also recorded and surfaced as a limitation, while two invalid reports fail the workflow. The host assigns stable finding ids after anonymization, the evaluator must account for every source finding exactly once, and the host renders the final report from validated accounting after the merger. Validation is advisory and read-only: it never edits files, runs tests, gates a release, or replaces builds, linters, scanners, or human review.
76
76
 
77
77
  ## Research specifics
78
78
 
@@ -96,7 +96,7 @@ Fusion child environments strip session/model/provider variables plus metered cr
96
96
 
97
97
  ## Budgets and output contracts
98
98
 
99
- Budget planning is per route and per stage. Every configured candidate, evaluator, and merger route must have a usable context window. The affine estimator from the shared token-budget layer accounts for byte classes plus a 512-token intercept; backed model-family calibrations are used only where applicable, unknown/unbacked providers are reported in artifacts/result details, and multibyte/dense ASCII diagnostics are preserved.
99
+ Budget planning is per route and per stage. Every configured candidate, evaluator, and merger route must have a usable context window. The affine estimator from the shared token-budget layer accounts for byte classes plus a 512-token intercept; backed model-family calibrations are used only where applicable, unknown/unbacked providers are reported in artifacts/result details, and multibyte/dense ASCII diagnostics are preserved. Post-run calibration compares that one-request forecast only with the first provider request; cumulative agent-loop and cache usage is retained as total usage but is never misclassified as a prompt under-forecast.
100
100
 
101
101
  `budget-plan.json` uses `pi-background-tasks.fusion-budget-plan.v4` and records route capacities, stage forecasts for candidate/evaluation/evaluation-repair/merge, conditional repair reservation, warnings, blockers, empty-request counterfactuals, and remediation. Each route reserves the larger of Fusion's 32,768-token output contract reserve and the resolved model's declared maximum output; a model advertising a 128,000-token maximum therefore receives the full 128,000-token reserve. Fatal preflight blockers launch zero children. High utilization or worst-case reservation pressure is a warning when input still fits. Exact rendered prompt checks happen again immediately before candidate, evaluation, repair, and merge launches.
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background-tasks",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Pi extension for durable background shell tasks, read-only delegated agents, local attested Pi runs, and fixed-purpose Fusion workflows through child Pi processes.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -7,6 +7,7 @@ import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
8
8
  EMPTY_FUSION_USAGE,
9
9
  FUSION_MANIFEST_SCHEMA_VERSION,
10
+ FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
10
11
  FusionError,
11
12
  cloneFusionUsage,
12
13
  type FusionArtifactManifest,
@@ -83,14 +84,39 @@ export interface CreateFusionArtifactStoreOptions {
83
84
 
84
85
  export interface RecordFusionChildAttemptInput {
85
86
  result: FusionChildRunResult;
87
+ systemPrompt: string;
86
88
  prompt: string;
87
89
  responseKind: 'md' | 'txt';
88
90
  }
89
91
 
92
+ export type RecordValidationCandidateContractEventInput =
93
+ | {
94
+ candidateId: FusionCandidateId;
95
+ slot: 1 | 2 | 3;
96
+ status: 'normalized';
97
+ detail: {
98
+ normalization: 'markdown_json_fence' | 'prose_then_markdown_json_fence';
99
+ original_sha256: string;
100
+ forwarded_sha256: string;
101
+ warning: string;
102
+ };
103
+ }
104
+ | {
105
+ candidateId: FusionCandidateId;
106
+ slot: 1 | 2 | 3;
107
+ status: 'dropped';
108
+ detail: {
109
+ response_sha256: string;
110
+ error: string;
111
+ warning: string;
112
+ };
113
+ };
114
+
90
115
  export interface RecordFusionFailedAttemptInput {
91
116
  stage: FusionStage;
92
117
  slot?: 1 | 2 | 3;
93
118
  attempt: number;
119
+ systemPrompt: string;
94
120
  prompt: string;
95
121
  events: Buffer;
96
122
  partialResponse: Buffer;
@@ -398,6 +424,7 @@ export class FusionArtifactStore {
398
424
 
399
425
  async recordChildAttempt(input: RecordFusionChildAttemptInput): Promise<void> {
400
426
  const prefix = attemptPrefix(input.result.stage, input.result.slot, input.result.attempt);
427
+ await this.writeArtifact(`${prefix}.system-prompt.txt`, input.systemPrompt);
401
428
  const promptRef = await this.writeArtifact(`${prefix}.prompt.txt`, input.prompt);
402
429
  const eventsRef = await this.writeArtifact(`${prefix}.events.jsonl`, input.result.events);
403
430
  const stderrRef = await this.writeArtifact(`${prefix}.stderr.txt`, input.result.stderr);
@@ -442,8 +469,22 @@ export class FusionArtifactStore {
442
469
  return this.writeArtifact(calibrationViolationName(prefix), `${canonicalJson(input.violation)}\n`);
443
470
  }
444
471
 
472
+ async recordValidationCandidateContractEvent(
473
+ input: RecordValidationCandidateContractEventInput,
474
+ ): Promise<FusionArtifactRef> {
475
+ const name = `candidate-${String(input.slot)}.output-contract-${input.status}.json`;
476
+ return this.writeArtifact(name, `${canonicalJson({
477
+ schema_version: FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
478
+ ...input.detail,
479
+ candidate_id: input.candidateId,
480
+ slot: input.slot,
481
+ status: input.status,
482
+ })}\n`);
483
+ }
484
+
445
485
  async recordFailedAttempt(input: RecordFusionFailedAttemptInput): Promise<void> {
446
486
  const prefix = attemptPrefix(input.stage, input.slot, input.attempt);
487
+ await this.writeArtifact(`${prefix}.system-prompt.txt`, input.systemPrompt);
447
488
  const promptRef = await this.writeArtifact(`${prefix}.prompt.txt`, input.prompt);
448
489
  const eventsRef = await this.writeArtifact(`${prefix}.events.jsonl`, input.events);
449
490
  const stderrRef = await this.writeArtifact(`${prefix}.stderr.txt`, input.stderr);
@@ -1122,7 +1122,13 @@ export class FusionBudget {
1122
1122
  const inputSegments = [knownTextSegment(systemPrompt), knownTextSegment(userPrompt)];
1123
1123
  const promptUtf8Bytes = inputSegments.reduce((sum, segment) => sum + segment.bytes, 0);
1124
1124
  const estimate = estimateRouteInput(route, inputSegments);
1125
- const billedInput = result.usage.input + result.usage.cacheRead + result.usage.cacheWrite;
1125
+ // The forecast is a one-request admission estimate. Compare it only with
1126
+ // the first provider request, never with aggregate agent-loop/cache usage.
1127
+ // Custom child runners predating this observation field remain compatible,
1128
+ // but cannot produce a calibration verdict without like-for-like evidence.
1129
+ const observedUsage = result.firstRequestUsage;
1130
+ if (observedUsage === undefined) return undefined;
1131
+ const billedInput = observedUsage.input + observedUsage.cacheRead + observedUsage.cacheWrite;
1126
1132
  if (billedInput <= estimate.tokens) return undefined;
1127
1133
  const violation: FusionCalibrationViolation = {
1128
1134
  schema_version: FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION,
@@ -1137,12 +1143,14 @@ export class FusionBudget {
1137
1143
  rate_source: estimate.rateSource,
1138
1144
  prompt_utf8_bytes: promptUtf8Bytes,
1139
1145
  prompt_sha256: sha256Hex(`${systemPrompt}\u0000${userPrompt}`),
1146
+ observation_scope: 'first_provider_request',
1147
+ provider_request_count: result.providerRequestCount ?? 1,
1140
1148
  forecast_input_tokens: estimate.tokens,
1141
1149
  billed_input_tokens: billedInput,
1142
1150
  billed_input_breakdown: {
1143
- input: result.usage.input,
1144
- cache_read: result.usage.cacheRead,
1145
- cache_write: result.usage.cacheWrite,
1151
+ input: observedUsage.input,
1152
+ cache_read: observedUsage.cacheRead,
1153
+ cache_write: observedUsage.cacheWrite,
1146
1154
  },
1147
1155
  under_forecast_tokens: billedInput - estimate.tokens,
1148
1156
  byte_class_breakdown: estimate.byte_class_breakdown,
@@ -525,6 +525,67 @@ export interface ParsedFusionValidationCandidateReport {
525
525
  limitations: readonly string[];
526
526
  }
527
527
 
528
+ export type FusionValidationCandidateNormalization =
529
+ | 'markdown_json_fence'
530
+ | 'prose_then_markdown_json_fence';
531
+
532
+ export interface RecoveredFusionValidationCandidateReport {
533
+ report: ParsedFusionValidationCandidateReport;
534
+ /** Bare JSON forwarded to the evaluator after explicit, audited recovery. */
535
+ response: string;
536
+ normalization: FusionValidationCandidateNormalization;
537
+ }
538
+
539
+ /**
540
+ * Recognize exactly one complete Markdown JSON fence, optionally preceded by a
541
+ * short prose preamble. This is deliberately narrower than generic substring
542
+ * extraction: trailing prose, nested fences, unlabelled fences, and oversized
543
+ * preambles remain contract failures.
544
+ */
545
+ function fencedValidationCandidateJson(text: string): {
546
+ payload: string;
547
+ normalization: FusionValidationCandidateNormalization;
548
+ } | undefined {
549
+ const trimmed = text.trim();
550
+ const openingPattern = /```json[ \t]*\r?\n/giu;
551
+ const openings = [...trimmed.matchAll(openingPattern)];
552
+ if (openings.length !== 1) return undefined;
553
+ const opening = openings[0];
554
+ if (opening === undefined) return undefined;
555
+ const headerEnd = opening.index + opening[0].length;
556
+ const closing = trimmed.indexOf('```', headerEnd);
557
+ if (closing < 0 || trimmed.slice(closing + 3).includes('```')) return undefined;
558
+ if (trimmed.slice(closing + 3).trim().length > 0) return undefined;
559
+ const preamble = trimmed.slice(0, opening.index).trim();
560
+ if (Buffer.byteLength(preamble, 'utf8') > 2_000 || preamble.includes('```')) return undefined;
561
+ const payload = trimmed.slice(headerEnd, closing).trim();
562
+ if (payload.length === 0 || payload.includes('```')) return undefined;
563
+ return {
564
+ payload,
565
+ normalization: preamble.length === 0
566
+ ? 'markdown_json_fence'
567
+ : 'prose_then_markdown_json_fence',
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Defensive recovery for the one observed contract violation shape. Callers
573
+ * must persist/surface the returned normalization; this function intentionally
574
+ * does not make the strict parser permissive.
575
+ */
576
+ export function recoverFencedFusionValidationCandidateReport(
577
+ text: string,
578
+ candidateId: FusionCandidateId,
579
+ ): RecoveredFusionValidationCandidateReport | undefined {
580
+ const recovered = fencedValidationCandidateJson(text);
581
+ if (recovered === undefined) return undefined;
582
+ return {
583
+ report: parseFusionValidationCandidateReport(recovered.payload, candidateId),
584
+ response: recovered.payload,
585
+ normalization: recovered.normalization,
586
+ };
587
+ }
588
+
528
589
  export function parseFusionValidationCandidateReport(text: string, candidateId: FusionCandidateId): ParsedFusionValidationCandidateReport {
529
590
  let parsed: unknown;
530
591
  try {
@@ -1,4 +1,4 @@
1
- import { randomBytes as nodeRandomBytes } from 'node:crypto';
1
+ import { createHash, randomBytes as nodeRandomBytes } from 'node:crypto';
2
2
  import { canonicalJson } from '../attested-pi-run.js';
3
3
  import { parseJsonText } from '../common.js';
4
4
  import { FUSION_BUDGET_POLICY, FusionBudget, assertChildOutputWithinContract } from './budget.js';
@@ -11,6 +11,7 @@ import {
11
11
  boundedEvaluationErrors,
12
12
  formatEvaluationErrors,
13
13
  parseFusionValidationCandidateReport,
14
+ recoverFencedFusionValidationCandidateReport,
14
15
  renderValidatedFusionValidationReport,
15
16
  validateFusionEvaluation,
16
17
  validateFusionFindingAccounting,
@@ -35,6 +36,7 @@ import {
35
36
  FUSION_INPUT_SCHEMA_VERSION,
36
37
  FUSION_NO_TOOLS_CAPABILITY,
37
38
  FUSION_RESULT_SCHEMA_VERSION,
39
+ FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
38
40
  FusionError,
39
41
  addFusionUsage,
40
42
  createEmptyFusionUsage,
@@ -169,6 +171,7 @@ function recordFailureInput(
169
171
  stage: FusionStage,
170
172
  slot: CandidateSlot | undefined,
171
173
  attempt: number,
174
+ systemPrompt: string,
172
175
  prompt: string,
173
176
  responseKind: 'md' | 'txt',
174
177
  ): RecordFusionFailedAttemptInput {
@@ -176,6 +179,7 @@ function recordFailureInput(
176
179
  const base: RecordFusionFailedAttemptInput = {
177
180
  stage,
178
181
  attempt,
182
+ systemPrompt,
179
183
  prompt,
180
184
  events: error.events,
181
185
  partialResponse: error.response,
@@ -194,6 +198,7 @@ function recordFailureInput(
194
198
  const base: RecordFusionFailedAttemptInput = {
195
199
  stage,
196
200
  attempt,
201
+ systemPrompt,
197
202
  prompt,
198
203
  events: Buffer.alloc(0),
199
204
  partialResponse: Buffer.alloc(0),
@@ -371,22 +376,123 @@ function anonymousCandidates(
371
376
  }
372
377
 
373
378
  interface ValidationSourceData {
379
+ candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate];
374
380
  findings: readonly FusionValidationFindingRecord[];
375
381
  verified: readonly string[];
376
382
  limitations: readonly string[];
377
383
  }
378
384
 
379
- function validationSourceData(candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate]): ValidationSourceData {
385
+ function sha256Text(value: string): string {
386
+ return createHash('sha256').update(value, 'utf8').digest('hex');
387
+ }
388
+
389
+ function boundedContractError(error: unknown): string {
390
+ const value = errorText(error);
391
+ return value.length <= 1_000 ? value : `${value.slice(0, 999)}…`;
392
+ }
393
+
394
+ /**
395
+ * Enforce the validation-candidate contract without making the shared JSON
396
+ * parser permissive. A single, tightly recognized fenced response is recovered
397
+ * with a durable warning. One irrecoverable minority report is represented as
398
+ * an explicit limitation; two or more still fail the workflow loudly.
399
+ */
400
+ async function prepareValidationSourceData(
401
+ candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate],
402
+ anonymousMap: Record<FusionCandidateId, CandidateSlot>,
403
+ store: FusionArtifactStore,
404
+ ): Promise<ValidationSourceData> {
405
+ const prepared = candidates.map((candidate) => ({ ...candidate })) as [
406
+ AnonymousFusionCandidate,
407
+ AnonymousFusionCandidate,
408
+ AnonymousFusionCandidate,
409
+ ];
380
410
  const findings: FusionValidationFindingRecord[] = [];
381
411
  const verified: string[] = [];
382
412
  const limitations: string[] = [];
383
- for (const candidate of candidates) {
384
- const report = parseFusionValidationCandidateReport(candidate.response, candidate.candidate_id);
385
- findings.push(...report.findings);
386
- verified.push(...report.verified);
413
+ let normalizationCount = 0;
414
+ const failures: Array<{ candidate: AnonymousFusionCandidate; error: string }> = [];
415
+
416
+ for (const candidate of prepared) {
417
+ try {
418
+ const report = parseFusionValidationCandidateReport(candidate.response, candidate.candidate_id);
419
+ findings.push(...report.findings);
420
+ verified.push(...report.verified);
421
+ limitations.push(...report.limitations);
422
+ continue;
423
+ } catch (strictError) {
424
+ try {
425
+ const recovered = recoverFencedFusionValidationCandidateReport(
426
+ candidate.response,
427
+ candidate.candidate_id,
428
+ );
429
+ if (recovered === undefined) throw strictError;
430
+ await store.recordValidationCandidateContractEvent({
431
+ candidateId: candidate.candidate_id,
432
+ slot: anonymousMap[candidate.candidate_id],
433
+ status: 'normalized',
434
+ detail: {
435
+ normalization: recovered.normalization,
436
+ original_sha256: sha256Text(candidate.response),
437
+ forwarded_sha256: sha256Text(recovered.response),
438
+ warning: 'Candidate output violated the bare-JSON contract; a single complete JSON fence was removed and recorded.',
439
+ },
440
+ });
441
+ candidate.response = recovered.response;
442
+ findings.push(...recovered.report.findings);
443
+ verified.push(...recovered.report.verified);
444
+ limitations.push(...recovered.report.limitations);
445
+ normalizationCount += 1;
446
+ continue;
447
+ } catch (recoveryError) {
448
+ failures.push({
449
+ candidate,
450
+ error: boundedContractError(recoveryError === strictError ? strictError : recoveryError),
451
+ });
452
+ }
453
+ }
454
+ }
455
+
456
+ if (normalizationCount > 0) {
457
+ limitations.push(
458
+ `${String(normalizationCount)} validation report${normalizationCount === 1 ? '' : 's'} required audited removal of a Markdown JSON wrapper; JSON content was unchanged.`,
459
+ );
460
+ }
461
+
462
+ for (const failure of failures) {
463
+ await store.recordValidationCandidateContractEvent({
464
+ candidateId: failure.candidate.candidate_id,
465
+ slot: anonymousMap[failure.candidate.candidate_id],
466
+ status: 'dropped',
467
+ detail: {
468
+ response_sha256: sha256Text(failure.candidate.response),
469
+ error: failure.error,
470
+ warning: 'Candidate output could not be parsed under the strict or fenced-JSON contract.',
471
+ },
472
+ });
473
+ }
474
+ if (failures.length > 1) {
475
+ throw new FusionError(
476
+ `fusion_validate cannot continue: ${String(failures.length)} of 3 candidate reports violated the structured-output contract`,
477
+ { code: 'evaluation_invalid', stage: 'candidate' },
478
+ );
479
+ }
480
+ const failure = failures[0];
481
+ if (failure !== undefined) {
482
+ const synthetic = canonicalJson({
483
+ schema_version: FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
484
+ findings: [],
485
+ verified: [],
486
+ limitations: [
487
+ 'This validation report could not be parsed after strict contract checks; no findings or verification claims from it were included.',
488
+ ],
489
+ });
490
+ failure.candidate.response = synthetic;
491
+ const report = parseFusionValidationCandidateReport(synthetic, failure.candidate.candidate_id);
387
492
  limitations.push(...report.limitations);
388
493
  }
389
- return { findings, verified, limitations };
494
+
495
+ return { candidates: prepared, findings, verified, limitations };
390
496
  }
391
497
 
392
498
  function validateEvaluationAccountsForSourceFindings(
@@ -558,11 +664,16 @@ export class FusionOrchestrator {
558
664
  input.onProgress?.({ type: 'state', state: 'candidates_complete' });
559
665
 
560
666
  const shuffled = anonymousCandidates(candidateResults, shuffledSlots(this.randomBytes));
561
- const validationData = profile.id === 'validate' ? validationSourceData(shuffled.candidates) : undefined;
667
+ // Persist the blind mapping before workflow-specific contract parsing
668
+ // so a failed validation remains attributable to its durable slot artifact.
562
669
  await store.setAnonymousMap(shuffled.map);
670
+ const validationData = profile.id === 'validate'
671
+ ? await prepareValidationSourceData(shuffled.candidates, shuffled.map, store)
672
+ : undefined;
673
+ const evaluationCandidates = validationData?.candidates ?? shuffled.candidates;
563
674
  const blindInput = buildBlindEvaluationInput(
564
675
  input.canonicalInput,
565
- shuffled.candidates,
676
+ evaluationCandidates,
566
677
  validationData?.findings,
567
678
  );
568
679
  await store.writeBlindCandidates(buildEvaluationPrompt(blindInput));
@@ -585,7 +696,7 @@ export class FusionOrchestrator {
585
696
 
586
697
  await store.transition('merging');
587
698
  input.onProgress?.({ type: 'state', state: 'merging' });
588
- const mergeInput = buildMergeInput(input.canonicalInput, shuffled.candidates, evaluation);
699
+ const mergeInput = buildMergeInput(input.canonicalInput, evaluationCandidates, evaluation);
589
700
  const mergePrompt = buildMergePrompt(mergeInput);
590
701
  budget.assertStagePrompt('merge', profile.mergerSystemPrompt, mergePrompt);
591
702
  input.onProgress?.({ type: 'merge_started' });
@@ -604,7 +715,7 @@ export class FusionOrchestrator {
604
715
  'md',
605
716
  );
606
717
  addFusionUsage(usage, merged.usage);
607
- await store.recordChildAttempt({ result: merged, prompt: mergePrompt, responseKind: 'md' });
718
+ await store.recordChildAttempt({ result: merged, systemPrompt: profile.mergerSystemPrompt, prompt: mergePrompt, responseKind: 'md' });
608
719
  await this.recordCalibrationObservation(
609
720
  input,
610
721
  store,
@@ -729,9 +840,9 @@ export class FusionOrchestrator {
729
840
  controller.signal,
730
841
  candidateCapability,
731
842
  slot,
732
- 'md',
843
+ profile.id === 'validate' ? 'txt' : 'md',
733
844
  ).then(async (result) => {
734
- await store.recordChildAttempt({ result, prompt, responseKind: 'md' });
845
+ await store.recordChildAttempt({ result, systemPrompt, prompt, responseKind: profile.id === 'validate' ? 'txt' : 'md' });
735
846
  await this.recordCalibrationObservation(
736
847
  input,
737
848
  store,
@@ -866,7 +977,7 @@ export class FusionOrchestrator {
866
977
  attempt,
867
978
  );
868
979
  addFusionUsage(usage, result.usage);
869
- await store.recordChildAttempt({ result, prompt, responseKind: 'txt' });
980
+ await store.recordChildAttempt({ result, systemPrompt, prompt, responseKind: 'txt' });
870
981
  await this.recordCalibrationObservation(
871
982
  input,
872
983
  store,
@@ -966,7 +1077,7 @@ export class FusionOrchestrator {
966
1077
  if (!signal.aborted && retryableSpawn(error, launchTry) && launchTry === 1) continue;
967
1078
  addFailedChildUsage(usage, error);
968
1079
  await store.recordFailedAttempt(
969
- recordFailureInput(error, stage, slot, logicalAttempt, userPrompt, responseKind),
1080
+ recordFailureInput(error, stage, slot, logicalAttempt, systemPrompt, userPrompt, responseKind),
970
1081
  );
971
1082
  await store.setUsage(usage);
972
1083
  throw error;
@@ -1440,6 +1440,8 @@ export class FusionPiCompactResultParser {
1440
1440
  ): {
1441
1441
  text: string;
1442
1442
  usage: FusionUsage;
1443
+ firstRequestUsage: FusionUsage;
1444
+ providerRequestCount: number;
1443
1445
  provider: string;
1444
1446
  model: string;
1445
1447
  qualifiedId: string;
@@ -1474,6 +1476,8 @@ export class FusionPiCompactResultParser {
1474
1476
  return {
1475
1477
  text: reconstructFinalText(response, final),
1476
1478
  usage: observed.usage,
1479
+ firstRequestUsage: cloneFusionUsage(parsed.records[0]?.usage ?? createEmptyFusionUsage()),
1480
+ providerRequestCount: parsed.records.length,
1477
1481
  provider: final.provider,
1478
1482
  model: final.model,
1479
1483
  qualifiedId: `${final.provider}/${final.model}`,
@@ -2119,6 +2123,8 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
2119
2123
  qualifiedId: parsed.qualifiedId,
2120
2124
  text: parsed.text,
2121
2125
  usage: parsed.usage,
2126
+ firstRequestUsage: parsed.firstRequestUsage,
2127
+ providerRequestCount: parsed.providerRequestCount,
2122
2128
  events: parsed.events,
2123
2129
  stderr: parsed.diagnostics,
2124
2130
  exitCode: close.code,
@@ -190,6 +190,7 @@ Return only JSON matching this exact closed schema:
190
190
  "limitations": ["non-blank statement of what you could not cover"]
191
191
  }
192
192
  Use an empty findings array when no issues were found; do not omit verified or limitations.
193
+ Do not wrap the JSON in Markdown fences or prose. Emit exactly one bare JSON object.
193
194
 
194
195
  Do not inflate severity and do not invent issues to appear thorough. If the work is correct, say so plainly in verified/limitations. A report with no findings that names the evidence behind that conclusion is a valid and valuable result; a padded report is not.
195
196
 
@@ -24,7 +24,9 @@ export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-
24
24
  export const FUSION_SOURCE_POLICY_SCHEMA_VERSION = 'pi-background-tasks.fusion-source-policy.v1';
25
25
  export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-budget-plan.v4';
26
26
  export const FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION =
27
- 'pi-background-tasks.fusion-calibration-violation.v1';
27
+ 'pi-background-tasks.fusion-calibration-violation.v2';
28
+ export const FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION =
29
+ 'pi-background-tasks.fusion-validation-candidate-contract-event.v1';
28
30
  export const FUSION_TOOL_CALL_LOG_SCHEMA_VERSION = 'pi-background-tasks.fusion-tool-call.v1';
29
31
 
30
32
  /**
@@ -769,7 +771,12 @@ export interface FusionChildRunResult {
769
771
  model: string;
770
772
  qualifiedId: string;
771
773
  text: string;
774
+ /** Aggregate usage across the complete child agent loop. */
772
775
  usage: FusionUsage;
776
+ /** First provider request, used for like-for-like prompt forecast calibration. */
777
+ firstRequestUsage?: FusionUsage;
778
+ /** Number of provider requests represented by aggregate usage. */
779
+ providerRequestCount?: number;
773
780
  events: Buffer;
774
781
  stderr: Buffer;
775
782
  exitCode: number;
@@ -962,6 +969,8 @@ export interface FusionCalibrationViolation {
962
969
  rate_source: TokenBudgetRateSource;
963
970
  prompt_utf8_bytes: number;
964
971
  prompt_sha256: string;
972
+ observation_scope: 'first_provider_request';
973
+ provider_request_count: number;
965
974
  forecast_input_tokens: number;
966
975
  billed_input_tokens: number;
967
976
  billed_input_breakdown: {