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
@@ -0,0 +1,257 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { isIP } from 'node:net';
5
+ import { canonicalJson } from '../attested-pi-run.js';
6
+ import { isJsonObject, parseJsonText } from '../common.js';
7
+ import {
8
+ FUSION_SOURCE_POLICY_SCHEMA_VERSION,
9
+ FusionError,
10
+ type FusionDeclaredSourceV1,
11
+ type FusionSourcePolicyV1,
12
+ } from './types.js';
13
+
14
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
15
+ const O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
16
+
17
+ function sha256Text(value: string): string {
18
+ return createHash('sha256').update(value, 'utf8').digest('hex');
19
+ }
20
+
21
+ function stripIpv6Brackets(hostname: string): string {
22
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
23
+ }
24
+
25
+ function isBlockedHostname(hostname: string): boolean {
26
+ const lower = hostname.toLowerCase();
27
+ return (
28
+ lower === 'localhost' ||
29
+ lower.endsWith('.localhost') ||
30
+ lower === 'metadata' ||
31
+ lower === 'metadata.local' ||
32
+ lower === 'metadata.google.internal' ||
33
+ lower === 'metadata.goog' ||
34
+ lower === 'instance-data' ||
35
+ lower === 'instance-data.ec2.internal'
36
+ );
37
+ }
38
+
39
+ function isBlockedIpv4(host: string): boolean {
40
+ const parts = host.split('.').map((part) => Number.parseInt(part, 10));
41
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
42
+ return true;
43
+ const [a = 0, b = 0, c = 0] = parts;
44
+ if (a === 0 || a === 10 || a === 127) return true;
45
+ if (a === 100 && b >= 64 && b <= 127) return true;
46
+ if (a === 169 && b === 254) return true;
47
+ if (a === 172 && b >= 16 && b <= 31) return true;
48
+ if (a === 192 && b === 168) return true;
49
+ if (a === 192 && b === 0 && c === 0) return true;
50
+ if (a === 192 && b === 0 && c === 2) return true;
51
+ if (a === 198 && (b === 18 || b === 19)) return true;
52
+ if (a === 198 && b === 51 && c === 100) return true;
53
+ if (a === 203 && b === 0 && c === 113) return true;
54
+ if (a >= 224) return true;
55
+ return host === '255.255.255.255' || host === '168.63.129.16';
56
+ }
57
+
58
+ function isBlockedIpv6(host: string): boolean {
59
+ const lower = host.toLowerCase();
60
+ return (
61
+ lower === '::' ||
62
+ lower === '::1' ||
63
+ lower.startsWith('::ffff:') ||
64
+ lower.startsWith('64:ff9b::') ||
65
+ lower.startsWith('64:ff9b:1:') ||
66
+ lower.startsWith('100:') ||
67
+ lower.startsWith('2001:2:') ||
68
+ lower.startsWith('2001:db8:') ||
69
+ lower.startsWith('2002:') ||
70
+ lower.startsWith('fc') ||
71
+ lower.startsWith('fd') ||
72
+ lower.startsWith('fe8') ||
73
+ lower.startsWith('fe9') ||
74
+ lower.startsWith('fea') ||
75
+ lower.startsWith('feb') ||
76
+ lower.startsWith('ff')
77
+ );
78
+ }
79
+
80
+ export function canonicalizeFusionPublicUrl(value: string): string {
81
+ let url: URL;
82
+ try {
83
+ url = new URL(value);
84
+ } catch (error) {
85
+ throw new FusionError(
86
+ `fusion research declared source URL is malformed: ${error instanceof Error ? error.message : String(error)}`,
87
+ { code: 'orchestration_failed', childCreated: false },
88
+ );
89
+ }
90
+ if (url.username !== '' || url.password !== '') {
91
+ throw new FusionError('fusion research source URL must not contain credentials', {
92
+ code: 'orchestration_failed',
93
+ childCreated: false,
94
+ });
95
+ }
96
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
97
+ throw new FusionError('fusion research source URL must use http or https', {
98
+ code: 'orchestration_failed',
99
+ childCreated: false,
100
+ });
101
+ }
102
+ url.username = '';
103
+ url.password = '';
104
+ url.hash = '';
105
+ const normalizedHost = stripIpv6Brackets(url.hostname.toLowerCase().replace(/\.+$/u, ''));
106
+ url.hostname = normalizedHost;
107
+ if (isBlockedHostname(normalizedHost)) {
108
+ throw new FusionError('fusion research source URL must be public, not localhost/metadata', {
109
+ code: 'orchestration_failed',
110
+ childCreated: false,
111
+ });
112
+ }
113
+ const ipKind = isIP(normalizedHost);
114
+ if ((ipKind === 4 && isBlockedIpv4(normalizedHost)) || (ipKind === 6 && isBlockedIpv6(normalizedHost))) {
115
+ throw new FusionError('fusion research source URL must be public, not private/reserved', {
116
+ code: 'orchestration_failed',
117
+ childCreated: false,
118
+ });
119
+ }
120
+ if ((url.protocol === 'http:' && url.port === '80') || (url.protocol === 'https:' && url.port === '443')) {
121
+ url.port = '';
122
+ }
123
+ return url.toString();
124
+ }
125
+
126
+ export interface DeclaredFusionSourceInput {
127
+ url: string;
128
+ purpose: string;
129
+ }
130
+
131
+ export function normalizeFusionDeclaredSources(
132
+ sources: readonly DeclaredFusionSourceInput[] = [],
133
+ ): readonly FusionDeclaredSourceV1[] {
134
+ const seen = new Map<string, number>();
135
+ return sources.map((source, index) => {
136
+ if (typeof source.url !== 'string' || source.url.trim().length === 0) {
137
+ throw new FusionError(`fusion research source ${String(index)} requires non-blank URL`, {
138
+ code: 'orchestration_failed',
139
+ childCreated: false,
140
+ });
141
+ }
142
+ if (typeof source.purpose !== 'string' || source.purpose.trim().length === 0) {
143
+ throw new FusionError(`fusion research source ${String(index)} requires non-blank purpose`, {
144
+ code: 'orchestration_failed',
145
+ childCreated: false,
146
+ });
147
+ }
148
+ const canonicalUrl = canonicalizeFusionPublicUrl(source.url.trim());
149
+ const previous = seen.get(canonicalUrl);
150
+ if (previous !== undefined) {
151
+ throw new FusionError(
152
+ `fusion research source ${String(index)} duplicates canonical URL from source ${String(previous)}: ${canonicalUrl}`,
153
+ { code: 'orchestration_failed', childCreated: false },
154
+ );
155
+ }
156
+ seen.set(canonicalUrl, index);
157
+ const purpose = source.purpose.trim();
158
+ return {
159
+ url: canonicalUrl,
160
+ canonical_url: canonicalUrl,
161
+ purpose,
162
+ sha256: sha256Text(`${canonicalUrl}\u0000${purpose}`),
163
+ };
164
+ });
165
+ }
166
+
167
+ export function buildFusionSourcePolicy(
168
+ cwd: string,
169
+ sources: readonly FusionDeclaredSourceV1[],
170
+ ): FusionSourcePolicyV1 {
171
+ const body = {
172
+ schema_version: FUSION_SOURCE_POLICY_SCHEMA_VERSION,
173
+ workflow: 'research' as const,
174
+ cwd,
175
+ sources,
176
+ } as const;
177
+ return { ...body, root_sha256: sha256Text(canonicalJson(body)) };
178
+ }
179
+
180
+ export function sourcePolicyCanonicalBytes(policy: FusionSourcePolicyV1): string {
181
+ return canonicalJson(policy);
182
+ }
183
+
184
+ function requireString(record: Record<PropertyKey, unknown>, key: string, label: string): string {
185
+ const value = record[key];
186
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${label}.${key} must be non-blank string`);
187
+ return value;
188
+ }
189
+
190
+ export function parseFusionSourcePolicy(value: unknown): FusionSourcePolicyV1 {
191
+ if (!isJsonObject(value) || Array.isArray(value)) throw new Error('fusion source policy must be object');
192
+ const keys = Object.keys(value).sort();
193
+ const expected = ['cwd', 'root_sha256', 'schema_version', 'sources', 'workflow'];
194
+ if (keys.join('\0') !== expected.join('\0')) throw new Error('fusion source policy keys mismatch');
195
+ if (value['schema_version'] !== FUSION_SOURCE_POLICY_SCHEMA_VERSION) throw new Error('fusion source policy schema_version mismatch');
196
+ if (value['workflow'] !== 'research') throw new Error('fusion source policy workflow must be research');
197
+ const cwd = requireString(value, 'cwd', 'fusion source policy');
198
+ const rootSha256 = requireString(value, 'root_sha256', 'fusion source policy');
199
+ if (!SHA256_HEX.test(rootSha256)) throw new Error('fusion source policy.root_sha256 must be sha256');
200
+ if (!Array.isArray(value['sources'])) throw new Error('fusion source policy.sources must be array');
201
+ const sources = value['sources'].map((item, index): FusionDeclaredSourceV1 => {
202
+ const label = `fusion source policy.sources[${String(index)}]`;
203
+ if (!isJsonObject(item) || Array.isArray(item)) throw new Error(`${label} must be object`);
204
+ const itemKeys = Object.keys(item).sort();
205
+ const itemExpected = ['canonical_url', 'purpose', 'sha256', 'url'];
206
+ if (itemKeys.join('\0') !== itemExpected.join('\0')) throw new Error(`${label} keys mismatch`);
207
+ const url = requireString(item, 'url', label);
208
+ const purpose = requireString(item, 'purpose', label);
209
+ const canonical_url = requireString(item, 'canonical_url', label);
210
+ const sha256 = requireString(item, 'sha256', label);
211
+ if (!SHA256_HEX.test(sha256)) throw new Error(`${label}.sha256 must be sha256`);
212
+ if (canonicalizeFusionPublicUrl(url) !== canonical_url) throw new Error(`${label}.canonical_url mismatch`);
213
+ if (url !== canonical_url) throw new Error(`${label}.url must equal canonical_url`);
214
+ if (purpose.trim() !== purpose) throw new Error(`${label}.purpose must be trimmed`);
215
+ if (sha256Text(`${canonical_url}\u0000${purpose}`) !== sha256) throw new Error(`${label}.sha256 mismatch`);
216
+ return { url, canonical_url, purpose, sha256 };
217
+ });
218
+ const seen = new Set<string>();
219
+ for (const [index, source] of sources.entries()) {
220
+ if (seen.has(source.canonical_url)) {
221
+ throw new Error(`fusion source policy.sources[${String(index)}].canonical_url duplicate`);
222
+ }
223
+ seen.add(source.canonical_url);
224
+ }
225
+ const body = { schema_version: FUSION_SOURCE_POLICY_SCHEMA_VERSION, workflow: 'research' as const, cwd, sources } as const;
226
+ if (sha256Text(canonicalJson(body)) !== rootSha256) throw new Error('fusion source policy root_sha256 mismatch');
227
+ return { ...body, root_sha256: rootSha256 };
228
+ }
229
+
230
+ async function readRegularFileNoSymlink(path: string, label: string): Promise<Buffer> {
231
+ let handle: Awaited<ReturnType<typeof open>>;
232
+ try {
233
+ handle = await open(path, constants.O_RDONLY | O_NOFOLLOW);
234
+ } catch (error) {
235
+ if (isJsonObject(error) && error['code'] === 'ELOOP') {
236
+ throw new Error(`${label} at ${path} is a symlink; refusing to follow it`);
237
+ }
238
+ throw error;
239
+ }
240
+ try {
241
+ const stats = await handle.stat();
242
+ if (!stats.isFile()) throw new Error(`${label} at ${path} is not a regular file`);
243
+ return await handle.readFile();
244
+ } finally {
245
+ await handle.close();
246
+ }
247
+ }
248
+
249
+ export async function readFusionSourcePolicyFile(path: string, expectedSha256: string): Promise<FusionSourcePolicyV1> {
250
+ if (!SHA256_HEX.test(expectedSha256)) throw new Error('fusion source policy expected hash is malformed');
251
+ const bytes = await readRegularFileNoSymlink(path, 'fusion source policy');
252
+ const actual = createHash('sha256').update(bytes).digest('hex');
253
+ if (actual !== expectedSha256) throw new Error('fusion source policy artifact hash mismatch');
254
+ const text = bytes.toString('utf8');
255
+ if (!Buffer.from(text, 'utf8').equals(bytes)) throw new Error('fusion source policy is not UTF-8');
256
+ return parseFusionSourcePolicy(parseJsonText(text));
257
+ }
@@ -11,11 +11,16 @@ import type {
11
11
  export type FusionThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
12
12
 
13
13
  export const FUSION_MODEL_CONFIG_SCHEMA_VERSION = 'pi-background-tasks.fusion-models.v1';
14
- export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v4';
14
+ export const FUSION_LEGACY_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v4';
15
+ export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v5';
15
16
  export const FUSION_EVALUATION_SCHEMA_VERSION = 'pi-background-tasks.fusion-evaluation.v1';
16
- export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v4';
17
- export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v3';
17
+ export const FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION = 'pi-background-tasks.fusion-validation-candidate.v1';
18
+ export const FUSION_LEGACY_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v4';
19
+ export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v5';
20
+ export const FUSION_LEGACY_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v3';
21
+ export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v4';
18
22
  export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-context-ledger.v2';
23
+ export const FUSION_SOURCE_POLICY_SCHEMA_VERSION = 'pi-background-tasks.fusion-source-policy.v1';
19
24
  export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-budget-plan.v3';
20
25
  export const FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION =
21
26
  'pi-background-tasks.fusion-calibration-violation.v1';
@@ -46,26 +51,51 @@ export type FusionStage = (typeof FUSION_STAGE_VALUES)[number];
46
51
 
47
52
  export const FUSION_CAPABILITY_VALUES = Object.freeze(['reason', 'inspect', 'research'] as const);
48
53
  export type FusionCapability = (typeof FUSION_CAPABILITY_VALUES)[number];
49
- export const FUSION_DEFAULT_CAPABILITY: FusionCapability = 'reason';
54
+
55
+ /** No-tools capability for reason candidates, evaluator, repair, and merger. */
56
+ export const FUSION_NO_TOOLS_CAPABILITY: FusionCapability = 'reason';
57
+ /** Legacy default retained for old type imports only. New workflows never default. */
58
+ export const FUSION_BRAINSTORM_DEFAULT_CAPABILITY: FusionCapability = 'inspect';
59
+ /** @deprecated New v5 workflows have no caller capability default. */
60
+ export const FUSION_DEFAULT_CAPABILITY: FusionCapability = FUSION_BRAINSTORM_DEFAULT_CAPABILITY;
50
61
 
51
62
  export const FUSION_WEB_FETCH_TOOL_NAME = 'fusion_web_fetch' as const;
52
63
  export const FUSION_INSPECT_TOOLS = Object.freeze(['read', 'grep', 'find', 'ls'] as const);
64
+ export const FUSION_RESEARCH_TOOLS = Object.freeze([
65
+ 'read',
66
+ 'grep',
67
+ 'find',
68
+ 'ls',
69
+ FUSION_WEB_FETCH_TOOL_NAME,
70
+ ] as const);
53
71
 
54
72
  /**
55
73
  * Workflow identities sharing one orchestrator, one context projection, one
56
74
  * evaluation schema, and one artifact store. A workflow selects stage framing and
57
75
  * capability policy only; it never changes the canonical input schema.
58
76
  */
59
- export const FUSION_WORKFLOW_IDS = Object.freeze(['brainstorm', 'validate'] as const);
77
+ export const FUSION_WORKFLOW_IDS = Object.freeze([
78
+ 'reason',
79
+ 'investigate',
80
+ 'research',
81
+ 'validate',
82
+ ] as const);
60
83
  export type FusionWorkflowId = (typeof FUSION_WORKFLOW_IDS)[number];
84
+ export const FUSION_PUBLIC_WORKFLOW_NAMES = Object.freeze([
85
+ 'fusion_reason',
86
+ 'fusion_investigate',
87
+ 'fusion_research',
88
+ 'fusion_validate',
89
+ ] as const);
90
+ export type FusionPublicWorkflowName = (typeof FUSION_PUBLIC_WORKFLOW_NAMES)[number];
91
+ export type FusionContextKind = 'session_projection' | 'clean_task';
61
92
 
62
93
  /**
63
94
  * The single capability the validate workflow ever runs candidates with.
64
95
  *
65
- * Deliberately separate from `FUSION_DEFAULT_CAPABILITY`, which must stay the
66
- * least-privileged `reason` profile for brainstorm. A validator that cannot read
67
- * the code it is judging produces opinion rather than validation, so this is a
68
- * fixed workflow policy and not a default a caller may override.
96
+ * Deliberately separate from the caller-selectable brainstorm default. Although
97
+ * both workflows currently give candidates read-only inspection, validation pins
98
+ * that capability as fixed policy rather than exposing a caller override.
69
99
  */
70
100
  export const FUSION_VALIDATE_CAPABILITY: FusionCapability = 'inspect';
71
101
 
@@ -74,6 +104,9 @@ export const FUSION_FORBIDDEN_TOOLS = Object.freeze([
74
104
  'edit',
75
105
  'write',
76
106
  'fusion_brainstorm',
107
+ 'fusion_reason',
108
+ 'fusion_investigate',
109
+ 'fusion_research',
77
110
  'fusion_validate',
78
111
  'bg_delegate',
79
112
  'bg_result',
@@ -302,15 +335,73 @@ export interface FusionConversationProjectionV4 {
302
335
 
303
336
  export type FusionConversationProjectionV3 = FusionConversationProjectionV4;
304
337
 
305
- export interface FusionCanonicalInputV4 {
338
+ export interface FusionDeclaredSourceV1 {
339
+ url: string;
340
+ canonical_url: string;
341
+ purpose: string;
342
+ sha256: string;
343
+ }
344
+
345
+ export interface FusionCleanTaskContextV1 {
346
+ kind: 'clean_task';
347
+ policy_id: 'fusion-clean-task-v1';
348
+ declared_sources: readonly FusionDeclaredSourceV1[];
349
+ }
350
+
351
+ export interface FusionSessionProjectionContextV1 {
352
+ kind: 'session_projection';
353
+ policy_id: 'fusion-session-projection-v1';
354
+ system_prompt: string;
355
+ conversation_projection: FusionConversationProjectionV4;
356
+ }
357
+
358
+ export interface FusionCanonicalInputV5Base {
306
359
  schema_version: typeof FUSION_INPUT_SCHEMA_VERSION;
360
+ workflow?: FusionWorkflowId | undefined;
361
+ cwd: string;
362
+ request: FusionCanonicalRequestV3;
363
+ }
364
+
365
+ export interface FusionSessionProjectionCanonicalInputV5 extends FusionCanonicalInputV5Base {
366
+ workflow?: FusionWorkflowId | undefined;
367
+ /** @deprecated v4 readability alias. Present only for session-projection inputs at runtime. */
368
+ system_prompt: string;
369
+ /** @deprecated v4 readability alias. Present only for session-projection inputs at runtime. */
370
+ conversation_projection: FusionConversationProjectionV4;
371
+ context?: FusionSessionProjectionContextV1 | undefined;
372
+ }
373
+
374
+ export interface FusionCleanTaskCanonicalInputV5 extends FusionCanonicalInputV5Base {
375
+ workflow: Exclude<FusionWorkflowId, 'reason'>;
376
+ context: FusionCleanTaskContextV1;
377
+ }
378
+
379
+ export type FusionCanonicalInputV5 =
380
+ | FusionSessionProjectionCanonicalInputV5
381
+ | FusionCleanTaskCanonicalInputV5;
382
+
383
+ /** Legacy v4 shape retained for frozen golden fixtures/readability only. */
384
+ export interface FusionCanonicalInputV4 {
385
+ schema_version: typeof FUSION_LEGACY_INPUT_SCHEMA_VERSION;
307
386
  cwd: string;
308
387
  system_prompt: string;
309
388
  request: FusionCanonicalRequestV3;
310
389
  conversation_projection: FusionConversationProjectionV4;
311
390
  }
312
391
 
313
- export type FusionCanonicalInputV3 = FusionCanonicalInputV4;
392
+ export type FusionCanonicalInputV3 = FusionCanonicalInputV5;
393
+
394
+ export interface FusionSourcePolicyV1 {
395
+ schema_version: typeof FUSION_SOURCE_POLICY_SCHEMA_VERSION;
396
+ workflow: 'research';
397
+ cwd: string;
398
+ sources: readonly FusionDeclaredSourceV1[];
399
+ root_sha256: string;
400
+ }
401
+
402
+ export interface FusionSourcePolicyArtifactRef extends FusionArtifactRef {
403
+ root_sha256: string;
404
+ }
314
405
 
315
406
  export interface CandidateAssessment {
316
407
  candidate_id: FusionCandidateId;
@@ -343,12 +434,56 @@ export interface FusionSynthesisPlan {
343
434
  must_avoid: readonly string[];
344
435
  }
345
436
 
437
+ export type FusionValidationSeverity = 'critical' | 'high' | 'minor';
438
+
439
+ export interface FusionValidationFindingRecord {
440
+ id: string;
441
+ candidate_id: FusionCandidateId;
442
+ severity: FusionValidationSeverity;
443
+ location: string;
444
+ evidence: string;
445
+ impact: string;
446
+ summary: string;
447
+ }
448
+
449
+ export interface FusionValidationFindingDecision {
450
+ source_id: string;
451
+ disposition: 'include' | 'exclude';
452
+ rationale: string;
453
+ group_id?: string | undefined;
454
+ }
455
+
456
+ export interface FusionValidationFindingGroup {
457
+ group_id: string;
458
+ source_ids: readonly string[];
459
+ severity: FusionValidationSeverity;
460
+ location: string;
461
+ evidence: string;
462
+ impact: string;
463
+ summary: string;
464
+ rationale: string;
465
+ }
466
+
467
+ export interface FusionValidationFindingAccounting {
468
+ findings: readonly FusionValidationFindingRecord[];
469
+ decisions: readonly FusionValidationFindingDecision[];
470
+ groups: readonly FusionValidationFindingGroup[];
471
+ }
472
+
473
+ export interface FusionValidationCandidateReportV1 {
474
+ schema_version: typeof FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION;
475
+ findings: readonly Omit<FusionValidationFindingRecord, 'id' | 'candidate_id'>[];
476
+ verified: readonly string[];
477
+ limitations: readonly string[];
478
+ }
479
+
346
480
  export interface FusionEvaluationV1 {
347
481
  schema_version: typeof FUSION_EVALUATION_SCHEMA_VERSION;
348
482
  candidate_assessments: readonly [CandidateAssessment, CandidateAssessment, CandidateAssessment];
349
483
  agreements: readonly string[];
350
484
  conflicts: readonly FusionConflict[];
351
485
  synthesis_plan: FusionSynthesisPlan;
486
+ validation_accounting?: FusionValidationFindingAccounting | undefined;
352
487
  }
353
488
 
354
489
  /** Exact Pi usage contract used at the child, artifact, and host tool-result boundaries. */
@@ -420,6 +555,8 @@ export interface FusionResultDetails {
420
555
  workflow: FusionWorkflowId;
421
556
  source: FusionSource;
422
557
  status: 'completed';
558
+ context: { kind: FusionContextKind; policy_id: string };
559
+ tool_policy: { candidate_tools: readonly string[]; evaluation_tools: readonly []; merge_tools: readonly [] };
423
560
  artifact_dir: string;
424
561
  models: {
425
562
  candidates: readonly [string, string, string];
@@ -596,6 +733,8 @@ export interface FusionToolCallLogRecord {
596
733
  status: FusionToolCallLogStatus;
597
734
  duration_ms: number;
598
735
  url?: string | undefined;
736
+ /** SHA-256 of a rejected attempted fetch URL; raw rejected URLs are never persisted. */
737
+ rejected_url_sha256?: string | undefined;
599
738
  final_url?: string | undefined;
600
739
  http_status?: number | undefined;
601
740
  response_bytes?: number | undefined;
@@ -676,6 +815,8 @@ export interface FusionArtifactManifest {
676
815
  evaluation: FusionCapability;
677
816
  merge: FusionCapability;
678
817
  };
818
+ context: { kind: FusionContextKind; policy_id: string; ledger_artifact?: string; source_policy_artifact?: string };
819
+ tool_policy: { candidate_tools: readonly string[]; evaluation_tools: readonly []; merge_tools: readonly [] };
679
820
  usage: FusionUsage;
680
821
  attempts: readonly FusionAttemptArtifactRecord[];
681
822
  artifacts: Readonly<Record<string, FusionArtifactRef>>;
@@ -756,6 +897,10 @@ export interface FusionBudgetEmptyRequestVerdict {
756
897
 
757
898
  export interface FusionBudgetPlanV1 {
758
899
  schema_version: typeof FUSION_BUDGET_PLAN_SCHEMA_VERSION;
900
+ workflow: FusionWorkflowId;
901
+ context: { kind: FusionContextKind; policy_id: string };
902
+ fixed_candidate_policy: { capability: FusionCapability; tools: readonly string[] };
903
+ tool_policy: { candidate_tools: readonly string[]; evaluation_tools: readonly []; merge_tools: readonly [] };
759
904
  policy: FusionBudgetPolicyDescriptor;
760
905
  routes: readonly FusionRouteCapacity[];
761
906
  stages: readonly FusionStageBudgetPlanEntry[];