@agwab/pi-workflow 0.3.0 → 0.4.0

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 (90) hide show
  1. package/README.md +3 -1
  2. package/dist/artifact-graph-runtime.d.ts +1 -1
  3. package/dist/artifact-graph-runtime.js +10 -5
  4. package/dist/artifact-graph-schema.js +127 -5
  5. package/dist/compiler.js +46 -11
  6. package/dist/dynamic-decision.d.ts +1 -0
  7. package/dist/dynamic-decision.js +7 -0
  8. package/dist/dynamic-generated-task-runtime.js +3 -1
  9. package/dist/dynamic-profiles.d.ts +1 -0
  10. package/dist/dynamic-profiles.js +3 -0
  11. package/dist/engine-run-graph.d.ts +2 -0
  12. package/dist/engine-run-graph.js +55 -5
  13. package/dist/engine.js +278 -15
  14. package/dist/extension.js +3 -2
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.js +4 -0
  17. package/dist/prompt-json.d.ts +7 -0
  18. package/dist/prompt-json.js +13 -0
  19. package/dist/roles.d.ts +1 -1
  20. package/dist/roles.js +5 -8
  21. package/dist/store.d.ts +20 -1
  22. package/dist/store.js +89 -29
  23. package/dist/strings.d.ts +11 -0
  24. package/dist/strings.js +24 -0
  25. package/dist/subagent-backend.js +557 -13
  26. package/dist/types.d.ts +101 -1
  27. package/dist/verification-ontology.d.ts +31 -0
  28. package/dist/verification-ontology.js +66 -0
  29. package/dist/workflow-artifact-tool.js +5 -6
  30. package/dist/workflow-artifacts.d.ts +7 -0
  31. package/dist/workflow-artifacts.js +55 -4
  32. package/dist/workflow-fetch-cache-extension.d.ts +1 -0
  33. package/dist/workflow-fetch-cache-extension.js +57 -9
  34. package/dist/workflow-metrics.d.ts +113 -0
  35. package/dist/workflow-metrics.js +272 -0
  36. package/dist/workflow-output-artifacts.js +5 -3
  37. package/dist/workflow-partial-output.d.ts +45 -0
  38. package/dist/workflow-partial-output.js +205 -0
  39. package/dist/workflow-progress-health.js +42 -10
  40. package/dist/workflow-web-source-extension.js +27 -4
  41. package/dist/workflow-web-source.js +26 -12
  42. package/docs/usage.md +76 -29
  43. package/node_modules/@agwab/pi-subagent/package.json +1 -1
  44. package/node_modules/@agwab/pi-subagent/src/index.ts +53 -5
  45. package/node_modules/@agwab/pi-subagent/src/panel.ts +7 -3
  46. package/package.json +2 -2
  47. package/skills/workflow-guide/SKILL.md +1 -0
  48. package/src/artifact-graph-runtime.ts +19 -13
  49. package/src/artifact-graph-schema.ts +143 -3
  50. package/src/cli.mjs +52 -0
  51. package/src/compiler.ts +49 -9
  52. package/src/dynamic-decision.ts +11 -0
  53. package/src/dynamic-generated-task-runtime.ts +3 -1
  54. package/src/dynamic-profiles.ts +4 -0
  55. package/src/engine-run-graph.ts +63 -4
  56. package/src/engine.ts +400 -14
  57. package/src/extension.ts +3 -2
  58. package/src/index.ts +49 -0
  59. package/src/prompt-json.ts +13 -0
  60. package/src/roles.ts +6 -9
  61. package/src/store.ts +123 -34
  62. package/src/strings.ts +38 -0
  63. package/src/subagent-backend.ts +727 -41
  64. package/src/types.ts +110 -2
  65. package/src/verification-ontology.ts +88 -0
  66. package/src/workflow-artifact-tool.ts +5 -7
  67. package/src/workflow-artifacts.ts +83 -3
  68. package/src/workflow-fetch-cache-extension.ts +78 -13
  69. package/src/workflow-metrics.ts +478 -0
  70. package/src/workflow-output-artifacts.ts +5 -3
  71. package/src/workflow-partial-output.ts +299 -0
  72. package/src/workflow-progress-health.ts +47 -15
  73. package/src/workflow-web-source-extension.ts +33 -4
  74. package/src/workflow-web-source.ts +36 -12
  75. package/workflows/README.md +7 -25
  76. package/workflows/deep-research/batched-verification.spec.json +253 -0
  77. package/workflows/deep-research/helpers/batch-verification-candidates.mjs +136 -0
  78. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +173 -20
  79. package/workflows/deep-research/helpers/normalize-input-packet.mjs +80 -1
  80. package/workflows/deep-research/helpers/render-executive.mjs +32 -5
  81. package/workflows/deep-research/helpers/shadow-select-verification.mjs +229 -0
  82. package/workflows/deep-research/helpers/verification-ontology.mjs +77 -0
  83. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +3 -2
  84. package/workflows/deep-research/schemas/deep-research-research-questions-control.schema.json +38 -0
  85. package/workflows/deep-research/schemas/deep-research-sanitize-claims-control.schema.json +63 -0
  86. package/workflows/deep-research/schemas/deep-research-verify-claims-batch-control.schema.json +47 -0
  87. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +10 -3
  88. package/workflows/deep-research/spec.json +32 -12
  89. package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stderr +0 -0
  90. package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stdout +0 -13
@@ -608,8 +608,8 @@ async function cachedFetchFailureResult(config, cache, key, failure) {
608
608
  }
609
609
  return errorToolResult(failure.code, failure.message, failure.extra);
610
610
  }
611
- const FETCH_LOCK_STALE_MS = 60_000;
612
- const FETCH_LOCK_WAIT_MS = 75_000;
611
+ const FETCH_LOCK_STALE_MS = 4 * 60_000;
612
+ const FETCH_LOCK_WAIT_MS = 5 * 60_000;
613
613
  async function withWorkflowWebFetchLock(config, key, signal, fn) {
614
614
  const release = await acquireWorkflowWebFetchLock(config, key, signal);
615
615
  try {
@@ -627,10 +627,11 @@ async function acquireWorkflowWebFetchLock(config, key, signal) {
627
627
  if (signal?.aborted)
628
628
  throw new Error("aborted");
629
629
  try {
630
+ const ownerId = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
630
631
  await mkdir(lockDir);
631
- await writeFile(resolve(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), key }, null, 2)}\n`, "utf8");
632
+ await writeFile(resolve(lockDir, "owner.json"), `${JSON.stringify({ ownerId, pid: process.pid, createdAt: new Date().toISOString(), key }, null, 2)}\n`, "utf8");
632
633
  return async () => {
633
- await rm(lockDir, { recursive: true, force: true });
634
+ await releaseWorkflowWebFetchLock(lockDir, ownerId);
634
635
  };
635
636
  }
636
637
  catch (error) {
@@ -644,6 +645,17 @@ async function acquireWorkflowWebFetchLock(config, key, signal) {
644
645
  }
645
646
  }
646
647
  }
648
+ async function releaseWorkflowWebFetchLock(lockDir, ownerId) {
649
+ try {
650
+ const current = await readFetchLockOwner(lockDir);
651
+ if (current?.ownerId !== ownerId)
652
+ return;
653
+ await rm(lockDir, { recursive: true, force: true });
654
+ }
655
+ catch {
656
+ // Missing or unreadable lock will be retried by the caller.
657
+ }
658
+ }
647
659
  async function removeStaleFetchLock(lockDir) {
648
660
  try {
649
661
  const current = await stat(lockDir);
@@ -655,6 +667,17 @@ async function removeStaleFetchLock(lockDir) {
655
667
  // Missing or unreadable lock will be retried by the caller.
656
668
  }
657
669
  }
670
+ async function readFetchLockOwner(lockDir) {
671
+ try {
672
+ const parsed = JSON.parse(await readFile(resolve(lockDir, "owner.json"), "utf8"));
673
+ return isRecord(parsed) && typeof parsed.ownerId === "string"
674
+ ? { ownerId: parsed.ownerId }
675
+ : undefined;
676
+ }
677
+ catch {
678
+ return undefined;
679
+ }
680
+ }
658
681
  async function readDurableFetchFailure(config, key) {
659
682
  try {
660
683
  const parsed = JSON.parse(await readFile(fetchFailurePath(config, key), "utf8"));
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { appendFile, mkdir, readFile, readdir, rename, writeFile, } from "node:fs/promises";
3
3
  import { isIP } from "node:net";
4
4
  import { dirname, resolve } from "node:path";
5
+ import { compactStrings } from "./strings.js";
5
6
  export const WORKFLOW_WEB_SOURCE_CACHE_SCHEMA = "workflow-web-source-cache-v1";
6
7
  export const WORKFLOW_WEB_SOURCE_INDEX_SCHEMA = "workflow-web-source-index-v1";
7
8
  export const WORKFLOW_WEB_SOURCE_INDEX_EVENT_SCHEMA = "workflow-web-source-index-event-v1";
@@ -22,7 +23,7 @@ export const DEFAULT_WORKFLOW_WEB_SECURITY_POLICY = {
22
23
  allowPrivateHosts: false,
23
24
  cacheRawProviderPayloads: false,
24
25
  };
25
- const SENSITIVE_QUERY_PARAM_PATTERN = /(^|[-_])(access[-_]?token|auth|code|credential|key|password|secret|session|signature|sig|token)([-_]|$)/i;
26
+ const SENSITIVE_QUERY_PARAM_PATTERN = /(^|[-_])(access[-_]?token|auth|code|credential|key|password|secret|session|session[-_]?id|sessionid|signature|sig|sid|jwt|token)([-_]|$)/i;
26
27
  const PRIVATE_HOST_PATTERNS = [
27
28
  /^localhost$/i,
28
29
  /^127\./,
@@ -165,7 +166,7 @@ export function createWorkflowWebSource(options) {
165
166
  redactedUrl,
166
167
  urlKey: sourceUrlCacheKey(options.url),
167
168
  domain,
168
- ...(options.title ? { title: options.title } : {}),
169
+ ...(options.title ? { title: redactInlineSecrets(options.title) } : {}),
169
170
  ...(options.provider ? { provider: options.provider } : {}),
170
171
  contentHash,
171
172
  text: options.text,
@@ -355,15 +356,12 @@ export function extractTextFromToolResult(result) {
355
356
  const content = result.content;
356
357
  if (!Array.isArray(content))
357
358
  return "";
358
- return content
359
- .map((entry) => {
359
+ return compactStrings(content.map((entry) => {
360
360
  if (!isRecord(entry))
361
361
  return "";
362
362
  const text = entry.text;
363
363
  return typeof text === "string" ? text : "";
364
- })
365
- .filter(Boolean)
366
- .join("\n\n");
364
+ }), { trim: false, unique: false }).join("\n\n");
367
365
  }
368
366
  export function extractTitleFromToolResult(result) {
369
367
  if (!isRecord(result))
@@ -446,7 +444,7 @@ function sourceToIndexEntry(source) {
446
444
  redactedUrl: source.redactedUrl,
447
445
  ...(source.urlKey ? { urlKey: source.urlKey } : {}),
448
446
  domain: source.domain,
449
- ...(source.title ? { title: source.title } : {}),
447
+ ...(source.title ? { title: redactInlineSecrets(source.title) } : {}),
450
448
  contentHash: source.contentHash,
451
449
  textChars: source.textChars,
452
450
  ...(source.provider ? { provider: source.provider } : {}),
@@ -702,9 +700,15 @@ function consumeAnchoredSnippet(options) {
702
700
  }
703
701
  const raw = redactInlineSecrets(options.text.slice(sourceStart, sourceEnd));
704
702
  const consumed = consumeWorkflowWebVisibleBudget(options.budget, raw, visibleLimit);
703
+ // Redaction can expand secrets. Promote only when the redacted anchor
704
+ // itself no longer fits; clipping trailing context can remain a match.
705
+ const redactedThroughAnchorLength = consumed.truncated
706
+ ? redactInlineSecrets(options.text.slice(sourceStart, Math.min(sourceEnd, anchorEnd))).length
707
+ : 0;
708
+ const anchorTruncated = status === "truncated" || redactedThroughAnchorLength > visibleLimit;
705
709
  const truncated = status === "truncated" || consumed.truncated;
706
710
  return {
707
- status,
711
+ status: anchorTruncated ? "truncated" : status,
708
712
  quote: consumed.text,
709
713
  visibleChars: consumed.text.length,
710
714
  sourceStart,
@@ -737,7 +741,15 @@ function normalizeForSearch(text) {
737
741
  map.push(index);
738
742
  }
739
743
  }
740
- return { normalized: normalized.trim(), map };
744
+ while (normalized.startsWith(" ")) {
745
+ normalized = normalized.slice(1);
746
+ map.shift();
747
+ }
748
+ while (normalized.endsWith(" ")) {
749
+ normalized = normalized.slice(0, -1);
750
+ map.pop();
751
+ }
752
+ return { normalized, map };
741
753
  }
742
754
  function nearbySnippet(text, needle, maxChars) {
743
755
  const index = text.indexOf(needle);
@@ -834,7 +846,9 @@ function sourceIndexEntryFromUnknown(value) {
834
846
  redactedUrl: value.redactedUrl,
835
847
  ...(typeof value.urlKey === "string" ? { urlKey: value.urlKey } : {}),
836
848
  domain: value.domain,
837
- ...(typeof value.title === "string" ? { title: value.title } : {}),
849
+ ...(typeof value.title === "string"
850
+ ? { title: redactInlineSecrets(value.title) }
851
+ : {}),
838
852
  contentHash: value.contentHash,
839
853
  textChars: Number(value.textChars),
840
854
  ...(typeof value.provider === "string" ? { provider: value.provider } : {}),
@@ -972,7 +986,7 @@ function redactInlineSecrets(value) {
972
986
  function redactInlineSecretsNoUrls(value) {
973
987
  return value
974
988
  .replace(/(authorization|cookie|set-cookie):\s*[^\n\r]+/gi, "$1: REDACTED")
975
- .replace(/(token|secret|password|api[-_]?key)=([^\s&]+)/gi, "$1=REDACTED")
989
+ .replace(/(token|secret|password|api[-_]?key|jwt|sid|sessionid|session[-_]?id)=([^\s&]+)/gi, "$1=REDACTED")
976
990
  .replace(/\/Users\/[^\s:'")]+/g, "/Users/REDACTED");
977
991
  }
978
992
  function isRecord(value) {
package/docs/usage.md CHANGED
@@ -114,9 +114,11 @@ For reusable workflow authoring, `workflow-guide` includes validated scaffold bu
114
114
  | `/workflow show <run-id-or-workflow-name>` | If the ref starts with `workflow_`, show run details; otherwise show the raw workflow spec. |
115
115
  | `/workflow logs <run-id> [task-id] [lines]` | Print captured logs for a workflow task. Defaults to `task-1`. |
116
116
  | `/workflow wait <run-id> [timeout-ms]` | Poll until the run finishes or the optional timeout elapses. |
117
+ | `/workflow stop <run-id>` | Interrupt a non-terminal run, best-effort interrupt active subagents, mark unfinished tasks interrupted, and stop the local supervisor watch. Use `/workflow resume <run-id>` if you want to restart unfinished work later. |
117
118
  | `/workflow resume <run-id>` | Resume a failed, interrupted, or resumable blocked run (including dynamic approval blocked in headless mode): completed tasks are preserved; failed/interrupted/skipped or resumable blocked tasks reset to pending and reschedule. Loop workflows are not supported yet. |
119
+ | `/workflow stop <run-id>` | Stop a non-terminal run: best-effort interrupt of active subagent workers, then mark unfinished tasks `interrupted`. Completed task artifacts are preserved, and the stopped run can be restarted later with `/workflow resume` (resumed tasks start fresh sessions). |
118
120
 
119
- Not implemented: `/workflow continue` and `/workflow delegate`. Use `status`, `show`, `logs`, `wait`, `resume`, and `pi-workflow inspect` for text/CLI inspection. The standalone CLI also offers `pi-workflow supervise <run-id>|--all` to drive scheduling from outside a Pi session (unfinished failed/interrupted or resumable blocked runs within the last 7 days are announced at session start with resume hints).
121
+ Not implemented: `/workflow continue` and `/workflow delegate`. Use `status`, `show`, `logs`, `wait`, `stop`, `resume`, and `pi-workflow inspect` for text/CLI inspection. The standalone CLI also offers `pi-workflow supervise <run-id>|--all` to drive scheduling from outside a Pi session (unfinished failed/interrupted or resumable blocked runs within the last 7 days are announced at session start with resume hints).
120
122
 
121
123
  ### Workflow board controls
122
124
 
@@ -198,13 +200,30 @@ For lower-latency runs, pass `--thinking low` explicitly:
198
200
 
199
201
  This is an opt-in fast mode. Package defaults remain conservative until a separate holdout evaluation provides enough evidence to change them. Current evidence is limited but encouraging for explicit fast runs: the 2026-07-02 `deep-research` combined gate on P1/P2/P3-style prompts resolved non-support tasks to `low`, completed selected valid runs in about 15-17 minutes, passed the strict gate 9/9, and had zero source-ref join failures across those 9 runs. Treat this as a speed option, not proof that every workflow should default to `low`.
200
202
 
203
+ ### Opt-in batched verification for deep-research
204
+
205
+ `deep-research` still verifies one claim per verifier task by default. For controlled runs where verifier batching is acceptable, use the explicit path-ref variant:
206
+
207
+ ```text
208
+ /workflow validate ./workflows/deep-research/batched-verification.spec.json
209
+ /workflow run ./workflows/deep-research/batched-verification.spec.json "Research this repository and verify the key claims."
210
+ ```
211
+
212
+ This path-ref variant keeps the same planner/research/normalization/audit/final stages, but feeds `verify-claims` from `verification-batches` and requires each verifier task to return one `results[]` row per claim id. It is not registered as an official bundled workflow name and does not change package defaults. Treat speed/cost results as task-specific: claim a win only when the run's audit reports zero missing/duplicate/invalid verifier rows, zero sourceRef join failures, and no verified-floor regression.
213
+
214
+ ### Verification outcome ontology
215
+
216
+ The package exports a small verification outcome vocabulary for workflows that verify source-backed claims: `verified`, `partially_supported`, `unsupported`, `conflicting`, and `verification_blocked`. Bundled workflow helpers must use bundle-local shims that stay in parity with the package export, because helper imports are bundled from the workflow spec directory. `verification_blocked` means the verifier could not evaluate the claim because required evidence, source access, tool execution, or policy constraints blocked verification. It is not a weaker form of `verified`, never counts toward verified floors, and should remain visible in audit summaries so operators can decide whether to rerun, change source access, or treat the claim as unresolved.
217
+
218
+ Adopt this vocabulary only for evidence-verification outcomes. Do not force it onto workflow-control, finding-disposition, or ship-readiness verdicts such as `KEEP`, `DROP`, `READY`, or `NEEDS_WORK`. Deep-diff-review revival is not part of this ontology change.
219
+
201
220
  ### Run-scoped web-source cache
202
221
 
203
222
  Prefer normalized workflow web tools in new workflows:
204
223
 
205
224
  - `workflow_web_search` returns compact candidate cards.
206
225
  - `workflow_web_fetch_source` caches one or more URLs and returns compact source cards with `sourceRef` values; pass `urls: [...]` or `sources: [{ url, title }]` to batch several fetches in one tool call.
207
- - `workflow_web_source_read` reads narrow exact/fuzzy/term-matched evidence snippets by `sourceRef`; pass `queries: [...]` or `reads: [...]` to batch several snippets from the same source in one tool call, or `claim` + distinctive `terms` when the exact quote is unknown. Term/claim reads return candidate metadata (`matchedTerms`, `missingTerms`, `coverageRatio`) rather than a proof verdict.
226
+ - `workflow_web_source_read` reads narrow exact/fuzzy/term-matched evidence snippets by `sourceRef`; pass `queries: [...]` or `reads: [...]` to batch several snippets from the same source in one tool call, or `claim` + distinctive `terms` when the exact quote is unknown. Term/claim reads return candidate metadata (`matchedTerms`, `missingTerms`, `coverageRatio`) rather than a proof verdict. Snippet windows are anchored to the match: a result may report `status: "truncated"` when the per-task visible budget or `maxChars` clips the window (the returned quote still starts at the match), and `status: "budget_exhausted"` when no visible budget remains; both include a `next` hint suggesting smaller queries or a fresh task.
208
227
 
209
228
  The normalized cache is stored under the workflow run directory:
210
229
 
@@ -214,7 +233,11 @@ The normalized cache is stored under the workflow run directory:
214
233
 
215
234
  Do not instruct agents to read that directory directly; source cards intentionally expose only opaque refs and short previews. The cache also writes an append-only index ledger plus same-URL fetch locks/negative-cache files so duplicate lookup and deterministic terminal failures can recover across parallel worker processes. Custom extension `fetch_content` providers are treated as trusted fetchers and are disabled under the default private-host policy; use the default safe fetch path or opt into trusted private-host behavior only for controlled providers. Legacy workflow tasks that still use `fetch_content` keep the older run-scoped file cache under `.pi/workflows/<run-id>/source-cache/fetch-content/`. Set `PI_WORKFLOW_FETCH_CONTENT_CACHE=0` to disable that legacy fetch cache for a run.
216
235
 
217
- Benchmark note: cache-enabled runs are a distinct cohort from older uncached runs. Do not compare wall-clock numbers directly unless the task set, model, and cache policy are controlled and recorded.
236
+ To reduce worker context pressure for legacy `fetch_content` tasks, the bundled
237
+ workflow fetch wrapper caps inline response text while preserving full stored
238
+ source content. Override with `PI_WORKFLOW_FETCH_CONTENT_INLINE_CHARS=<n>` or
239
+ disable the inline cap with `PI_WORKFLOW_FETCH_CONTENT_INLINE_CHARS=0` when you
240
+ intentionally need the provider's full inline response.
218
241
 
219
242
  ## Bundled workflows
220
243
 
@@ -284,7 +307,15 @@ Dynamic workflows keep JSON as the source of truth while allowing trusted bundle
284
307
  }
285
308
  ```
286
309
 
287
- Controller/helper/nested workflow refs must be bundle-local `./...` paths. Nested workflow specs are intentionally self-contained at their own directory level: refs inside a nested spec may point to files in that nested spec's subtree, but not to parent-level shared files via `../`. Put shared helpers/schemas under each nested workflow subtree or expose them through the parent controller/helper layer. Controller/helper code is trusted Node.js code for orchestration and timeout isolation, not a security sandbox. Generated agents are real workflow tasks: `ctx.agent({ id, agent, prompt, tools })` inserts a deterministic `stageId.id` task into `compiled.json` and `run.json`, persists a request hash in `dynamic/events.jsonl`, and replays fail-closed if the same id later changes request shape. On resume, controllers must re-issue previously recorded `ctx.agent`, `ctx.helper`, and `ctx.workflow` operations in the same order before issuing new operations; omitted or out-of-order replay fails closed with an explicit replay-invariant error. Use `ctx.parallel([() => ctx.agent(...), ...])` for dynamic fan-out; the runtime records queued sibling generation ops before the controller suspends, and non-suspension operation failures make the controller fail closed. Generated dependency cycles are rejected. `ctx.helper(name, input)` can call only helpers declared in `dynamic.helpers`; pure/retry-safe helpers may set `idempotent: true` so a crash after `helper.started` but before `helper.completed` can retry the helper instead of permanently failing closed. `ctx.workflow(name, input)` can call only nested specs declared in `dynamic.workflows`.
310
+ Controller/helper/nested workflow refs must be bundle-local `./...` paths. Nested workflow specs are intentionally self-contained at their own directory level: refs inside a nested spec may point to files in that nested spec's subtree, but not to parent-level shared files via `../` put shared helpers/schemas under each nested workflow subtree or expose them through the parent controller/helper layer. Controller/helper code is trusted Node.js code for orchestration and timeout isolation, not a security sandbox.
311
+
312
+ Controller context rules:
313
+
314
+ - Generated agents are real workflow tasks: `ctx.agent({ id, agent, prompt, tools })` inserts a deterministic `stageId.id` task into `compiled.json` and `run.json`, persists a request hash in `dynamic/events.jsonl`, and replays fail-closed if the same id later changes request shape.
315
+ - On resume, controllers must re-issue previously recorded `ctx.agent`, `ctx.helper`, and `ctx.workflow` operations in the same order before issuing new operations; omitted or out-of-order replay fails closed with an explicit replay-invariant error.
316
+ - Use `ctx.parallel([() => ctx.agent(...), ...])` for dynamic fan-out; the runtime records queued sibling generation ops before the controller suspends, and non-suspension operation failures make the controller fail closed. Generated dependency cycles are rejected.
317
+ - `ctx.helper(name, input)` can call only helpers declared in `dynamic.helpers`; pure/retry-safe helpers may set `idempotent: true` so a crash after `helper.started` but before `helper.completed` can retry the helper instead of permanently failing closed.
318
+ - `ctx.workflow(name, input)` can call only nested specs declared in `dynamic.workflows`.
288
319
 
289
320
  Dynamic outputs should be compact typed artifacts. The controller returns normal workflow sections through `{ control, analysis, refs }`; generated child agents must return the same `<control>`, `<analysis>`, `<refs>` protocol as other artifact-graph tasks. When a controller result includes `outputTasks`/`outputTaskIds` (the built-in decision loop sets this from accepted `synthesize` actions), downstream `from: "<dynamic-stage>"` reducers also receive those exported task artifacts as stable sources such as `<dynamic-stage>.output`. Runtime state is stored under `.pi/workflows/<run-id>/dynamic/`:
290
321
 
@@ -301,13 +332,7 @@ Budgets bound controller behavior (`maxAgents`, `maxConcurrency`, `maxRuntimeMs`
301
332
 
302
333
  ### DAG authoring
303
334
 
304
- Top-level `artifactGraph.stages` is DAG-capable by default. A nested `type: "dag"` is a workflow/control container, not a leaf subagent task: it must contain child `stages` and should not have its own prompt. The runtime lowers public graph relationships onto the internal dependency scheduler while preserving artifact/data boundaries.
305
-
306
- Keep these layers distinct:
307
-
308
- - **Workflow layer**: graph/control/data-dependency semantics such as `id`, `from`, `after`, `sourcePolicy`, `sourceProjection`, scheduling, and artifacts.
309
- - **Subagent layer**: model-backed execution patterns such as `single`, `foreach`, `reduce`, and loop child stages.
310
- - **Support layer**: deterministic local helper execution through `support: { uses, options }`.
335
+ Top-level `artifactGraph.stages` is DAG-capable by default. A nested `type: "dag"` is a workflow/control container, not a leaf subagent task: it must contain child `stages` and should not have its own prompt. The runtime lowers public graph relationships onto the internal dependency scheduler while preserving artifact/data boundaries. Keep the authoring layers described under "Stage model" distinct when composing DAGs.
311
336
 
312
337
  DAG rules:
313
338
 
@@ -405,6 +430,28 @@ Use workflow-local JSON Schema files when the control plane needs stronger valid
405
430
 
406
431
  The built-in validator supports the subset used by bundled workflows: `type`, `required`, `properties`, `items`, `enum`, `const`, length/item/number bounds, `additionalProperties`, and simple `allOf`/`anyOf`/`oneOf`. Unsupported keywords such as `$ref`, `$defs`, `definitions`, and `pattern` are rejected when the workflow is loaded.
407
432
 
433
+ ### Opt-in partial output for streaming foreach
434
+
435
+ A producer stage can declare stable array paths that may be published before terminal completion:
436
+
437
+ ```json
438
+ "output": {
439
+ "partial": { "paths": ["$.items"] }
440
+ }
441
+ ```
442
+
443
+ A downstream `foreach` may then opt in on the matching `from` edge:
444
+
445
+ ```json
446
+ "from": {
447
+ "source": "plan",
448
+ "path": "$.items",
449
+ "streaming": { "enabled": true, "minChunk": 2 }
450
+ }
451
+ ```
452
+
453
+ The runtime accepts only partial items for declared paths. Published partial items must be final/stable JSON objects with a non-empty string `id`; the producer may emit them as `<partial-control>{"schema":"workflow-partial-output-v1","path":"$.items","items":[...]}</partial-control>` before the final workflow output. If the final `control.json` later changes a published item with the same id, the streaming foreach placeholder blocks fail-closed. Downstream reducers still wait for the foreach placeholder plus all generated item tasks, so partial output overlaps item work without relaxing final fan-in gates.
454
+
408
455
  ## Support helpers
409
456
 
410
457
  A support node runs local helper code inline instead of launching a subagent. It is declared by adding a `support` object; it does not use a separate `type` value:
@@ -542,6 +589,15 @@ Authoring checklist:
542
589
  7. Add JSON output contracts for model-produced data that later stages depend on.
543
590
  8. Run `/workflow validate <workflow-or-file>` before using the workflow.
544
591
 
592
+ ### Roles
593
+
594
+ A workflow can declare reusable role context under top-level `roles`. Compiled role text is injected into subagent task prompts as a `# Role Context` block, and `/workflow roles <workflow>` shows the compiled result per role. Role fields:
595
+
596
+ - `fromAgent`: extract sections from a discoverable Pi agent's markdown body. By default only safe knowledge sections are included (`Core Principles`, `Domain Expertise`, `Safety Review`, `Rules`, `Research Manifest`); orchestration and output-format sections are always excluded.
597
+ - `includeSections` / `excludeSections`: override which agent sections are extracted.
598
+ - `prompt`: literal role text, appended after any extracted agent sections.
599
+ - `maxChars`: compiled role budget (default 12000). Longer content is truncated and flagged in `/workflow roles` output.
600
+
545
601
  ### Tool allowlists
546
602
 
547
603
  Workflow `tools` are still the child-worker allowlist. Entries can be strings:
@@ -578,26 +634,17 @@ Scope order is agent frontmatter fallback < `defaults.tools` < stage `tools`: th
578
634
  - Write-capable workflows should use managed worktrees in git repositories.
579
635
  - In non-git workspaces with `worktreePolicy: "off"`, writes mutate the live directory.
580
636
  - No backend fallback exists. The compiled backend/strategy is fixed for the run.
637
+ - Subagent process launches are gated per Pi process to avoid boot storms: at most `max(2, floor(cpu cores / 2))` concurrent launches, overridable with the `PI_WORKFLOW_MAX_CONCURRENT_LAUNCHES` environment variable. Queued tasks report a waiting message in their status. Deterministic boot failures (extension load or configuration errors) fail fast instead of consuming transient-failure retries.
581
638
  - External content, source files, and web pages used by workflow workers are untrusted data, not instructions.
582
639
 
583
640
  ## Web tools
584
641
 
585
642
  New workflows should use `workflow_web_search`, `workflow_web_fetch_source`, and
586
- `workflow_web_source_read`. These tools route through a workflow web-source
587
- adapter, return compact model-visible cards/snippets, and preserve full source
588
- text in a run-scoped cache when safe. Fetch accepts `urls: [...]` and
589
- `sources: [{ url, title }]` so agents can cache several source cards in one call.
590
- Source-read accepts `queries: [...]` and `reads: [...]` so agents can retrieve
591
- several snippets from one `sourceRef` in a single call, and accepts `claim` +
592
- distinctive `terms` for deterministic quote
593
- candidate harvesting when the exact quote is unknown. Term/claim matches are
594
- candidate evidence and include matched/missing term metadata; they are not a
595
- verdict by themselves. The bundled `pi-web-access` adapter remains
596
- available as the default compatibility provider for this release scope.
597
-
598
- Legacy workflows that use `web_search`, `fetch_content`, `get_search_content`, or
599
- `code_search` still use the bundled `pi-web-access` dependency packaged with
600
- pi-workflow. Object-form custom tool `extensions` are merged with built-in
601
- mappings and deduplicated for the subagent launch. Web calls can still fail when
602
- network access, provider credentials, browser state, or quota are unavailable;
603
- research workflows should report those limits instead of guessing.
643
+ `workflow_web_source_read` tool semantics, batching forms, and the run-scoped
644
+ cache are documented under "Run-scoped web-source cache" above. The bundled
645
+ `pi-web-access` adapter remains the default compatibility provider for this
646
+ release scope.
647
+
648
+ - Legacy workflows that use `web_search`, `fetch_content`, `get_search_content`, or `code_search` still use the bundled `pi-web-access` dependency packaged with pi-workflow.
649
+ - Object-form custom tool `extensions` are merged with built-in mappings and deduplicated for the subagent launch.
650
+ - Web calls can still fail when network access, provider credentials, browser state, or quota are unavailable; research workflows should report those limits instead of guessing.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agwab/pi-subagent",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Minimal subagent runtime for Pi.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -210,6 +210,52 @@ function getExecuteParams(first: unknown, second: unknown): unknown {
210
210
  return second === undefined ? first : second;
211
211
  }
212
212
 
213
+ function isAbortSignalLike(value: unknown): value is AbortSignal {
214
+ return isRecord(value) && typeof value.aborted === "boolean";
215
+ }
216
+
217
+ function normalizeExecuteArgs(args: unknown[]): {
218
+ params: unknown;
219
+ signal?: AbortSignal;
220
+ onUpdate?: ToolUpdateCallback;
221
+ ctx?: unknown;
222
+ } {
223
+ const [first, second, third, fourth, fifth] = args;
224
+ const params = getExecuteParams(first, second);
225
+
226
+ // Pi has shipped both execute(toolCallId, params, signal, onUpdate, ctx)
227
+ // and execute(toolCallId, params, onUpdate, ctx, signal) call orders. Support
228
+ // both so context-scoped metadata (cwd/session) and cancellation survive either
229
+ // host version.
230
+ if (typeof third === "function") {
231
+ return {
232
+ params,
233
+ onUpdate: third as ToolUpdateCallback,
234
+ ctx: fourth,
235
+ ...(isAbortSignalLike(fifth) ? { signal: fifth } : {}),
236
+ };
237
+ }
238
+
239
+ if (isAbortSignalLike(fifth) && !isAbortSignalLike(third)) {
240
+ return {
241
+ params,
242
+ signal: fifth,
243
+ ...(typeof fourth === "function"
244
+ ? { onUpdate: fourth as ToolUpdateCallback }
245
+ : { ctx: fourth }),
246
+ };
247
+ }
248
+
249
+ return {
250
+ params,
251
+ ...(isAbortSignalLike(third) ? { signal: third } : {}),
252
+ ...(typeof fourth === "function"
253
+ ? { onUpdate: fourth as ToolUpdateCallback }
254
+ : {}),
255
+ ctx: fifth,
256
+ };
257
+ }
258
+
213
259
  function getCwd(ctx: unknown): string {
214
260
  if (isRecord(ctx) && typeof ctx.cwd === "string" && ctx.cwd.length > 0)
215
261
  return ctx.cwd;
@@ -595,9 +641,10 @@ async function writeUnsupportedResult(
595
641
  });
596
642
  }
597
643
 
598
- interface ToolUpdateCallback {
599
- (update: { content: ToolTextContent[]; details: unknown }): void;
600
- }
644
+ type ToolUpdateCallback = (update: {
645
+ content: ToolTextContent[];
646
+ details: unknown;
647
+ }) => void;
601
648
 
602
649
  interface NotificationContext {
603
650
  ui?: {
@@ -957,8 +1004,9 @@ export default function registerSubagentEngine(pi: ExtensionAPI) {
957
1004
  : summary;
958
1005
  return new SingleLineComponent(`${title} ${theme.fg("muted", rest)}`);
959
1006
  },
960
- async execute(toolCallIdOrArgs, maybeParams, signal, onUpdate, ctx) {
961
- const params = getExecuteParams(toolCallIdOrArgs, maybeParams);
1007
+ async execute(...executeArgs: unknown[]) {
1008
+ const { params, signal, onUpdate, ctx } =
1009
+ normalizeExecuteArgs(executeArgs);
962
1010
  const cwd = getCwd(ctx);
963
1011
 
964
1012
  try {
@@ -512,12 +512,14 @@ async function readTask(
512
512
  mtimeMs: number,
513
513
  loadTails: boolean,
514
514
  currentSessionId?: string,
515
+ options: { staleOverride?: boolean } = {},
515
516
  ): Promise<TaskRow | null> {
516
517
  const parsed = await readJson(resultPath);
517
518
  if (!isResultEnvelope(parsed)) return null;
518
519
  const log = await readLogTail(cwd, parsed, loadTails, currentSessionId);
519
520
  const stale =
520
- isActive(parsed.status) && Date.now() - mtimeMs > STALE_RUN_AFTER_MS;
521
+ isActive(parsed.status) &&
522
+ (options.staleOverride ?? Date.now() - mtimeMs > STALE_RUN_AFTER_MS);
521
523
  return {
522
524
  attemptId: parsed.attemptId ?? parsed.taskId ?? "unknown",
523
525
  status: stale ? "failed" : parsed.status,
@@ -571,6 +573,7 @@ async function readTaskFromRegistry(
571
573
  loadTails: boolean,
572
574
  currentSessionId?: string,
573
575
  ): Promise<TaskRow> {
576
+ const registryStale = registryTaskStale(task);
574
577
  if (
575
578
  task.artifactCwd !== undefined &&
576
579
  task.resultPath !== undefined &&
@@ -590,13 +593,14 @@ async function readTaskFromRegistry(
590
593
  statInfo.mtimeMs,
591
594
  loadTails,
592
595
  currentSessionId,
596
+ isActive(task.status) ? { staleOverride: registryStale } : undefined,
593
597
  );
594
598
  if (parsed !== null) return parsed;
595
599
  }
596
600
  }
597
601
  }
598
602
  const log = await readTailFromRegistryPath(task, loadTails, currentSessionId);
599
- const stale = registryTaskStale(task);
603
+ const stale = registryStale;
600
604
  return {
601
605
  attemptId: task.attemptId ?? task.taskId ?? "unknown",
602
606
  status: stale ? "failed" : task.status,
@@ -1087,7 +1091,7 @@ export class SubagentPanel implements Component {
1087
1091
  }
1088
1092
 
1089
1093
  render(width: number): string[] {
1090
- const safeWidth = Math.max(48, width);
1094
+ const safeWidth = Math.max(1, width);
1091
1095
  const maxLines = panelLineBudget();
1092
1096
  const lines: string[] = [];
1093
1097
  const active = this.snapshot.runs.filter((run) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agwab/pi-workflow",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Workflow orchestration for Pi subagents.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -76,7 +76,7 @@
76
76
  "node": ">=22.19.0"
77
77
  },
78
78
  "dependencies": {
79
- "@agwab/pi-subagent": "^0.4.0",
79
+ "@agwab/pi-subagent": "^0.4.2",
80
80
  "pi-web-access": "^0.10.7",
81
81
  "typebox": "^1.1.39"
82
82
  },
@@ -29,6 +29,7 @@ Resolve paths relative to this skill directory. Treat those docs as the source o
29
29
  - For deterministic local post-processing, declare a `support` object with `support.uses` pointing to a bundle-local `./*.mjs` helper; support is trusted local code, not sandboxed subagent work and does not use a separate `type` value.
30
30
  - For bounded iteration, use `loop` with fixed child stages, `maxRounds`, and deterministic `until`.
31
31
  - Agent-declared tools are the authority ceiling; workflow `tools` can only narrow them.
32
+ - To reuse agent knowledge across stages, declare top-level `roles` (`fromAgent` extracts safe agent sections; `prompt` appends literal text). Compiled role text is injected as a `# Role Context` block; check the result with `/workflow roles <workflow>`. See "Roles" in `docs/usage.md`.
32
33
  - Keep review/research workflows read-only unless the workflow explicitly documents managed-worktree mutation.
33
34
  - Write-capable workflows need explicit worktree policy, validation/check stages, and protected-path awareness.
34
35
  - In non-git workspaces with `worktreePolicy: "off"`, writes mutate the live directory.
@@ -2,6 +2,8 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, extname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
+ import { stringifyPromptJson } from "./prompt-json.js";
6
+ import { compactStrings } from "./strings.js";
5
7
  import { loadWorkflowHelper } from "./workflow-helpers.js";
6
8
  import {
7
9
  WORKFLOW_ARTIFACT_TOOL_NAME,
@@ -13,7 +15,7 @@ import {
13
15
  type WorkflowSourceManifestSource,
14
16
  } from "./workflow-artifact-tool.js";
15
17
  import { writeWorkflowTaskArtifactBundle } from "./workflow-output-artifacts.js";
16
- import { type JsonSchema } from "./json-schema.js";
18
+ import type { JsonSchema } from "./json-schema.js";
17
19
  import {
18
20
  buildRunSourceContext,
19
21
  readOutputText,
@@ -29,11 +31,11 @@ import {
29
31
  writeJsonAtomic,
30
32
  writeRunRecord,
31
33
  } from "./store.js";
32
- import {
33
- type CompiledTask,
34
- type CompiledWorkflow,
35
- type WorkflowRunRecord,
36
- type WorkflowTaskRunRecord,
34
+ import type {
35
+ CompiledTask,
36
+ CompiledWorkflow,
37
+ WorkflowRunRecord,
38
+ WorkflowTaskRunRecord,
37
39
  } from "./types.js";
38
40
 
39
41
  export async function executeSupportTask(
@@ -372,7 +374,9 @@ export function normalizeDynamicControllerOutput(value: unknown): {
372
374
  refs: [],
373
375
  };
374
376
  }
375
- export function normalizeSupportControl(value: unknown): Record<string, unknown> {
377
+ export function normalizeSupportControl(
378
+ value: unknown,
379
+ ): Record<string, unknown> {
376
380
  if (value && typeof value === "object" && !Array.isArray(value)) {
377
381
  const record = value as Record<string, unknown>;
378
382
  return {
@@ -474,7 +478,7 @@ export async function prepareDagTask(
474
478
  compiledTask.compiledPrompt,
475
479
  "# Source Stage Context",
476
480
  "Use this deterministic source context packet. Prefer structuredOutput over outputPreview. Do not assume dependencies beyond this explicit packet.",
477
- JSON.stringify({ ...context, missingDependencies: missing }, null, 2),
481
+ stringifyPromptJson({ ...context, missingDependencies: missing }),
478
482
  ].join("\n\n"),
479
483
  };
480
484
  }
@@ -486,6 +490,10 @@ async function prepareArtifactGraphTask(
486
490
  task: WorkflowTaskRunRecord,
487
491
  contextDependsOn: readonly string[],
488
492
  ): Promise<CompiledTask> {
493
+ if (compiledTask.artifactGraph?.artifactAccess === "none") {
494
+ return { ...compiledTask, cwd: task.cwd };
495
+ }
496
+
489
497
  const taskDir = dirname(fromProjectPath(cwd, task.files.result));
490
498
  const manifestPath = join(taskDir, "source-manifest.json");
491
499
  const ledgerPath = join(taskDir, "read-ledger.jsonl");
@@ -880,7 +888,7 @@ export function formatArtifactGraphSourceContext(
880
888
  return [
881
889
  "# Workflow Artifact Inputs",
882
890
  "Use workflow_artifact to list/read upstream workflow artifacts. Inline controlProjection fields are authoritative for the projected data they contain; use artifact reads for declared requiredReads, missing fields, or debug detail.",
883
- "Projected reads must include a JSON path when using maxItems or maxChars, for example {\"action\":\"read\",\"source\":\"plan\",\"artifact\":\"control\",\"path\":\"$.factSlots\",\"maxItems\":8,\"maxChars\":2000}. For a whole artifact read, omit maxItems/maxChars.",
891
+ 'Projected reads must include a JSON path when using maxItems or maxChars, for example {"action":"read","source":"plan","artifact":"control","path":"$.factSlots","maxItems":8,"maxChars":2000}. For a whole artifact read, omit maxItems/maxChars.',
884
892
  requiredReads.length > 0
885
893
  ? [
886
894
  "Required reads before final output:",
@@ -888,7 +896,7 @@ export function formatArtifactGraphSourceContext(
888
896
  ].join("\n")
889
897
  : "No hard requiredReads are declared for this stage.",
890
898
  "Available sources:",
891
- JSON.stringify(
899
+ stringifyPromptJson(
892
900
  sources.map((source) => ({
893
901
  source: source.source,
894
902
  taskId: source.taskId,
@@ -904,13 +912,11 @@ export function formatArtifactGraphSourceContext(
904
912
  projectionTruncated: source.projectionTruncated,
905
913
  availableArtifacts: Object.keys(source.artifacts),
906
914
  })),
907
- null,
908
- 2,
909
915
  ),
910
916
  ].join("\n\n");
911
917
  }
912
918
  function uniqueStrings(values: readonly string[]): string[] {
913
- return [...new Set(values.filter((value) => value.trim().length > 0))];
919
+ return compactStrings(values, { trim: false, dropWhitespaceOnly: true });
914
920
  }
915
921
 
916
922
  export async function readArtifactGraphControl(