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
@@ -3,9 +3,10 @@ import { lookup as nodeLookup } from 'node:dns/promises';
3
3
  import * as http from 'node:http';
4
4
  import * as https from 'node:https';
5
5
  import { isIP } from 'node:net';
6
+ import { performance } from 'node:perf_hooks';
6
7
  import { TextDecoder } from 'node:util';
7
8
 
8
- import TurndownService from 'turndown';
9
+ import type TurndownService from 'turndown';
9
10
 
10
11
  export const FUSION_WEB_FETCH_TIMEOUT_MS = 60_000;
11
12
  export const FUSION_WEB_FETCH_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
@@ -117,6 +118,10 @@ interface TableReplacement {
117
118
  markdown: string;
118
119
  }
119
120
 
121
+ type TurndownServiceConstructor = typeof TurndownService;
122
+
123
+ let turndownServiceLoad: Promise<TurndownServiceConstructor> | undefined;
124
+
120
125
  const USER_AGENT = 'pi-background-tasks fusion_web_fetch/1.0';
121
126
  const ACCEPT_HEADER = 'text/markdown, text/html;q=0.9, application/xhtml+xml;q=0.8, text/plain;q=0.7';
122
127
  const METADATA_HOSTNAMES = new Set([
@@ -150,6 +155,7 @@ const IPV4_DENY_RANGES: readonly [number, number][] = [
150
155
  const IPV6_DENY_RANGES: readonly [bigint, number][] = [
151
156
  [ipv6ToBigIntLiteral('::'), 128],
152
157
  [ipv6ToBigIntLiteral('::1'), 128],
158
+ [ipv6ToBigIntLiteral('64:ff9b::'), 96],
153
159
  [ipv6ToBigIntLiteral('64:ff9b:1::'), 48],
154
160
  [ipv6ToBigIntLiteral('100::'), 64],
155
161
  [ipv6ToBigIntLiteral('2001:2::'), 48],
@@ -187,18 +193,19 @@ export async function fusionWebFetch(
187
193
  const effective = mergeOptions(options);
188
194
  const requestedFormat = req.extract ?? 'markdown';
189
195
  const start = effective.now();
196
+ const deadlineMs = start + effective.timeoutMs;
190
197
  const requestedUrl = normalizeRequestUrl(req.url).url;
191
198
  let currentUrl = requestedUrl;
192
199
 
193
200
  for (let redirectCount = 0; ; redirectCount += 1) {
194
- const remainingMs = start + effective.timeoutMs - effective.now();
201
+ const remainingMs = deadlineMs - effective.now();
195
202
  if (remainingMs <= 0) {
196
203
  throw new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', {
197
204
  url: currentUrl.toString(),
198
205
  });
199
206
  }
200
207
 
201
- const result = await fetchOne(currentUrl, effective, remainingMs, redirectCount > 0);
208
+ const result = await fetchOne(currentUrl, effective, deadlineMs, redirectCount > 0);
202
209
  if (result.kind === 'redirect') {
203
210
  if (redirectCount >= effective.maxRedirects) {
204
211
  throw new FusionWebFetchError(
@@ -211,7 +218,7 @@ export async function fusionWebFetch(
211
218
  continue;
212
219
  }
213
220
 
214
- const extracted = extractContent(result.body, result.contentType, requestedFormat);
221
+ const extracted = await extractContent(result.body, result.contentType, requestedFormat);
215
222
  const capped = capUtf8Bytes(extracted.content, effective.maxOutputBytes);
216
223
  return {
217
224
  url: requestedUrl.toString(),
@@ -234,7 +241,7 @@ function mergeOptions(options: FusionWebFetchOptions): EffectiveOptions {
234
241
  request: options.request ?? defaultTransportRequest,
235
242
  agent: options.agent ?? false,
236
243
  createConnection: options.createConnection,
237
- now: options.now ?? Date.now,
244
+ now: options.now ?? (() => performance.now()),
238
245
  timeoutMs: options.timeoutMs ?? FUSION_WEB_FETCH_TIMEOUT_MS,
239
246
  maxResponseBytes: options.maxResponseBytes ?? FUSION_WEB_FETCH_MAX_RESPONSE_BYTES,
240
247
  maxOutputBytes: options.maxOutputBytes ?? FUSION_WEB_FETCH_MAX_OUTPUT_BYTES,
@@ -305,11 +312,23 @@ function normalizeRedirectUrl(baseUrl: URL, location: string): URL {
305
312
  async function fetchOne(
306
313
  url: URL,
307
314
  options: EffectiveOptions,
308
- remainingMs: number,
315
+ deadlineMs: number,
309
316
  redirectHop: boolean,
310
317
  ): Promise<FetchOneResult> {
311
318
  const normalized = normalizeRequestUrl(url.toString());
312
- const vetted = await vetHost(normalized.hostname, options, redirectHop, normalized.url.toString());
319
+ const vetted = await vetHost(
320
+ normalized.hostname,
321
+ options,
322
+ redirectHop,
323
+ normalized.url.toString(),
324
+ deadlineMs,
325
+ );
326
+ const remainingMs = deadlineMs - options.now();
327
+ if (remainingMs <= 0) {
328
+ throw new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', {
329
+ url: normalized.url.toString(),
330
+ });
331
+ }
313
332
  return await executeRequest(normalized.url, vetted.selectedAddress, options, remainingMs);
314
333
  }
315
334
 
@@ -318,6 +337,7 @@ async function vetHost(
318
337
  options: EffectiveOptions,
319
338
  redirectHop: boolean,
320
339
  url: string,
340
+ deadlineMs: number,
321
341
  ): Promise<VettedHost> {
322
342
  if (METADATA_HOSTNAMES.has(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {
323
343
  throwAddressError(redirectHop, `fusion_web_fetch blocked host: ${hostname}`, url);
@@ -326,7 +346,9 @@ async function vetHost(
326
346
  const literalFamily = isIP(hostname);
327
347
  const literalAddress: FusionDnsAddress = { address: hostname, family: literalFamily === 4 ? 4 : 6 };
328
348
  const resolvedAddresses =
329
- literalFamily === 0 ? await resolveWithLookup(hostname, options.lookup, url) : [literalAddress];
349
+ literalFamily === 0
350
+ ? await resolveWithLookup(hostname, options.lookup, url, deadlineMs - options.now())
351
+ : [literalAddress];
330
352
 
331
353
  if (resolvedAddresses.length === 0) {
332
354
  throw new FusionWebFetchError('dns_failure', `DNS lookup returned no addresses for ${hostname}`, { url });
@@ -358,15 +380,38 @@ async function resolveWithLookup(
358
380
  hostname: string,
359
381
  lookup: FusionDnsLookup,
360
382
  url: string,
383
+ remainingMs: number,
361
384
  ): Promise<readonly FusionDnsAddress[]> {
385
+ if (remainingMs <= 0) {
386
+ throw new FusionWebFetchError('request_timeout', 'fusion_web_fetch request timeout elapsed', { url });
387
+ }
388
+ let timer: NodeJS.Timeout | undefined;
362
389
  try {
363
- return await lookup(hostname);
390
+ return await Promise.race([
391
+ lookup(hostname),
392
+ new Promise<never>((_resolve, reject) => {
393
+ timer = setTimeout(
394
+ () =>
395
+ reject(
396
+ new FusionWebFetchError(
397
+ 'request_timeout',
398
+ 'fusion_web_fetch request timeout elapsed during DNS lookup',
399
+ { url },
400
+ ),
401
+ ),
402
+ Math.max(1, remainingMs),
403
+ );
404
+ }),
405
+ ]);
364
406
  } catch (error) {
407
+ if (error instanceof FusionWebFetchError) throw error;
365
408
  throw new FusionWebFetchError(
366
409
  'dns_failure',
367
410
  `DNS lookup failed for ${hostname}: ${error instanceof Error ? error.message : 'unknown error'}`,
368
411
  error instanceof Error ? { url, cause: error } : { url },
369
412
  );
413
+ } finally {
414
+ if (timer !== undefined) clearTimeout(timer);
370
415
  }
371
416
  }
372
417
 
@@ -434,7 +479,9 @@ function executeRequest(
434
479
  const status = response.statusCode ?? 0;
435
480
  const location = firstHeader(response, 'location');
436
481
  if (isRedirectStatus(status)) {
437
- response.resume();
482
+ // Redirect payloads are never consumed. Destroy the response/socket before
483
+ // advancing so an endless or oversized redirect body cannot outlive this hop.
484
+ response.destroy();
438
485
  if (location === undefined || location.trim().length === 0) {
439
486
  settleReject(
440
487
  new FusionWebFetchError('http_error', `fusion_web_fetch redirect status ${String(status)} lacks Location`, {
@@ -638,18 +685,18 @@ function readContentLength(value: string | undefined, url: string, status: numbe
638
685
  return Number(trimmed);
639
686
  }
640
687
 
641
- function extractContent(
688
+ async function extractContent(
642
689
  body: Buffer,
643
690
  contentType: string,
644
691
  requestedFormat: 'text' | 'markdown',
645
- ): ExtractionResult {
692
+ ): Promise<ExtractionResult> {
646
693
  try {
647
694
  const mediaType = mediaTypeFromContentType(contentType);
648
695
  const decoded = decodeBody(body, contentType);
649
696
  if (mediaType === 'text/plain') return { content: decoded, format: 'text' };
650
697
  if (mediaType === 'text/markdown') return { content: decoded, format: 'markdown' };
651
698
  if (requestedFormat === 'text') return { content: htmlToText(decoded), format: 'text' };
652
- return { content: htmlToMarkdown(decoded), format: 'markdown' };
699
+ return { content: await htmlToMarkdown(decoded), format: 'markdown' };
653
700
  } catch (error) {
654
701
  if (error instanceof FusionWebFetchError) throw error;
655
702
  throw new FusionWebFetchError(
@@ -695,10 +742,11 @@ function stripAsciiQuotes(value: string): string {
695
742
  return value;
696
743
  }
697
744
 
698
- function htmlToMarkdown(html: string): string {
745
+ async function htmlToMarkdown(html: string): Promise<string> {
746
+ const TurndownServiceClass = await loadTurndownService();
699
747
  const stripped = stripUnsafeHtmlBlocks(html);
700
748
  const tables = replaceTablesWithTokens(stripped);
701
- const turndown = new TurndownService({ bulletListMarker: '-', codeBlockStyle: 'fenced', headingStyle: 'atx' });
749
+ const turndown = new TurndownServiceClass({ bulletListMarker: '-', codeBlockStyle: 'fenced', headingStyle: 'atx' });
702
750
  let markdown = turndown.turndown(tables.html).trim();
703
751
  for (const table of tables.replacements) {
704
752
  markdown = markdown.replace(table.token, table.markdown);
@@ -706,6 +754,47 @@ function htmlToMarkdown(html: string): string {
706
754
  return markdown.trim();
707
755
  }
708
756
 
757
+ async function loadTurndownService(): Promise<TurndownServiceConstructor> {
758
+ if (turndownServiceLoad === undefined) {
759
+ turndownServiceLoad = import('turndown').then((module) => module.default);
760
+ }
761
+ try {
762
+ return await turndownServiceLoad;
763
+ } catch (error) {
764
+ turndownServiceLoad = undefined;
765
+ throw normalizeTurndownLoadError(error);
766
+ }
767
+ }
768
+
769
+ function normalizeTurndownLoadError(error: unknown): Error {
770
+ if (isMissingTurndownDependency(error)) {
771
+ const details = error instanceof Error ? { cause: error } : {};
772
+ return new FusionWebFetchError(
773
+ 'extraction_failed',
774
+ 'fusion_web_fetch markdown extraction dependency "turndown" is missing from pi-background-tasks; repair the package install with `pi update --extensions` or `npm install --omit=dev --prefix <pi-background-tasks>`.',
775
+ details,
776
+ );
777
+ }
778
+ if (error instanceof Error) return error;
779
+ return new Error(`fusion_web_fetch markdown extraction dependency failed to load: ${String(error)}`);
780
+ }
781
+
782
+ function isMissingTurndownDependency(error: unknown): boolean {
783
+ const code = errorCode(error);
784
+ if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') return false;
785
+ return errorMessage(error).includes('turndown');
786
+ }
787
+
788
+ function errorCode(error: unknown): string | undefined {
789
+ if (typeof error !== 'object' || error === null) return undefined;
790
+ const code = Reflect.get(error, 'code');
791
+ return typeof code === 'string' ? code : undefined;
792
+ }
793
+
794
+ function errorMessage(error: unknown): string {
795
+ return error instanceof Error ? error.message : String(error);
796
+ }
797
+
709
798
  function htmlToText(html: string): string {
710
799
  const withoutBlocks = stripUnsafeHtmlBlocks(html);
711
800
  const withBreaks = withoutBlocks
@@ -9,84 +9,145 @@ import {
9
9
  fusionValidateCandidateSystemPrompt,
10
10
  } from './prompts.js';
11
11
  import {
12
- FUSION_DEFAULT_CAPABILITY,
12
+ FUSION_INSPECT_TOOLS,
13
+ FUSION_NO_TOOLS_CAPABILITY,
14
+ FUSION_RESEARCH_TOOLS,
13
15
  FUSION_VALIDATE_CAPABILITY,
14
16
  FusionError,
15
17
  type FusionCapability,
18
+ type FusionContextKind,
19
+ type FusionPublicWorkflowName,
16
20
  type FusionWorkflowId,
17
21
  } from './types.js';
18
22
 
19
- export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm';
20
- export const FUSION_VALIDATE_TOOL_NAME = 'fusion_validate';
23
+ export const FUSION_REASON_TOOL_NAME = 'fusion_reason' as const;
24
+ export const FUSION_INVESTIGATE_TOOL_NAME = 'fusion_investigate' as const;
25
+ export const FUSION_RESEARCH_TOOL_NAME = 'fusion_research' as const;
26
+ export const FUSION_VALIDATE_TOOL_NAME = 'fusion_validate' as const;
21
27
 
22
- /**
23
- * How a workflow decides which capability its candidate children run with.
24
- *
25
- * `caller_selected` lets the tool schema offer a capability argument and defaults
26
- * to the least-privileged profile. `fixed` pins one capability for every run and
27
- * makes each other value a loud orchestration failure rather than a silent
28
- * downgrade.
29
- */
30
- export type FusionCapabilityPolicy = 'caller_selected' | 'fixed';
28
+ /** @deprecated v4 recursion-denylist compatibility only; do not register new public APIs with this name. */
29
+ export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm' as const;
31
30
 
32
- /**
33
- * Stage framing for one Fusion workflow.
34
- *
35
- * Everything a workflow can vary lives here: the four system prompts, the
36
- * capability policy, and presentation strings. Everything else - the conversation
37
- * projection, canonical input schema, budget policy, evaluation schema, artifact
38
- * store, and state machine - is shared and must never be branched per workflow.
39
- */
40
31
  export interface FusionWorkflowProfile {
41
32
  readonly id: FusionWorkflowId;
42
- readonly toolName: string;
43
- /** First character of the run id, so artifact directories are self-describing. */
44
- readonly runIdPrefix: string;
45
- readonly capabilityPolicy: FusionCapabilityPolicy;
46
- /** The only capability permitted when `capabilityPolicy` is `fixed`. */
47
- readonly fixedCapability: FusionCapability | undefined;
48
- readonly defaultCapability: FusionCapability;
33
+ readonly publicName: FusionPublicWorkflowName;
34
+ readonly toolName: FusionPublicWorkflowName;
35
+ /** Human-readable run-id prefix, e.g. `reason-<hex>`. */
36
+ readonly runIdPrefix: `${FusionWorkflowId}-`;
37
+ readonly contextKind: FusionContextKind;
38
+ readonly candidateCapability: FusionCapability;
39
+ readonly candidateTools: readonly string[];
40
+ readonly evaluatorCapability: typeof FUSION_NO_TOOLS_CAPABILITY;
41
+ readonly evaluatorTools: readonly [];
42
+ readonly mergeCapability: typeof FUSION_NO_TOOLS_CAPABILITY;
43
+ readonly mergeTools: readonly [];
49
44
  readonly candidateSystemPrompt: (capability: FusionCapability) => string;
50
45
  readonly evaluatorSystemPrompt: string;
51
46
  readonly evaluationRepairSystemPrompt: string;
52
47
  readonly mergerSystemPrompt: string;
53
- /** Human-readable noun used in progress lines and rendered results. */
54
48
  readonly label: string;
55
49
  }
56
50
 
57
- export const FUSION_BRAINSTORM_WORKFLOW: FusionWorkflowProfile = Object.freeze({
58
- id: 'brainstorm',
59
- toolName: FUSION_BRAINSTORM_TOOL_NAME,
60
- runIdPrefix: 'f',
61
- capabilityPolicy: 'caller_selected',
62
- fixedCapability: undefined,
63
- defaultCapability: FUSION_DEFAULT_CAPABILITY,
51
+ function freezeProfile(profile: FusionWorkflowProfile): FusionWorkflowProfile {
52
+ const empty = Object.freeze([]) as readonly [];
53
+ return Object.freeze({
54
+ ...profile,
55
+ candidateTools: Object.freeze([...profile.candidateTools]),
56
+ evaluatorTools: empty,
57
+ mergeTools: empty,
58
+ });
59
+ }
60
+
61
+ export const FUSION_REASON_WORKFLOW = freezeProfile({
62
+ id: 'reason',
63
+ publicName: FUSION_REASON_TOOL_NAME,
64
+ toolName: FUSION_REASON_TOOL_NAME,
65
+ runIdPrefix: 'reason-',
66
+ contextKind: 'session_projection',
67
+ candidateCapability: FUSION_NO_TOOLS_CAPABILITY,
68
+ candidateTools: [],
69
+ evaluatorCapability: FUSION_NO_TOOLS_CAPABILITY,
70
+ evaluatorTools: [],
71
+ mergeCapability: FUSION_NO_TOOLS_CAPABILITY,
72
+ mergeTools: [],
73
+ candidateSystemPrompt: fusionCandidateSystemPrompt,
74
+ evaluatorSystemPrompt: FUSION_EVALUATOR_SYSTEM_PROMPT,
75
+ evaluationRepairSystemPrompt: FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
76
+ mergerSystemPrompt: FUSION_MERGER_SYSTEM_PROMPT,
77
+ label: 'fusion reason',
78
+ });
79
+
80
+ export const FUSION_INVESTIGATE_WORKFLOW = freezeProfile({
81
+ id: 'investigate',
82
+ publicName: FUSION_INVESTIGATE_TOOL_NAME,
83
+ toolName: FUSION_INVESTIGATE_TOOL_NAME,
84
+ runIdPrefix: 'investigate-',
85
+ contextKind: 'clean_task',
86
+ candidateCapability: 'inspect',
87
+ candidateTools: FUSION_INSPECT_TOOLS,
88
+ evaluatorCapability: FUSION_NO_TOOLS_CAPABILITY,
89
+ evaluatorTools: [],
90
+ mergeCapability: FUSION_NO_TOOLS_CAPABILITY,
91
+ mergeTools: [],
64
92
  candidateSystemPrompt: fusionCandidateSystemPrompt,
65
93
  evaluatorSystemPrompt: FUSION_EVALUATOR_SYSTEM_PROMPT,
66
94
  evaluationRepairSystemPrompt: FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
67
95
  mergerSystemPrompt: FUSION_MERGER_SYSTEM_PROMPT,
68
- label: 'fusion',
96
+ label: 'fusion investigate',
69
97
  });
70
98
 
71
- export const FUSION_VALIDATE_WORKFLOW: FusionWorkflowProfile = Object.freeze({
99
+ export const FUSION_RESEARCH_WORKFLOW = freezeProfile({
100
+ id: 'research',
101
+ publicName: FUSION_RESEARCH_TOOL_NAME,
102
+ toolName: FUSION_RESEARCH_TOOL_NAME,
103
+ runIdPrefix: 'research-',
104
+ contextKind: 'clean_task',
105
+ candidateCapability: 'research',
106
+ candidateTools: FUSION_RESEARCH_TOOLS,
107
+ evaluatorCapability: FUSION_NO_TOOLS_CAPABILITY,
108
+ evaluatorTools: [],
109
+ mergeCapability: FUSION_NO_TOOLS_CAPABILITY,
110
+ mergeTools: [],
111
+ candidateSystemPrompt: fusionCandidateSystemPrompt,
112
+ evaluatorSystemPrompt: FUSION_EVALUATOR_SYSTEM_PROMPT,
113
+ evaluationRepairSystemPrompt: FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
114
+ mergerSystemPrompt: FUSION_MERGER_SYSTEM_PROMPT,
115
+ label: 'fusion research',
116
+ });
117
+
118
+ export const FUSION_VALIDATE_WORKFLOW = freezeProfile({
72
119
  id: 'validate',
120
+ publicName: FUSION_VALIDATE_TOOL_NAME,
73
121
  toolName: FUSION_VALIDATE_TOOL_NAME,
74
- runIdPrefix: 'v',
75
- capabilityPolicy: 'fixed',
76
- fixedCapability: FUSION_VALIDATE_CAPABILITY,
77
- defaultCapability: FUSION_VALIDATE_CAPABILITY,
122
+ runIdPrefix: 'validate-',
123
+ contextKind: 'clean_task',
124
+ candidateCapability: FUSION_VALIDATE_CAPABILITY,
125
+ candidateTools: FUSION_INSPECT_TOOLS,
126
+ evaluatorCapability: FUSION_NO_TOOLS_CAPABILITY,
127
+ evaluatorTools: [],
128
+ mergeCapability: FUSION_NO_TOOLS_CAPABILITY,
129
+ mergeTools: [],
78
130
  candidateSystemPrompt: fusionValidateCandidateSystemPrompt,
79
131
  evaluatorSystemPrompt: FUSION_VALIDATE_EVALUATOR_SYSTEM_PROMPT,
80
132
  evaluationRepairSystemPrompt: FUSION_VALIDATE_EVALUATION_REPAIR_SYSTEM_PROMPT,
81
133
  mergerSystemPrompt: FUSION_VALIDATE_MERGER_SYSTEM_PROMPT,
82
- label: 'validate',
134
+ label: 'fusion validate',
83
135
  });
84
136
 
85
137
  const PROFILES_BY_ID: Readonly<Record<FusionWorkflowId, FusionWorkflowProfile>> = Object.freeze({
86
- brainstorm: FUSION_BRAINSTORM_WORKFLOW,
138
+ reason: FUSION_REASON_WORKFLOW,
139
+ investigate: FUSION_INVESTIGATE_WORKFLOW,
140
+ research: FUSION_RESEARCH_WORKFLOW,
87
141
  validate: FUSION_VALIDATE_WORKFLOW,
88
142
  });
89
143
 
144
+ export const FUSION_WORKFLOW_PROFILES = Object.freeze([
145
+ FUSION_REASON_WORKFLOW,
146
+ FUSION_INVESTIGATE_WORKFLOW,
147
+ FUSION_RESEARCH_WORKFLOW,
148
+ FUSION_VALIDATE_WORKFLOW,
149
+ ] as const);
150
+
90
151
  export function fusionWorkflowProfile(id: FusionWorkflowId): FusionWorkflowProfile {
91
152
  const profile = PROFILES_BY_ID[id];
92
153
  if (profile === undefined) {
@@ -98,33 +159,26 @@ export function fusionWorkflowProfile(id: FusionWorkflowId): FusionWorkflowProfi
98
159
  return profile;
99
160
  }
100
161
 
101
- /**
102
- * Resolve the candidate capability for one run under its workflow's policy.
103
- *
104
- * A `fixed` workflow rejects each other capability instead of quietly substituting
105
- * its own: silently accepting `reason` for a validation run would produce a review
106
- * that never read the code, which is exactly the failure this workflow exists to
107
- * prevent.
108
- */
109
- export function resolveWorkflowCapability(
162
+ export function assertWorkflowCapability(
110
163
  profile: FusionWorkflowProfile,
111
164
  requested: FusionCapability | undefined,
112
165
  ): FusionCapability {
113
- if (profile.capabilityPolicy === 'caller_selected') {
114
- return requested ?? profile.defaultCapability;
115
- }
116
- const fixed = profile.fixedCapability;
117
- if (fixed === undefined) {
166
+ if (requested !== undefined && requested !== profile.candidateCapability) {
118
167
  throw new FusionError(
119
- `fusion workflow ${profile.id} declares a fixed capability policy without a capability`,
168
+ `fusion workflow ${profile.id} always runs candidates with the ${profile.candidateCapability} capability; received ${String(requested)}`,
120
169
  { code: 'orchestration_failed', childCreated: false },
121
170
  );
122
171
  }
123
- if (requested !== undefined && requested !== fixed) {
124
- throw new FusionError(
125
- `fusion workflow ${profile.id} always runs candidates with the ${fixed} capability; received ${String(requested)}`,
126
- { code: 'orchestration_failed', childCreated: false },
127
- );
128
- }
129
- return fixed;
172
+ return profile.candidateCapability;
130
173
  }
174
+
175
+ /** @deprecated v4 artifact/testing alias. The retired public tool is never registered. */
176
+ export const FUSION_BRAINSTORM_WORKFLOW = FUSION_REASON_WORKFLOW;
177
+
178
+ export const FUSION_REASON = FUSION_REASON_WORKFLOW;
179
+ export const FUSION_INVESTIGATE = FUSION_INVESTIGATE_WORKFLOW;
180
+ export const FUSION_RESEARCH = FUSION_RESEARCH_WORKFLOW;
181
+ export const FUSION_VALIDATE = FUSION_VALIDATE_WORKFLOW;
182
+
183
+ /** @deprecated v5 workflows do not default; retained for old imports. */
184
+ export const resolveWorkflowCapability = assertWorkflowCapability;
package/src/extension.ts CHANGED
@@ -559,7 +559,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
559
559
  const current = PACKAGE_VERSION ?? 'unknown';
560
560
  const latest = latestKnownVersion;
561
561
  const pinnedNpm = latest ? `${PACKAGE_NAME}@${latest}` : `${PACKAGE_NAME}@<version>`;
562
- const pinnedGit = latest ? `${GIT_INSTALL_TARGET}@v${latest}` : `${GIT_INSTALL_TARGET}@<tag>`;
563
562
  const lines = [
564
563
  latest
565
564
  ? `pi-background-tasks ${current} is installed; ${latest} is the latest published version.`
@@ -567,8 +566,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
567
566
  'Update from npm:',
568
567
  ` pi install npm:${PACKAGE_NAME}@latest`,
569
568
  ` pi install npm:${pinnedNpm}`,
570
- 'Or update from git tags:',
571
- ` pi install ${pinnedGit}`,
569
+ 'Git releases are independent of npm versions; use main only when you want current repository state:',
570
+ ` pi install ${GIT_INSTALL_TARGET}@main`,
571
+ `For a pinned git release, first verify the tag exists, then use ${GIT_INSTALL_TARGET}@<existing-tag>.`,
572
572
  'This command only prints update instructions; it does not install or self-update.',
573
573
  ];
574
574
  ctx.ui.notify(lines.join('\n'), 'info');