@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
@@ -0,0 +1,299 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { hashDynamicRequest } from "./dynamic-events.js";
5
+ import { readJson, writeJsonAtomic } from "./store.js";
6
+
7
+ export const WORKFLOW_PARTIAL_OUTPUT_PROTOCOL =
8
+ "workflow-partial-output-v1" as const;
9
+ export const WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA =
10
+ "workflow-partial-output-ledger-v1" as const;
11
+ export const WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE = "partial-control.json";
12
+
13
+ export type WorkflowPartialOutputIssueCode =
14
+ | "invalid_json"
15
+ | "invalid_type"
16
+ | "invalid_schema"
17
+ | "invalid_path"
18
+ | "disallowed_path"
19
+ | "missing_items"
20
+ | "missing_item_id"
21
+ | "duplicate_item_id";
22
+
23
+ export interface WorkflowPartialOutputIssue {
24
+ code: WorkflowPartialOutputIssueCode;
25
+ message: string;
26
+ sectionIndex?: number;
27
+ path?: string;
28
+ itemId?: string;
29
+ }
30
+
31
+ export interface WorkflowPartialOutputItem {
32
+ path: string;
33
+ itemId: string;
34
+ itemHash: string;
35
+ item: unknown;
36
+ ordinal: number;
37
+ sectionIndex: number;
38
+ sectionItemIndex: number;
39
+ itemRef: string;
40
+ }
41
+
42
+ export interface WorkflowPartialOutputLedger {
43
+ schema: typeof WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA;
44
+ protocol: typeof WORKFLOW_PARTIAL_OUTPUT_PROTOCOL;
45
+ items: WorkflowPartialOutputItem[];
46
+ issues: WorkflowPartialOutputIssue[];
47
+ }
48
+
49
+ export interface ParseWorkflowPartialOutputOptions {
50
+ allowedPaths?: readonly string[];
51
+ }
52
+
53
+ interface PartialSectionMatch {
54
+ content: string;
55
+ start: number;
56
+ end: number;
57
+ index: number;
58
+ }
59
+
60
+ const PARTIAL_CONTROL_OPEN = "partial-control";
61
+
62
+ export function partialOutputLedgerPath(taskDir: string): string {
63
+ return join(taskDir, WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE);
64
+ }
65
+
66
+ export async function readWorkflowPartialOutputLedger(
67
+ taskDir: string,
68
+ ): Promise<WorkflowPartialOutputLedger | undefined> {
69
+ return await readJson<WorkflowPartialOutputLedger>(
70
+ partialOutputLedgerPath(taskDir),
71
+ );
72
+ }
73
+
74
+ export async function writeWorkflowPartialOutputLedger(options: {
75
+ taskDir: string;
76
+ rawOutput: string;
77
+ allowedPaths?: readonly string[];
78
+ }): Promise<WorkflowPartialOutputLedger> {
79
+ const ledger = parseWorkflowPartialOutput(options.rawOutput, {
80
+ allowedPaths: options.allowedPaths,
81
+ });
82
+ await writeJsonAtomic(partialOutputLedgerPath(options.taskDir), ledger);
83
+ return ledger;
84
+ }
85
+
86
+ export async function writeWorkflowPartialOutputLedgerFromFile(options: {
87
+ taskDir: string;
88
+ outputFile: string;
89
+ allowedPaths?: readonly string[];
90
+ }): Promise<WorkflowPartialOutputLedger | undefined> {
91
+ const rawOutput = await readFile(options.outputFile, "utf8").catch(
92
+ () => undefined,
93
+ );
94
+ if (rawOutput === undefined) return undefined;
95
+ return await writeWorkflowPartialOutputLedger({
96
+ taskDir: options.taskDir,
97
+ rawOutput,
98
+ allowedPaths: options.allowedPaths,
99
+ });
100
+ }
101
+
102
+ export function stripWorkflowPartialOutputSections(raw: string): string {
103
+ if (!raw.includes(PARTIAL_CONTROL_OPEN)) return raw;
104
+ return raw.replace(partialControlSectionRegExp(), "");
105
+ }
106
+
107
+ export function parseWorkflowPartialOutput(
108
+ raw: string,
109
+ options: ParseWorkflowPartialOutputOptions = {},
110
+ ): WorkflowPartialOutputLedger {
111
+ const allowedPaths = options.allowedPaths
112
+ ? new Set(options.allowedPaths)
113
+ : undefined;
114
+ const items: WorkflowPartialOutputItem[] = [];
115
+ const issues: WorkflowPartialOutputIssue[] = [];
116
+ const byPathAndId = new Map<string, WorkflowPartialOutputItem>();
117
+
118
+ for (const section of collectPartialControlSections(raw)) {
119
+ const parsed = parsePartialSectionJson(section, issues);
120
+ if (!parsed) continue;
121
+ const path = parsePartialSectionPath(parsed, section, allowedPaths, issues);
122
+ if (!path) continue;
123
+ const rawItems = parsePartialSectionItems(parsed, section, path, issues);
124
+ if (!rawItems) continue;
125
+ for (const [sectionItemIndex, item] of rawItems.entries()) {
126
+ const itemId = stablePartialItemId(item);
127
+ if (!itemId) {
128
+ issues.push({
129
+ code: "missing_item_id",
130
+ sectionIndex: section.index,
131
+ path,
132
+ message:
133
+ "partial output items must be objects with a stable non-empty string id",
134
+ });
135
+ continue;
136
+ }
137
+ const itemHash = hashDynamicRequest(item);
138
+ const key = `${path}\0${itemId}`;
139
+ const existing = byPathAndId.get(key);
140
+ if (existing) {
141
+ if (existing.itemHash !== itemHash) {
142
+ issues.push({
143
+ code: "duplicate_item_id",
144
+ sectionIndex: section.index,
145
+ path,
146
+ itemId,
147
+ message: `partial output item ${itemId} at ${path} changed after it was published`,
148
+ });
149
+ }
150
+ continue;
151
+ }
152
+ const ordinal = items.length;
153
+ const partialItem: WorkflowPartialOutputItem = {
154
+ path,
155
+ itemId,
156
+ itemHash,
157
+ item,
158
+ ordinal,
159
+ sectionIndex: section.index,
160
+ sectionItemIndex,
161
+ itemRef: `${WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE}#/items/${ordinal}`,
162
+ };
163
+ items.push(partialItem);
164
+ byPathAndId.set(key, partialItem);
165
+ }
166
+ }
167
+
168
+ return {
169
+ schema: WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA,
170
+ protocol: WORKFLOW_PARTIAL_OUTPUT_PROTOCOL,
171
+ items,
172
+ issues,
173
+ };
174
+ }
175
+
176
+ export function hasFatalPartialOutputIssue(
177
+ ledger: Pick<WorkflowPartialOutputLedger, "issues"> | undefined,
178
+ ): WorkflowPartialOutputIssue | undefined {
179
+ return ledger?.issues.find((issue) => issue.code === "duplicate_item_id");
180
+ }
181
+
182
+ function collectPartialControlSections(raw: string): PartialSectionMatch[] {
183
+ if (!raw.includes(PARTIAL_CONTROL_OPEN)) return [];
184
+ const matches: PartialSectionMatch[] = [];
185
+ const re = partialControlSectionRegExp();
186
+ let match: RegExpExecArray | null;
187
+ while ((match = re.exec(raw)) !== null) {
188
+ matches.push({
189
+ content: match[1] ?? "",
190
+ start: match.index,
191
+ end: re.lastIndex,
192
+ index: matches.length,
193
+ });
194
+ }
195
+ return matches;
196
+ }
197
+
198
+ function partialControlSectionRegExp(): RegExp {
199
+ return /[ \t]*<partial-control\s*>([\s\S]*?)<\/partial-control>[ \t]*(?:\r?\n)?/gi;
200
+ }
201
+
202
+ function parsePartialSectionJson(
203
+ section: PartialSectionMatch,
204
+ issues: WorkflowPartialOutputIssue[],
205
+ ): Record<string, unknown> | undefined {
206
+ let parsed: unknown;
207
+ try {
208
+ parsed = JSON.parse(section.content.trim());
209
+ } catch (error) {
210
+ issues.push({
211
+ code: "invalid_json",
212
+ sectionIndex: section.index,
213
+ message: error instanceof Error ? error.message : String(error),
214
+ });
215
+ return undefined;
216
+ }
217
+ if (!isRecord(parsed)) {
218
+ issues.push({
219
+ code: "invalid_type",
220
+ sectionIndex: section.index,
221
+ message: "partial-control section must contain a JSON object",
222
+ });
223
+ return undefined;
224
+ }
225
+ if (parsed.schema !== WORKFLOW_PARTIAL_OUTPUT_PROTOCOL) {
226
+ issues.push({
227
+ code: "invalid_schema",
228
+ sectionIndex: section.index,
229
+ message: `partial-control schema must be ${WORKFLOW_PARTIAL_OUTPUT_PROTOCOL}`,
230
+ });
231
+ return undefined;
232
+ }
233
+ return parsed;
234
+ }
235
+
236
+ function parsePartialSectionPath(
237
+ section: Record<string, unknown>,
238
+ match: PartialSectionMatch,
239
+ allowedPaths: Set<string> | undefined,
240
+ issues: WorkflowPartialOutputIssue[],
241
+ ): string | undefined {
242
+ const path = section.path;
243
+ if (typeof path !== "string" || !path.startsWith("$.")) {
244
+ issues.push({
245
+ code: "invalid_path",
246
+ sectionIndex: match.index,
247
+ message: "partial-control path must be a control JSONPath starting with $.",
248
+ });
249
+ return undefined;
250
+ }
251
+ if (allowedPaths && !allowedPaths.has(path)) {
252
+ issues.push({
253
+ code: "disallowed_path",
254
+ sectionIndex: match.index,
255
+ path,
256
+ message: `partial-control path ${path} is not declared for this stage`,
257
+ });
258
+ return undefined;
259
+ }
260
+ return path;
261
+ }
262
+
263
+ function parsePartialSectionItems(
264
+ section: Record<string, unknown>,
265
+ match: PartialSectionMatch,
266
+ path: string,
267
+ issues: WorkflowPartialOutputIssue[],
268
+ ): unknown[] | undefined {
269
+ const items = section.items;
270
+ if (!Array.isArray(items)) {
271
+ issues.push({
272
+ code: "missing_items",
273
+ sectionIndex: match.index,
274
+ path,
275
+ message: "partial-control items must be an array",
276
+ });
277
+ return undefined;
278
+ }
279
+ return items;
280
+ }
281
+
282
+ function stablePartialItemId(item: unknown): string | undefined {
283
+ if (!isRecord(item) || typeof item.id !== "string") return undefined;
284
+ const sanitized = sanitizePartialItemId(item.id);
285
+ return sanitized || undefined;
286
+ }
287
+
288
+ function sanitizePartialItemId(value: string): string {
289
+ return value
290
+ .trim()
291
+ .toLowerCase()
292
+ .replace(/[^a-z0-9_.-]+/g, "-")
293
+ .replace(/^-+|-+$/g, "")
294
+ .slice(0, 64);
295
+ }
296
+
297
+ function isRecord(value: unknown): value is Record<string, unknown> {
298
+ return typeof value === "object" && value !== null && !Array.isArray(value);
299
+ }
@@ -104,16 +104,16 @@ export function diagnoseWorkflowRunHealth(
104
104
  options: WorkflowHealthOptions = {},
105
105
  ): WorkflowProgressHealth {
106
106
  const nowMs = options.nowMs ?? Date.now();
107
- const runningTask = currentRunningTask(run.tasks ?? []);
108
- if (runningTask)
109
- return diagnoseWorkflowTaskHealth(runningTask, run, { nowMs });
110
-
111
107
  const problem = (run.tasks ?? []).find((task) =>
112
108
  isProblemStatus(task.status),
113
109
  );
114
110
  if (problem) return problemRunHealth(problem, nowMs);
115
111
  if (isProblemStatus(run.status)) return problemWorkflowHealth(run.status);
116
112
  if (run.status === "completed") return completedWorkflowHealth();
113
+
114
+ const runningTask = currentRunningTask(run.tasks ?? []);
115
+ if (runningTask)
116
+ return diagnoseWorkflowTaskHealth(runningTask, run, { nowMs });
117
117
  return waitingWorkflowHealth(run, nowMs);
118
118
  }
119
119
 
@@ -143,19 +143,20 @@ export function classifyWorkflowTaskDuration(
143
143
  .filter(Boolean)
144
144
  .join(" ")
145
145
  .toLowerCase();
146
- if (/\b(render|helper|support|schema|partition|gate)\b/.test(text))
147
- return "short";
148
- if (
149
- /\b(research|audit|synthesis|review|verify|verifier|normalize|plan|impact|spec)\b/.test(
150
- text,
151
- )
152
- )
153
- return "long";
154
146
  const maxRuntimeMs = task.runtime?.maxRuntimeMs;
155
147
  if (maxRuntimeMs !== undefined && Number.isFinite(maxRuntimeMs)) {
156
148
  if (maxRuntimeMs <= 5 * 60_000) return "short";
157
149
  if (maxRuntimeMs >= 60 * 60_000) return "long";
158
150
  }
151
+ if (task.kind === "support") return "short";
152
+ if (
153
+ /\b(research|audit|synthesi[sz]e?r?|review(?:er|ers|ing|s)?|verif(?:y|ier|iers|ication)|normaliz(?:e|er|ing|ation)?|plan(?:ning)?|impact|spec)\b/.test(
154
+ text,
155
+ )
156
+ )
157
+ return "long";
158
+ if (/\b(render|helper|support|schema|partition|gate)\b/.test(text))
159
+ return "short";
159
160
  return "medium";
160
161
  }
161
162
 
@@ -215,6 +216,33 @@ function waitingWorkflowHealth(
215
216
  nowMs: number,
216
217
  ): WorkflowProgressHealth {
217
218
  const hasPending = run.taskSummary.pending > 0;
219
+ const lastActivityAgeMs = ageMs(run.updatedAt, nowMs);
220
+ if (hasPending && lastActivityAgeMs !== undefined) {
221
+ if (lastActivityAgeMs >= STUCK_BY_DURATION.medium) {
222
+ return {
223
+ state: "likely-stuck",
224
+ label: "scheduler stuck",
225
+ summary: "pending tasks have not scheduled",
226
+ tone: "error",
227
+ suggestion: "resume",
228
+ reason: "no task is running and run activity is stale",
229
+ lastActivityAt: run.updatedAt,
230
+ lastActivityAgeMs,
231
+ };
232
+ }
233
+ if (lastActivityAgeMs >= STALL_BY_DURATION.medium) {
234
+ return {
235
+ state: "stalled",
236
+ label: "scheduler quiet",
237
+ summary: "pending tasks are waiting without recent activity",
238
+ tone: "warning",
239
+ suggestion: "inspect",
240
+ reason: "no task is running and run activity is stale",
241
+ lastActivityAt: run.updatedAt,
242
+ lastActivityAgeMs,
243
+ };
244
+ }
245
+ }
218
246
  return {
219
247
  state: hasPending ? "pending" : "active",
220
248
  label: hasPending ? "pending" : "active",
@@ -227,7 +255,7 @@ function waitingWorkflowHealth(
227
255
  ? "no task is currently running"
228
256
  : "workflow is still in progress",
229
257
  lastActivityAt: run.updatedAt,
230
- lastActivityAgeMs: ageMs(run.updatedAt, nowMs),
258
+ lastActivityAgeMs,
231
259
  };
232
260
  }
233
261
 
@@ -335,8 +363,12 @@ function runningContext(
335
363
  const durationClass = classifyWorkflowTaskDuration(task);
336
364
  const startedAtMs = parseTime(task.startedAt);
337
365
  const heartbeatAt = parseHeartbeatAt(task.lastMessage);
366
+ const heartbeatAgeMs = ageMs(heartbeatAt, nowMs);
338
367
  const activityAt = latestIso([heartbeatAt, run?.updatedAt, task.startedAt]);
339
368
  const lastActivityAgeMs = ageMs(activityAt, nowMs);
369
+ const hasFreshHeartbeat =
370
+ heartbeatAgeMs !== undefined &&
371
+ heartbeatAgeMs <= STALL_BY_DURATION[durationClass];
340
372
  return {
341
373
  task,
342
374
  nowMs,
@@ -346,8 +378,8 @@ function runningContext(
346
378
  activityAt,
347
379
  lastActivityAgeMs,
348
380
  heartbeatAt,
349
- heartbeatAgeMs: ageMs(heartbeatAt, nowMs),
350
- hasBackendSignal: Boolean(task.backendHandle || task.pid || heartbeatAt),
381
+ heartbeatAgeMs,
382
+ hasBackendSignal: Boolean(task.pid || hasFreshHeartbeat),
351
383
  staleMs: lastActivityAgeMs ?? Number.POSITIVE_INFINITY,
352
384
  };
353
385
  }
@@ -942,8 +942,8 @@ async function cachedFetchFailureResult(
942
942
  return errorToolResult(failure.code, failure.message, failure.extra);
943
943
  }
944
944
 
945
- const FETCH_LOCK_STALE_MS = 60_000;
946
- const FETCH_LOCK_WAIT_MS = 75_000;
945
+ const FETCH_LOCK_STALE_MS = 4 * 60_000;
946
+ const FETCH_LOCK_WAIT_MS = 5 * 60_000;
947
947
 
948
948
  async function withWorkflowWebFetchLock<T>(
949
949
  config: WorkflowWebSourceCacheConfig,
@@ -970,14 +970,15 @@ async function acquireWorkflowWebFetchLock(
970
970
  for (;;) {
971
971
  if (signal?.aborted) throw new Error("aborted");
972
972
  try {
973
+ const ownerId = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
973
974
  await mkdir(lockDir);
974
975
  await writeFile(
975
976
  resolve(lockDir, "owner.json"),
976
- `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), key }, null, 2)}\n`,
977
+ `${JSON.stringify({ ownerId, pid: process.pid, createdAt: new Date().toISOString(), key }, null, 2)}\n`,
977
978
  "utf8",
978
979
  );
979
980
  return async () => {
980
- await rm(lockDir, { recursive: true, force: true });
981
+ await releaseWorkflowWebFetchLock(lockDir, ownerId);
981
982
  };
982
983
  } catch (error) {
983
984
  if (!isFileExistsError(error)) throw error;
@@ -990,6 +991,19 @@ async function acquireWorkflowWebFetchLock(
990
991
  }
991
992
  }
992
993
 
994
+ async function releaseWorkflowWebFetchLock(
995
+ lockDir: string,
996
+ ownerId: string,
997
+ ): Promise<void> {
998
+ try {
999
+ const current = await readFetchLockOwner(lockDir);
1000
+ if (current?.ownerId !== ownerId) return;
1001
+ await rm(lockDir, { recursive: true, force: true });
1002
+ } catch {
1003
+ // Missing or unreadable lock will be retried by the caller.
1004
+ }
1005
+ }
1006
+
993
1007
  async function removeStaleFetchLock(lockDir: string): Promise<void> {
994
1008
  try {
995
1009
  const current = await stat(lockDir);
@@ -1001,6 +1015,21 @@ async function removeStaleFetchLock(lockDir: string): Promise<void> {
1001
1015
  }
1002
1016
  }
1003
1017
 
1018
+ async function readFetchLockOwner(
1019
+ lockDir: string,
1020
+ ): Promise<{ ownerId?: string } | undefined> {
1021
+ try {
1022
+ const parsed = JSON.parse(
1023
+ await readFile(resolve(lockDir, "owner.json"), "utf8"),
1024
+ ) as unknown;
1025
+ return isRecord(parsed) && typeof parsed.ownerId === "string"
1026
+ ? { ownerId: parsed.ownerId }
1027
+ : undefined;
1028
+ } catch {
1029
+ return undefined;
1030
+ }
1031
+ }
1032
+
1004
1033
  async function readDurableFetchFailure(
1005
1034
  config: WorkflowWebSourceCacheConfig,
1006
1035
  key: string,
@@ -10,6 +10,8 @@ import {
10
10
  import { isIP } from "node:net";
11
11
  import { dirname, resolve } from "node:path";
12
12
 
13
+ import { compactStrings } from "./strings.js";
14
+
13
15
  export const WORKFLOW_WEB_SOURCE_CACHE_SCHEMA =
14
16
  "workflow-web-source-cache-v1" as const;
15
17
  export const WORKFLOW_WEB_SOURCE_INDEX_SCHEMA =
@@ -150,7 +152,7 @@ export const DEFAULT_WORKFLOW_WEB_SECURITY_POLICY: WorkflowWebSecurityPolicy = {
150
152
  };
151
153
 
152
154
  const SENSITIVE_QUERY_PARAM_PATTERN =
153
- /(^|[-_])(access[-_]?token|auth|code|credential|key|password|secret|session|signature|sig|token)([-_]|$)/i;
155
+ /(^|[-_])(access[-_]?token|auth|code|credential|key|password|secret|session|session[-_]?id|sessionid|signature|sig|sid|jwt|token)([-_]|$)/i;
154
156
  const PRIVATE_HOST_PATTERNS = [
155
157
  /^localhost$/i,
156
158
  /^127\./,
@@ -331,7 +333,7 @@ export function createWorkflowWebSource(options: {
331
333
  redactedUrl,
332
334
  urlKey: sourceUrlCacheKey(options.url),
333
335
  domain,
334
- ...(options.title ? { title: options.title } : {}),
336
+ ...(options.title ? { title: redactInlineSecrets(options.title) } : {}),
335
337
  ...(options.provider ? { provider: options.provider } : {}),
336
338
  contentHash,
337
339
  text: options.text,
@@ -602,14 +604,14 @@ export function extractTextFromToolResult(result: unknown): string {
602
604
  if (!isRecord(result)) return "";
603
605
  const content = result.content;
604
606
  if (!Array.isArray(content)) return "";
605
- return content
606
- .map((entry) => {
607
+ return compactStrings(
608
+ content.map((entry) => {
607
609
  if (!isRecord(entry)) return "";
608
610
  const text = entry.text;
609
611
  return typeof text === "string" ? text : "";
610
- })
611
- .filter(Boolean)
612
- .join("\n\n");
612
+ }),
613
+ { trim: false, unique: false },
614
+ ).join("\n\n");
613
615
  }
614
616
 
615
617
  export function extractTitleFromToolResult(
@@ -712,7 +714,7 @@ function sourceToIndexEntry(
712
714
  redactedUrl: source.redactedUrl,
713
715
  ...(source.urlKey ? { urlKey: source.urlKey } : {}),
714
716
  domain: source.domain,
715
- ...(source.title ? { title: source.title } : {}),
717
+ ...(source.title ? { title: redactInlineSecrets(source.title) } : {}),
716
718
  contentHash: source.contentHash,
717
719
  textChars: source.textChars,
718
720
  ...(source.provider ? { provider: source.provider } : {}),
@@ -1066,9 +1068,18 @@ function consumeAnchoredSnippet(options: {
1066
1068
  raw,
1067
1069
  visibleLimit,
1068
1070
  );
1071
+ // Redaction can expand secrets. Promote only when the redacted anchor
1072
+ // itself no longer fits; clipping trailing context can remain a match.
1073
+ const redactedThroughAnchorLength = consumed.truncated
1074
+ ? redactInlineSecrets(
1075
+ options.text.slice(sourceStart, Math.min(sourceEnd, anchorEnd)),
1076
+ ).length
1077
+ : 0;
1078
+ const anchorTruncated =
1079
+ status === "truncated" || redactedThroughAnchorLength > visibleLimit;
1069
1080
  const truncated = status === "truncated" || consumed.truncated;
1070
1081
  return {
1071
- status,
1082
+ status: anchorTruncated ? "truncated" : status,
1072
1083
  quote: consumed.text,
1073
1084
  visibleChars: consumed.text.length,
1074
1085
  sourceStart,
@@ -1105,7 +1116,15 @@ function normalizeForSearch(text: string): {
1105
1116
  map.push(index);
1106
1117
  }
1107
1118
  }
1108
- return { normalized: normalized.trim(), map };
1119
+ while (normalized.startsWith(" ")) {
1120
+ normalized = normalized.slice(1);
1121
+ map.shift();
1122
+ }
1123
+ while (normalized.endsWith(" ")) {
1124
+ normalized = normalized.slice(0, -1);
1125
+ map.pop();
1126
+ }
1127
+ return { normalized, map };
1109
1128
  }
1110
1129
 
1111
1130
  function nearbySnippet(text: string, needle: string, maxChars: number): string {
@@ -1216,7 +1235,9 @@ function sourceIndexEntryFromUnknown(
1216
1235
  redactedUrl: value.redactedUrl,
1217
1236
  ...(typeof value.urlKey === "string" ? { urlKey: value.urlKey } : {}),
1218
1237
  domain: value.domain,
1219
- ...(typeof value.title === "string" ? { title: value.title } : {}),
1238
+ ...(typeof value.title === "string"
1239
+ ? { title: redactInlineSecrets(value.title) }
1240
+ : {}),
1220
1241
  contentHash: value.contentHash,
1221
1242
  textChars: Number(value.textChars),
1222
1243
  ...(typeof value.provider === "string" ? { provider: value.provider } : {}),
@@ -1375,7 +1396,10 @@ function redactInlineSecrets(value: string): string {
1375
1396
  function redactInlineSecretsNoUrls(value: string): string {
1376
1397
  return value
1377
1398
  .replace(/(authorization|cookie|set-cookie):\s*[^\n\r]+/gi, "$1: REDACTED")
1378
- .replace(/(token|secret|password|api[-_]?key)=([^\s&]+)/gi, "$1=REDACTED")
1399
+ .replace(
1400
+ /(token|secret|password|api[-_]?key|jwt|sid|sessionid|session[-_]?id)=([^\s&]+)/gi,
1401
+ "$1=REDACTED",
1402
+ )
1379
1403
  .replace(/\/Users\/[^\s:'")]+/g, "/Users/REDACTED");
1380
1404
  }
1381
1405
 
@@ -22,7 +22,9 @@ For spec-less direct dynamic execution, use `/workflow dynamic "<task>"`; it doe
22
22
  | `spec-review` | `scout` | Use when you want to check whether requirements, an API spec, or a contract are reflected in the implementation and tests. |
23
23
  | `impact-review` | `scout` | Use before merging or releasing a change to check affected areas, risks, missing tests, and missing docs. |
24
24
 
25
- More official workflows are planned. Experimental or candidate workflows should live outside the bundled `workflows/` directory until their task fit is validated.
25
+ Experimental or candidate workflows should live outside the bundled `workflows/` directory until their task fit is validated. `deep-research` also ships a path-ref-only batched verification variant at `workflows/deep-research/batched-verification.spec.json`; it is intentionally not registered as an official workflow name and must be invoked by explicit path after validation.
26
+
27
+ Bundled workflows that verify source-backed claims can share the verification outcome ontology exported by the package: `verified`, `partially_supported`, `unsupported`, `conflicting`, and `verification_blocked`. Workflow helpers should keep dependency-free bundle-local shims in parity with that package export, because helper imports are bundled from the workflow spec directory. `verification_blocked` means verification could not complete because evidence, tool, source-access, or policy conditions blocked evaluation; it is never counted as verified. Deep-research adopts this ontology now. Workflows with different verdict models, such as finding disposition or ship readiness, should not be forced into it. Deep-diff-review revival is intentionally out of scope for this ontology update.
26
28
 
27
29
  ## Bundle layout
28
30
 
@@ -45,30 +47,10 @@ Bundle names resolve from the directory name (`/workflow run name ...`). If two
45
47
 
46
48
  Artifact-graph workflows use `from` for data edges, `after` for order-only edges, and `type: "dag"` containers for nested sibling-scoped graphs. A downstream stage consumes a container with `from: "analysis"`, which resolves to the container's `outputFrom` child. See `docs/usage.md` for the full DAG example, artifact bundle rules, and validation rules.
47
49
 
48
- ## Support helpers
49
-
50
- A support node runs local helper code inline instead of launching a subagent. Declare it with a `support` object, not a separate `type` value:
51
-
52
- ```json
53
- {
54
- "id": "audit-claims",
55
- "from": "verify-claims",
56
- "sourcePolicy": "partial",
57
- "support": {
58
- "uses": "./helpers/claim-evidence-gate.mjs",
59
- "options": { "downgradeExactQuantitativeWithoutSource": true }
60
- }
61
- }
62
- ```
63
-
64
- Helper API:
50
+ ## Support helpers and web tools
65
51
 
66
- ```js
67
- export default async function helper({ sources, options, context }) {
68
- return { schema: "helper-output-v1", digest: "...", value: { /* control data */ } };
69
- }
70
- ```
52
+ Support nodes run bundle-local `.mjs` helper code inline instead of launching a subagent (deep-research uses them to compact normalize inputs and preserve audited verdict/sourceRef ledgers). Bundled workflows prefer the normalized web-source tools (`workflow_web_search`, `workflow_web_fetch_source`, `workflow_web_source_read`) over legacy web tools.
71
53
 
72
- Helper refs are intentionally directory-local only. Allowed refs start with `./` and point to `.mjs` files inside the workflow bundle directory. Parent-directory refs, absolute paths, home-relative paths, protocol refs (`file://`, `https://`), and `npm:` refs are rejected. This is containment and reproducibility, not a sandbox: helper code still runs inside the workflow process and is not constrained by subagent tool allowlists.
54
+ Legacy `fetch_content` workflow tasks use a run-scoped cache and a configurable inline text cap to reduce worker context pressure.
73
55
 
74
- Workflow runs now prefer normalized web-source tools: `workflow_web_search`, `workflow_web_fetch_source`, and `workflow_web_source_read`. Normalized web sources are stored inside `.pi/workflows/<run-id>/web-source-cache/`, model-visible tool results expose only compact cards/source refs/snippets, and agents should use `workflow_web_source_read` instead of reading cache files directly. Batch several source fetches with `urls: [...]` or `sources: [...]`, and batch several snippets from one sourceRef with `queries: [...]` or `reads: [...]` to reduce repeated tool turns; use `claim` plus distinctive `terms` for candidate quote windows with match metadata when the exact quote is unknown. Deep-research also uses support helpers to compact normalize inputs and preserve audited verdict/sourceRef ledgers before final synthesis. Custom extension `fetch_content` providers are disabled by default for normalized source fetches unless the workflow security policy explicitly trusts private-host behavior; this avoids accepting opaque provider network fetches as SSRF-safe. Legacy `fetch_content` tasks still use `.pi/workflows/<run-id>/source-cache/fetch-content/`; set `PI_WORKFLOW_FETCH_CONTENT_CACHE=0` to opt out for a run. Treat cache-enabled benchmark runs as a separate cohort from older uncached measurements.
56
+ See `docs/usage.md` for the support helper API and path-containment rules ("Support helpers") and for web tool semantics, batching, cache layout, and the `fetch_content` security policy ("Run-scoped web-source cache" and "Web tools").