pi-subagents 0.59.0 → 0.61.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 (72) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/agents.md +2 -2
  3. package/docs/configuration.md +9 -5
  4. package/docs/extension-api.md +14 -7
  5. package/docs/models.md +1 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +15 -3
  8. package/docs/workflows.md +15 -14
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +89 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +30 -234
  15. package/skills/pi-subagents/references/execution-controls.md +79 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +35 -28
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +256 -88
  21. package/src/agents/agents.ts +527 -221
  22. package/src/api/background-work.ts +7 -2
  23. package/src/api/external-runs.ts +67 -4
  24. package/src/api/preflight.ts +13 -8
  25. package/src/api/shared-types.ts +3 -0
  26. package/src/extension/index.ts +7 -4
  27. package/src/extension/public-execution.ts +48 -4
  28. package/src/extension/rpc.ts +62 -4
  29. package/src/extension/schemas.ts +12 -7
  30. package/src/extension/tool-description.ts +16 -16
  31. package/src/runs/background/async-execution.ts +53 -37
  32. package/src/runs/background/async-job-tracker.ts +65 -3
  33. package/src/runs/background/async-resume.ts +3 -1
  34. package/src/runs/background/async-status.ts +104 -11
  35. package/src/runs/background/auto-drain.ts +1 -1
  36. package/src/runs/background/control-channel.ts +3 -2
  37. package/src/runs/background/fleet-view.ts +1 -1
  38. package/src/runs/background/result-watcher.ts +1 -1
  39. package/src/runs/background/resume-guidance.ts +1 -1
  40. package/src/runs/background/run-status.ts +15 -4
  41. package/src/runs/background/subagent-runner.ts +6 -3
  42. package/src/runs/background/subagent-wait.ts +30 -23
  43. package/src/runs/background/wait-completions.ts +4 -1
  44. package/src/runs/background/wait-tool.ts +24 -18
  45. package/src/runs/foreground/execution.ts +72 -4
  46. package/src/runs/foreground/subagent-executor.ts +276 -59
  47. package/src/runs/shared/acceptance.ts +43 -18
  48. package/src/runs/shared/async-status-projection.ts +138 -4
  49. package/src/runs/shared/background-process-options.ts +9 -0
  50. package/src/runs/shared/host-step-status.ts +1 -0
  51. package/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  52. package/src/runs/shared/model-fallback.ts +61 -17
  53. package/src/runs/shared/mutation-evidence.ts +52 -3
  54. package/src/runs/shared/permissions.ts +1 -1
  55. package/src/runs/shared/pi-args.ts +47 -1
  56. package/src/runs/shared/single-output.ts +45 -18
  57. package/src/runs/shared/subagent-prompt-runtime.ts +20 -2
  58. package/src/runs/shared/tool-timeout.ts +1 -1
  59. package/src/runs/shared/workflow-graph.ts +16 -0
  60. package/src/shared/types.ts +74 -4
  61. package/src/shared/workflow-child-permit.ts +91 -0
  62. package/src/slash/prompt-template-bridge.ts +37 -1
  63. package/src/slash/slash-commands.ts +18 -26
  64. package/src/tui/fleet-status.ts +11 -3
  65. package/src/tui/render-helpers.ts +31 -0
  66. package/src/tui/render.ts +673 -145
  67. package/src/watchdog/change-signature.ts +40 -1
  68. package/src/workflows/host-command.ts +6 -1
  69. package/src/workflows/scripted-workflow.ts +206 -6
  70. package/src/workflows/workflow-child-summary.ts +1 -1
  71. package/src/workflows/workflow-receipt.ts +41 -4
  72. package/src/workflows/workflow-resources.ts +150 -0
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
4
5
  import * as path from "node:path";
5
6
  import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../shared/artifacts.ts";
6
7
 
@@ -11,6 +12,7 @@ const IGNORED_CHANGE_SEGMENTS = new Set([".git", "node_modules"]);
11
12
  const DEFAULT_MAX_HASH_FILE_BYTES = 64 * 1024 * 1024;
12
13
  const DEFAULT_MAX_HASH_TOTAL_BYTES = 64 * 1024 * 1024;
13
14
  const DEFAULT_MAX_HASH_ENTRIES = 2_000;
15
+ const TRACKED_ENTRY_PROBE_MAX_BYTES = 1_024;
14
16
 
15
17
  function positiveEnvNumber(name: string, fallback: number): number {
16
18
  const parsed = Number(process.env[name]);
@@ -43,6 +45,37 @@ function git(cwd: string, args: string[]): string | undefined {
43
45
  return result.stdout;
44
46
  }
45
47
 
48
+ function comparablePath(value: string): string {
49
+ let resolved = path.resolve(value);
50
+ try {
51
+ resolved = fs.realpathSync.native(resolved);
52
+ } catch {
53
+ // Keep the lexical path when canonicalization is unavailable.
54
+ }
55
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
56
+ }
57
+
58
+ function isHomeRepoRoot(root: string): boolean {
59
+ return comparablePath(root) === comparablePath(os.homedir());
60
+ }
61
+
62
+ /**
63
+ * Probe the index without asking Git to inspect the working tree. The output
64
+ * cap keeps this check bounded even for repositories with a very large index;
65
+ * an ENOBUFS result still proves that at least one tracked path exists.
66
+ */
67
+ function hasTrackedEntries(root: string): boolean | undefined {
68
+ const result = spawnSync("git", ["-C", root, "ls-files", "--cached", "--error-unmatch", "--", "."], {
69
+ encoding: "utf-8",
70
+ maxBuffer: TRACKED_ENTRY_PROBE_MAX_BYTES,
71
+ windowsHide: true,
72
+ });
73
+ const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
74
+ if (result.status === 0 || errorCode === "ENOBUFS") return true;
75
+ if (result.status === 1) return false;
76
+ return undefined;
77
+ }
78
+
46
79
  function normalizeRelPath(value: string): string {
47
80
  return value.replaceAll(path.sep, "/").replace(/^\.\//, "");
48
81
  }
@@ -181,7 +214,13 @@ function buildRepoChangeSignature(root: string, statusOutput: string): WatchdogR
181
214
  export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {
182
215
  const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
183
216
  if (!root) return undefined;
184
- const statusOutput = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
217
+ const skipUntracked = isHomeRepoRoot(root) || hasTrackedEntries(root) === false;
218
+ const statusOutput = git(root, [
219
+ "status",
220
+ "--porcelain=v1",
221
+ "-z",
222
+ `--untracked-files=${skipUntracked ? "no" : "all"}`,
223
+ ]);
185
224
  if (statusOutput === undefined) return undefined;
186
225
  try {
187
226
  return buildRepoChangeSignature(root, statusOutput);
@@ -53,7 +53,12 @@ export function normalizeWorkflowHostCommandParams(value: unknown, label = "runs
53
53
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
54
54
  const params = value as Record<string, unknown>;
55
55
  const unknownFields = Object.keys(params).filter((field) => !["kind", "command", "timeoutMs", "output", "role", "provider"].includes(field));
56
- if (unknownFields.length) throw new Error(`${label} has unsupported fields: ${unknownFields.join(", ")}.`);
56
+ if (unknownFields.length) {
57
+ const cwdHint = unknownFields.includes("cwd")
58
+ ? " The host step does not accept per-step cwd; commands and relative output paths use the workflow cwd. Set cwd on the outer subagent request, or put a trusted directory change in command (for example, 'cd /path/to/worktree && npm test')."
59
+ : "";
60
+ throw new Error(`${label} has unsupported fields: ${unknownFields.join(", ")}.${cwdHint}`);
61
+ }
57
62
  if (params.kind !== "command") throw new Error(`${label}.kind must be 'command'.`);
58
63
  if (typeof params.command !== "string" || !params.command.trim()) throw new Error(`${label}.command must be a non-empty string.`);
59
64
  const command = params.command.trim();
@@ -4,7 +4,8 @@ import { dirname, resolve as resolvePath } from "node:path";
4
4
  import { Worker } from "node:worker_threads";
5
5
  import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
6
6
  import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
7
- import type { HostStepNodeV1, SingleResult } from "../shared/types.ts";
7
+ import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
8
+ import type { AcceptanceRecoveryMetadata, HostStepNodeV1, SingleResult } from "../shared/types.ts";
8
9
  import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
9
10
 
10
11
  const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -500,8 +501,26 @@ function runLane(lane, firstResult, observe) {
500
501
  return visit(0, firstResult);
501
502
  }
502
503
 
504
+ function workflowPlanStringMetadata(params) {
505
+ return {
506
+ ...(typeof params.phase === "string" && params.phase.trim() ? { phase: params.phase.trim() } : {}),
507
+ ...(typeof params.label === "string" && params.label.trim() ? { label: params.label.trim() } : {}),
508
+ ...(typeof params.agent === "string" && params.agent.trim() ? { agent: params.agent.trim() } : {}),
509
+ };
510
+ }
511
+
503
512
  function runLanes(laneSpecs) {
504
513
  const lanes = validateLaneSpecs(laneSpecs);
514
+ parentPort.postMessage({ type: "lanePlan", lanes: lanes.map((lane) => ({
515
+ key: lane.key,
516
+ stages: lane.stages.map((stage) => ({
517
+ key: stage.key,
518
+ generatedKey: stage.generatedKey,
519
+ ...workflowPlanStringMetadata(stage.params),
520
+ ...(typeof stage.params.as === "string" && stage.params.as.trim() ? { outputName: stage.params.as.trim() } : {}),
521
+ ...(stage.params.outputSchema !== undefined ? { structured: true } : {}),
522
+ })),
523
+ })) });
505
524
  const firstItems = lanes.map((lane) => {
506
525
  const first = lane.stages[0];
507
526
  return { key: first.generatedKey, ...first.params };
@@ -586,7 +605,7 @@ function validateRunCall(key, params, label, fingerprints) {
586
605
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
587
606
  if (hostKeys.has(key)) throw new Error("Workflow key '" + key + "' is already used by runs.host.");
588
607
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error(label + " requires a params object.");
589
- if (Object.prototype.hasOwnProperty.call(params, "action") || Object.prototype.hasOwnProperty.call(params, "workflowScript") || Object.prototype.hasOwnProperty.call(params, "tasks") || Object.prototype.hasOwnProperty.call(params, "chain") || Object.prototype.hasOwnProperty.call(params, "parallel") || Object.prototype.hasOwnProperty.call(params, "concurrency") || Object.prototype.hasOwnProperty.call(params, "chainDir")) {
608
+ if (Object.prototype.hasOwnProperty.call(params, "action") || Object.prototype.hasOwnProperty.call(params, "workflowScript") || Object.prototype.hasOwnProperty.call(params, "globalConcurrencyLimit") || Object.prototype.hasOwnProperty.call(params, "maxSubagentSpawnsPerRun") || Object.prototype.hasOwnProperty.call(params, "tasks") || Object.prototype.hasOwnProperty.call(params, "chain") || Object.prototype.hasOwnProperty.call(params, "parallel") || Object.prototype.hasOwnProperty.call(params, "concurrency") || Object.prototype.hasOwnProperty.call(params, "chainDir")) {
590
609
  const hint = label === "runs.run" ? "; use runs.all(...) and JavaScript control flow for orchestration." : ".";
591
610
  throw new Error(label + " accepts one child via { agent, task } and execution controls only" + hint);
592
611
  }
@@ -624,7 +643,12 @@ function validateHostCommand(key, params) {
624
643
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error("runs.host('" + key + "') params must be an object.");
625
644
  const allowed = new Set(["kind", "command", "timeoutMs", "output", "role", "provider"]);
626
645
  const unknown = Object.keys(params).filter((field) => !allowed.has(field));
627
- if (unknown.length) throw new Error("runs.host('" + key + "') params have unsupported fields: " + unknown.join(", ") + ".");
646
+ if (unknown.length) {
647
+ const cwdHint = unknown.includes("cwd")
648
+ ? " The host step does not accept per-step cwd; commands and relative output paths use the workflow cwd. Set cwd on the outer subagent request, or put a trusted directory change in command (for example, 'cd /path/to/worktree && npm test')."
649
+ : "";
650
+ throw new Error("runs.host('" + key + "') params have unsupported fields: " + unknown.join(", ") + "." + cwdHint);
651
+ }
628
652
  if (params.kind !== "command") throw new Error("runs.host('" + key + "') kind must be 'command'.");
629
653
  if (typeof params.command !== "string" || !params.command.trim() || params.command.includes("\u0000") || new TextEncoder().encode(params.command.trim()).byteLength > 16384) throw new Error("runs.host('" + key + "') command must be a non-empty string of at most 16384 bytes without NUL.");
630
654
  if (!Number.isInteger(params.timeoutMs) || params.timeoutMs < 1 || params.timeoutMs > 86400000) throw new Error("runs.host('" + key + "') timeoutMs must be an integer from 1 to 86400000.");
@@ -952,6 +976,7 @@ export interface WorkflowScriptChildResult {
952
976
  requestedContext?: "fresh" | "fork";
953
977
  resolvedContext?: "fresh" | "fork" | "mixed";
954
978
  outputReference?: string;
979
+ recovery?: AcceptanceRecoveryMetadata;
955
980
  outputPathMapping?: { requestedPath: string; savedPath: string };
956
981
  externalAdapter?: import("../shared/types.ts").ExternalCliReceiptMetadata;
957
982
  resumability?: { state: "resumable" } | { state: "not-resumable"; reason: string };
@@ -977,6 +1002,22 @@ export interface WorkflowScriptTraceEntry {
977
1002
  warning?: string;
978
1003
  }
979
1004
 
1005
+ /** Bounded plan metadata emitted when a workflow materializes a runs.lanes graph. */
1006
+ export interface WorkflowLanePlanStage {
1007
+ key: string;
1008
+ generatedKey: string;
1009
+ agent?: string;
1010
+ phase?: string;
1011
+ label?: string;
1012
+ outputName?: string;
1013
+ structured?: boolean;
1014
+ }
1015
+
1016
+ export interface WorkflowLanePlan {
1017
+ key: string;
1018
+ stages: WorkflowLanePlanStage[];
1019
+ }
1020
+
980
1021
  export interface WorkflowSteerOptions {
981
1022
  mode?: "steer" | "follow_up" | "auto";
982
1023
  index?: number;
@@ -1044,6 +1085,7 @@ export interface RunWorkflowScriptOptions {
1044
1085
  };
1045
1086
  registerStopChild?: (stop: ((key: string, message?: string) => boolean) | undefined) => void;
1046
1087
  onTrace?: (trace: WorkflowScriptTraceEntry[]) => void;
1088
+ onLanePlan?: (lanes: WorkflowLanePlan[]) => void;
1047
1089
  onEmit?: (emits: unknown[]) => void;
1048
1090
  }
1049
1091
 
@@ -1067,6 +1109,138 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1067
1109
  return !!value && typeof value === "object" && !Array.isArray(value);
1068
1110
  }
1069
1111
 
1112
+ function isAcceptanceMetadataRecovery(result: WorkflowScriptChildResult): boolean {
1113
+ return !result.ok
1114
+ && result.recovery?.status === "available-for-review"
1115
+ && result.recovery.reason === "acceptance-metadata-rejected";
1116
+ }
1117
+
1118
+ const RECOVERY_REVIEW_MUTATION_VERB_PATTERN = /\b(?:add(?:ing)?|append(?:ing)?|apply(?:ing)?|cherry[ -]pick(?:ing)?|change|changing|clean(?:ing)?|commit(?:ting)?|cop(?:y|ying)|create|creating|delete|deleting|edit(?:ing)?|fix(?:ing)?|implement(?:ing)?|insert(?:ing)?|make|making|merge|merging|mov(?:e|ing)|modify(?:ing)?|mutate|mutating|open(?:ing)?|patch(?:ing)?|prepend(?:ing)?|push(?:ing)?|rebase|rebasing|refactor(?:ing)?|remove|removing|rename|renaming|replace|replacing|revert(?:ing)?|revise|revising|rewrite|rewriting|sav(?:e|ing)|stag(?:e|ing)|stash(?:ing)?|tag(?:ging)?|touch(?:ing)?|update|updating|write|writing)\b/i;
1119
+ const RECOVERY_REVIEW_READ_ONLY_PATTERN = /\b(?:read[- ]only|review only|only return findings|return findings only|suggest fixes only|without\s+(?:editing|modifying|changing|writing|touching)|do not\s+(?:edit|modify|change|write|touch)|don't\s+(?:edit|modify|change|write|touch)|must not\s+(?:edit|modify|change|write|touch))\b/i;
1120
+ const RECOVERY_REVIEW_NO_MUTATION_CLAUSE_PATTERN = /\b(?:do not|don't|must not)\s+(?:edit|modify|change|write|touch)(?:\s+files?)?(?:\s*,\s*(?:commit|push|comment|merge|launch(?:\s+subagents?)?)(?=\s*(?:,|\bor\b|[.;!?\n)]|$)))*(?:\s*,?\s*or\s+(?:commit|push|comment|merge|launch(?:\s+subagents?)?)(?=\s*(?:[.;!?\n)]|$)))?/gi;
1121
+ const RECOVERY_REVIEW_DELIVERABLE_PATTERN = /\b(?:compose|create|draft|prepare|produce|write)\s+(?:(?:a|an|the|your)\s+)?(?:findings?|review|report|summary|analysis|recommendations?)(?:\s+(?:to|at|in)\s+\S+)?/gi;
1122
+ const RECOVERY_REVIEW_CONTEXT_OBJECT_PATTERN = /\breview\s+(?:(?:the|this|that|saved)\s+)?(?:patch|diff|changes?|implementation|report)\b/gi;
1123
+ const RECOVERY_REVIEW_MUTATION_NOUN_CONTEXT_PATTERN = /\b(?:later\s+real\s+)?update\s+imperatives?\b/gi;
1124
+ const RECOVERY_REVIEW_PRIOR_FIX_CONTEXT_PATTERN = /\bthe\s+prior\s+fix\s+keeps\b/gi;
1125
+ const RECOVERY_REVIEW_DETECTION_CONTEXT_PATTERN = /\b(?:mutation\s+detection\s+now\s+includes\s+move\/rename\/copy\s+file\s+mutation\s+imperatives|delegation\s+detection\s+now\s+blocks\s+get\/let\/have\/tell\/ask\s+follow-up\s+forms)(?=[,.;!?\n]|\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b|$)/gi;
1126
+ const RECOVERY_REVIEW_PATTERN_CHANGE_CONTEXT_PATTERN = /\bRECOVERY_REVIEW_MUTATION_VERB_PATTERN\s+now\s+includes\s+append,\s+prepend,\s+and\s+sav(?:e|ing)\b(?=\s*(?:[,.;!?\n)]|$))/gi;
1127
+ const RECOVERY_REVIEW_GIT_PATTERN_CHANGE_CONTEXT_PATTERN = /\bfixed\s+mutating\s+git\s+follow-up\s+bypasses\b|\b(?:added|adding)\s+[a-z][a-z-]*(?:\/[a-z][a-z-]*)*(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)[a-z][a-z-]*(?:\/[a-z][a-z-]*)*)*\s+to\s+the\s+(?:mutation\s+imperative|mutating\s+git\s+command)\s+pattern\b|\band\s+[a-z][a-z-]*(?:\/[a-z][a-z-]*)*(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)[a-z][a-z-]*(?:\/[a-z][a-z-]*)*)*\s+to\s+the\s+mutating\s+git\s+command\s+pattern\b/gi;
1128
+ const RECOVERY_REVIEW_VALIDATION_EVIDENCE_PATTERN = /\bvalidation(?:\s+after\s+fix)?\s*:/gi;
1129
+ const RECOVERY_REVIEW_CONTRACT_PROHIBITION_PATTERN = /\b(?:do not|don't|must not)\s+(?:mutate\s+durable\s+state|launch\s+(?:mutating|destructive|mutating\/destructive)\s+work)(?:\s+or\s+(?:mutate\s+durable\s+state|launch\s+(?:mutating|destructive|mutating\/destructive)\s+work))*/gi;
1130
+ const RECOVERY_REVIEW_ONLY_REVIEW_CONTRACT_PATTERN = /\bmay\s+only\s+launch\s+explicit\s+read-only\s+review\s+children\s+with\s+acceptance:false\b/gi;
1131
+ const RECOVERY_REVIEW_BLOCKED_CONTEXT_FRAGMENT_PATTERN = /\bstate\.set, runs\.host, runs\.steer, ordinary\/mutating children, and destructive command wording are blocked(?=[,.;!?\n)\]}]|$)/gi;
1132
+ const RECOVERY_REVIEW_REGRESSION_EVIDENCE_PATTERN = /\b(?:existing regressions cover plain rm and git clean\/reset\/restore|prior regressions covering rm\/git clean as evidence only)(?=[,.;!?\n)\]}]|$)/gi;
1133
+ const RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN = /(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*\s+(?:is|are|remains?)\s+(?:blocked|(?:an?\s+)?blocked\s+examples?)(?=[,.;!?\n)\]}]|$)/gi;
1134
+ const RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN = /\b(?:examples?|phrasing|forms|variants):\s*(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*/gi;
1135
+ const RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN = /(?:^|[.;!?\n]\s*)blocked\s+examples:\s*(?:\r?\n[ \t]*(?:[-*]|\d+[.)])\s+[^\r\n]+)+/gim;
1136
+ const RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN = /\b(?:this\s+)?blocks?\s+examples?\s+like\s+(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*/gi;
1137
+ const RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN = /\bexamples?\s+like\s+(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*\s+(?:is|are|remains?)\s+blocked\b/gi;
1138
+ const RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN = /(?:\b(?:(?:the\s+)?examples?|commands?\s+hidden\s+in)\s+|(?:^|[\s([{]))(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+|or\s+)?|\s+(?:and|or)\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*\s+(?:(?:is|are|remains?)\s+)?visible\s+and\s+blocked\b/gi;
1139
+ const RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN = /\bdescribed\s+prompts?\s+like\s+Run\s+git\s+rebase\s+main,\s+git\s+rebase\s+main,\s+Run\s+git\s+cherry-pick\s+abc123,\s+cherry-pick\s+abc123,\s+and\s+Stage\s+the\s+changed\s+files\s+as\s+blocked\b/gi;
1140
+ const RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN = /\bpositive\s+git\s+mutations\s+are\s+broader:\s*git\s+branch\s+-D\s+old,\s+git\s+tag\s+-d\s+v1\.0,\s+git\s+stash,\s+git\s+revert\s+abc123,\s+and\s+natural\s+cherry\s+pick\s+abc123\s+now\s+trips?\s+the\s+recovery\s+barrier\b/gi;
1141
+ const RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN = /\bbroadened\s+positive\s+git\s+mutation\s+coverage\s+for\s+natural\s+`cherry\s+pick`,\s+`revert`,\s+`stash`,\s+`tag`,\s+plus\s+git\s+`branch\|revert\|stash\|tag`/gi;
1142
+ const RECOVERY_REVIEW_REGRESSION_QUOTED_EXAMPLE_PATTERN = /\b(?:added\s+)?exact\s+regressions?\s+for\s+(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*/gi;
1143
+ const RECOVERY_REVIEW_REGRESSION_ANAPHORIC_EXAMPLE_PATTERN = /\b(?:added\s+)?exact\s+regressions?\s+for\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request))(?:[\s,]+(?:now|still|again|really|actually|immediately)){0,3}[\s,]+anyway(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request))(?:[\s,]+(?:now|still|again|really|actually|immediately)){0,3}[\s,]+anyway)*/gi;
1144
+ const RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_ANAPHORIC_PATTERN = /\badded\s+(?:exact\s+)?regressions?\s+for\s+quoted\s+rm\s+remaining\s+blocked\s+followed\s+by\s+execute\s+the\s+previous\s+command\b(?=\s*(?:[,.;!?\n)]|\bwhile\b|$))/gi;
1145
+ const RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_NAMED_RM_PATTERN = /\b(?:added\s+)?(?:exact\s+)?regressions?\s+for\s+quoted\s+rm\s+remaining\s+blocked\s+followed\s+by\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request))(?:[\s,]+(?:now|still|again|really|actually|immediately)){0,3}(?:[\s,]+anyway)?(?=\s*(?:[,.;!?\n)]|\bwhile\b|$))/gi;
1146
+ const RECOVERY_REVIEW_BLOCKED_LIVE_VARIANT_CONTEXT_PATTERN = /\b(?:while\s+)?keeping\s+live-command\s+variants\s+such\s+as\s+followed\s+by\s+execute\s+the\s+previous\s+command\s+then\s+update\s+tests\s+blocked\b(?=\s*(?:[,.;!?\n)]|$))/gi;
1147
+ const RECOVERY_REVIEW_ANAPHORIC_REFERENCES_CONTEXT_PATTERN = /\bdirect\s+anaphoric\s+references\s+now\s+include\s+numeric\s+and\s+word\s+ordinals\s+through\s+tenth\s+plus\s+one,\s+so\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?:[\s,]+(?:right|now|still|again|really|actually|immediately)){0,4}[\s,]+anyway(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?:[\s,]+(?:right|now|still|again|really|actually|immediately)){0,4}[\s,]+anyway)*\s+(?:is|are|remains?)\s+blocked\b(?:\s+after\s+quoted\s+destructive\s+examples\s+are\s+scrubbed)?/gi;
1148
+ const RECOVERY_REVIEW_NO_ANAPHORIC_MUTATION_CLAUSE_PATTERN = /\b(?:do not|don't|must not)\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n—–-]){0,80}/gi;
1149
+ const RECOVERY_REVIEW_NO_DELEGATION_CLAUSE_PATTERN = /\b(?:do not|don't|must not)\s+(?:(?:launch|start|spawn|run)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)|(?:get|let|request|hand\s+off|assign|use|have|tell)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child)|(?:get|let|have|tell|request)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:review|implementation|fix(?:es)?|changes?|follow-up)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*\b(?:from|with|via|by)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child)|ask\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:(?:workers?|reviewers?|agents?|subagents?|children|child)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*\b(?:continue|implement|review|fix|edit|write|modify|change|patch|update|delete|remove|create|follow-up)|(?:review|implementation|fix(?:es)?|changes?|follow-up)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*\b(?:from|via)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child)))\b/gi;
1150
+ const RECOVERY_REVIEW_DELEGATION_PATTERN = /\b(?:launch|start|spawn|run)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)\b|\b(?:delegate|hand\s+off|assign)\s+(?:remediation|implementation(?:\s+follow-up)?|changes?|fix(?:es)?|follow-up)\s+to\s+(?:(?:a|an|the)\s+)?(?:workers?|reviewers?|agents?|subagents?|children|child)\b|\b(?:get|let|have|tell|request)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child)\b[^,.;!?\n]*\b(?:continue|implement|review|fix|edit|write|modify|change|patch|update|delete|remove|create|follow-up)\b|\b(?:get|let|have|tell|request)\b[^,.;!?\n]*(?:review|implementation|fix(?:es)?|changes?|follow-up)\b[^,.;!?\n]*\b(?:from|with|via|by)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child)\b|\bask\b[^,.;!?\n]*(?:(?:workers?|reviewers?|agents?|subagents?|children|child)\b[^,.;!?\n]*\b(?:continue|implement|review|fix|edit|write|modify|change|patch|update|delete|remove|create|follow-up)|(?:review|implementation|fix(?:es)?|changes?|follow-up)\b[^,.;!?\n]*\b(?:from|via)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child))\b|\buse\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)\s+(?:for|to)\s+(?:implementation|follow-up|remediation|fix|edit|write|modify|change|patch|update|delete|remove|create)\b/i;
1151
+ const RECOVERY_REVIEW_ANAPHORIC_MUTATION_PATTERN = /\b(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?!(?:\s+(?:(?:now|still|also|already)\s+)*(?:is|are|remains?)\s+blocked\b))(?:(?:[\s,]+\w+){0,4}[\s,]+anyway|(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n]){0,80})(?=\s*(?:[,.;!?\n)]|\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b|$))/i;
1152
+ const RECOVERY_REVIEW_DESTRUCTIVE_COMMAND_PATTERN = /(?:^|[\s;,.`'"([{])(?:\S*\/)?(?:rm|rmdir|unlink|truncate|mv|cp|chmod|chown)\b|\bgit\b(?:\s+(?:-[A-Za-z](?:\s+(?:"[^"\n]*"|'[^'\n]*'|\S+))?|--(?:git-dir|work-tree|namespace|exec-path|config-env)(?:=(?:"[^"\n]*"|'[^'\n]*'|\S+)|\s+(?:"[^"\n]*"|'[^'\n]*'|\S+))|--[A-Za-z0-9-]+(?:=(?:"[^"\n]*"|'[^'\n]*'|\S+))?))*\s+(?:add|branch|cherry-pick|clean|commit|merge|rebase|reset|restore|revert|stash|tag|checkout|switch)\b/i;
1153
+ const RECOVERY_REVIEW_DASH_LIVE_ACTION_PATTERN = /(?:[—–]|--|\s-\s|:|\s\/\s)\s*(?:then\s+)?(?:(?:add|append|apply|change|cherry[ -]pick|clean|commit|copy|create|delete|edit|fix|implement|insert|make|merge|move|modify|mutate|open|patch|prepend|push|rebase|refactor|remove|rename|replace|revert|revise|rewrite|save|stage|stash|tag|touch|update|write)\b|(?:launch|start|spawn|run)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)\b|(?:\S*\/)?(?:rm|rmdir|unlink|truncate|mv|cp|chmod|chown)\b|git\b[^,.;!?\n]*\b(?:add|branch|cherry-pick|clean|commit|merge|rebase|reset|restore|revert|stash|tag|checkout|switch)\b)/i;
1154
+
1155
+ function isExplicitReadOnlyRecoveryReview(params: Record<string, unknown>): boolean {
1156
+ const agent = typeof params.agent === "string" ? params.agent.trim() : "";
1157
+ const task = typeof params.task === "string" ? params.task.trim() : "";
1158
+ const taskDestructiveCommandText = task
1159
+ .replace(RECOVERY_REVIEW_BLOCKED_CONTEXT_FRAGMENT_PATTERN, " ")
1160
+ .replace(RECOVERY_REVIEW_REGRESSION_EVIDENCE_PATTERN, " ")
1161
+ .replace(RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN, " ")
1162
+ .replace(RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN, " ")
1163
+ .replace(RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN, " ")
1164
+ .replace(RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN, " ")
1165
+ .replace(RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN, " ")
1166
+ .replace(RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN, " ")
1167
+ .replace(RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN, " ")
1168
+ .replace(RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN, " ")
1169
+ .replace(RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN, " ")
1170
+ .replace(RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_NAMED_RM_PATTERN, " ");
1171
+ const taskDashLiveActionText = task
1172
+ .replace(RECOVERY_REVIEW_DELIVERABLE_PATTERN, " ")
1173
+ .replace(RECOVERY_REVIEW_CONTEXT_OBJECT_PATTERN, " ")
1174
+ .replace(RECOVERY_REVIEW_MUTATION_NOUN_CONTEXT_PATTERN, " ")
1175
+ .replace(RECOVERY_REVIEW_PRIOR_FIX_CONTEXT_PATTERN, " ")
1176
+ .replace(RECOVERY_REVIEW_DETECTION_CONTEXT_PATTERN, " ")
1177
+ .replace(RECOVERY_REVIEW_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
1178
+ .replace(RECOVERY_REVIEW_GIT_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
1179
+ .replace(RECOVERY_REVIEW_VALIDATION_EVIDENCE_PATTERN, " ")
1180
+ .replace(RECOVERY_REVIEW_CONTRACT_PROHIBITION_PATTERN, " ")
1181
+ .replace(RECOVERY_REVIEW_ONLY_REVIEW_CONTRACT_PATTERN, " ")
1182
+ .replace(RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN, " ")
1183
+ .replace(RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN, " ")
1184
+ .replace(RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN, " ")
1185
+ .replace(RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN, " ")
1186
+ .replace(RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN, " ")
1187
+ .replace(RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN, " ")
1188
+ .replace(RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN, " ")
1189
+ .replace(RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN, " ")
1190
+ .replace(RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN, " ")
1191
+ .replace(RECOVERY_REVIEW_REGRESSION_QUOTED_EXAMPLE_PATTERN, " ")
1192
+ .replace(RECOVERY_REVIEW_REGRESSION_ANAPHORIC_EXAMPLE_PATTERN, " ")
1193
+ .replace(RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_ANAPHORIC_PATTERN, " ")
1194
+ .replace(RECOVERY_REVIEW_BLOCKED_LIVE_VARIANT_CONTEXT_PATTERN, " ")
1195
+ .replace(RECOVERY_REVIEW_ANAPHORIC_REFERENCES_CONTEXT_PATTERN, " ");
1196
+ const taskMutationText = task
1197
+ .replace(RECOVERY_REVIEW_DELIVERABLE_PATTERN, " ")
1198
+ .replace(RECOVERY_REVIEW_CONTEXT_OBJECT_PATTERN, " ")
1199
+ .replace(RECOVERY_REVIEW_MUTATION_NOUN_CONTEXT_PATTERN, " ")
1200
+ .replace(RECOVERY_REVIEW_PRIOR_FIX_CONTEXT_PATTERN, " ")
1201
+ .replace(RECOVERY_REVIEW_DETECTION_CONTEXT_PATTERN, " ")
1202
+ .replace(RECOVERY_REVIEW_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
1203
+ .replace(RECOVERY_REVIEW_GIT_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
1204
+ .replace(RECOVERY_REVIEW_VALIDATION_EVIDENCE_PATTERN, " ")
1205
+ .replace(RECOVERY_REVIEW_CONTRACT_PROHIBITION_PATTERN, " ")
1206
+ .replace(RECOVERY_REVIEW_ONLY_REVIEW_CONTRACT_PATTERN, " ")
1207
+ .replace(RECOVERY_REVIEW_BLOCKED_CONTEXT_FRAGMENT_PATTERN, " ")
1208
+ .replace(RECOVERY_REVIEW_REGRESSION_EVIDENCE_PATTERN, " ")
1209
+ .replace(RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN, " ")
1210
+ .replace(RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN, " ")
1211
+ .replace(RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN, " ")
1212
+ .replace(RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN, " ")
1213
+ .replace(RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN, " ")
1214
+ .replace(RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN, " ")
1215
+ .replace(RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN, " ")
1216
+ .replace(RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN, " ")
1217
+ .replace(RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN, " ")
1218
+ .replace(RECOVERY_REVIEW_REGRESSION_QUOTED_EXAMPLE_PATTERN, " ")
1219
+ .replace(RECOVERY_REVIEW_REGRESSION_ANAPHORIC_EXAMPLE_PATTERN, " ")
1220
+ .replace(RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_ANAPHORIC_PATTERN, " ")
1221
+ .replace(RECOVERY_REVIEW_BLOCKED_LIVE_VARIANT_CONTEXT_PATTERN, " ")
1222
+ .replace(RECOVERY_REVIEW_ANAPHORIC_REFERENCES_CONTEXT_PATTERN, " ")
1223
+ .replace(RECOVERY_REVIEW_NO_ANAPHORIC_MUTATION_CLAUSE_PATTERN, " ")
1224
+ .replace(RECOVERY_REVIEW_NO_DELEGATION_CLAUSE_PATTERN, " ")
1225
+ .replace(RECOVERY_REVIEW_NO_MUTATION_CLAUSE_PATTERN, " ")
1226
+ .replace(new RegExp(RECOVERY_REVIEW_READ_ONLY_PATTERN.source, "gi"), " ");
1227
+ return params.acceptance === false
1228
+ && agent !== ""
1229
+ && /\b(?:advisor|oracle|review|reviewer)\b/i.test(agent)
1230
+ && RECOVERY_REVIEW_READ_ONLY_PATTERN.test(task)
1231
+ && !RECOVERY_REVIEW_DESTRUCTIVE_COMMAND_PATTERN.test(taskDestructiveCommandText)
1232
+ && !RECOVERY_REVIEW_MUTATION_VERB_PATTERN.test(taskMutationText)
1233
+ && !RECOVERY_REVIEW_DELEGATION_PATTERN.test(taskMutationText)
1234
+ && !RECOVERY_REVIEW_ANAPHORIC_MUTATION_PATTERN.test(taskMutationText)
1235
+ && !RECOVERY_REVIEW_DESTRUCTIVE_COMMAND_PATTERN.test(taskMutationText)
1236
+ && !RECOVERY_REVIEW_DASH_LIVE_ACTION_PATTERN.test(taskDashLiveActionText)
1237
+ && classifyTaskMutationIntent(agent, task).kind === "read-only";
1238
+ }
1239
+
1240
+ function recoveryBarrierMessage(sourceKey: string, target: string): string {
1241
+ return `Run '${target}' cannot launch after run '${sourceKey}' returned rejected acceptance recovery; only explicit read-only review children with acceptance:false may follow.`;
1242
+ }
1243
+
1070
1244
  function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
1071
1245
  if (!isRecord(value)) return false;
1072
1246
  const prototype = Object.getPrototypeOf(value);
@@ -1227,7 +1401,7 @@ function validateStaticHostCall(call: AstNode): WorkflowScriptValidationError[]
1227
1401
  for (const property of params.properties) {
1228
1402
  if (!astNode(property)) continue;
1229
1403
  const name = staticPropertyKey(property);
1230
- if (name !== undefined && !allowed.has(name)) errors.push({ message: `runs.host params contain unsupported field '${name}'.`, ...nodeLocation(property) });
1404
+ if (name !== undefined && !allowed.has(name)) errors.push({ message: name === "cwd" ? "runs.host params contain unsupported field 'cwd'. The host step does not accept per-step cwd; commands and relative output paths use the workflow cwd. Set cwd on the outer subagent request, or put a trusted directory change in command (for example, 'cd /path/to/worktree && npm test')." : `runs.host params contain unsupported field '${name}'.`, ...nodeLocation(property) });
1231
1405
  }
1232
1406
  const kindNode = directObjectPropertyValue(params, "kind");
1233
1407
  const commandNode = directObjectPropertyValue(params, "command");
@@ -1478,7 +1652,7 @@ function setupAbortResumeParams(params: Record<string, unknown>, result: Workflo
1478
1652
  export async function runWorkflowScript(options: RunWorkflowScriptOptions): Promise<WorkflowScriptResult> {
1479
1653
  if (!options.script.trim()) throw new Error("workflowScript must not be empty.");
1480
1654
  if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1)) throw new Error("workflow script timeout must be a positive integer.");
1481
- if (options.globalConcurrencyLimit !== undefined && (!Number.isInteger(options.globalConcurrencyLimit) || options.globalConcurrencyLimit < 1)) {
1655
+ if (options.globalConcurrencyLimit !== undefined && (!Number.isSafeInteger(options.globalConcurrencyLimit) || options.globalConcurrencyLimit < 1)) {
1482
1656
  throw new Error("workflow script global concurrency limit must be a positive integer.");
1483
1657
  }
1484
1658
  const launchSemaphore = new Semaphore(options.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
@@ -1505,6 +1679,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1505
1679
  const observedSteerCalls = new Set<number>();
1506
1680
  const observedHostCalls = new Set<number>();
1507
1681
  const childController = new AbortController();
1682
+ let acceptanceRecoveryBarrier: { key: string } | undefined;
1508
1683
  let settled = false;
1509
1684
  let finishing = false;
1510
1685
 
@@ -1524,6 +1699,13 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1524
1699
  console.error("Workflow onTrace callback failed:", error);
1525
1700
  }
1526
1701
  };
1702
+ const lanePlanChanged = (lanes: WorkflowLanePlan[]) => {
1703
+ try {
1704
+ options.onLanePlan?.(lanes);
1705
+ } catch (error) {
1706
+ console.error("Workflow onLanePlan callback failed:", error);
1707
+ }
1708
+ };
1527
1709
  const hostStepChanged = (hostStep: HostStepNodeV1) => {
1528
1710
  try {
1529
1711
  options.onHostStep?.(hostStep);
@@ -1536,6 +1718,13 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1536
1718
  const text = error instanceof Error ? error.message : String(error);
1537
1719
  return { key, ok: false, output: text, error: text, artifactPaths: [] };
1538
1720
  };
1721
+ const assertRecoveryBarrierAllowsRun = (key: string, params: Record<string, unknown>): void => {
1722
+ if (!acceptanceRecoveryBarrier || isExplicitReadOnlyRecoveryReview(params)) return;
1723
+ throw new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, key));
1724
+ };
1725
+ const recordAcceptanceRecoveryBarrier = (key: string, result: WorkflowScriptChildResult): void => {
1726
+ if (isAcceptanceMetadataRecovery(result)) acceptanceRecoveryBarrier = { key };
1727
+ };
1539
1728
  const stopChild = (key: string, message = `Workflow child '${key}' stopped by user.`): boolean => {
1540
1729
  if (!launches.has(key) || children.has(key)) return false;
1541
1730
  stoppedLaunches.add(key);
@@ -1626,6 +1815,10 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1626
1815
  });
1627
1816
  worker.on("message", (message: Record<string, unknown>) => {
1628
1817
  if (settled) return;
1818
+ if (message.type === "lanePlan" && Array.isArray(message.lanes)) {
1819
+ lanePlanChanged(message.lanes as WorkflowLanePlan[]);
1820
+ return;
1821
+ }
1629
1822
  if (message.type === "emit") {
1630
1823
  try {
1631
1824
  assertWorkflowJsonValue(message.value, "emit");
@@ -1712,6 +1905,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1712
1905
  return respond(Promise.reject(error));
1713
1906
  }
1714
1907
  if (message.method === "state.get") return respond(Promise.resolve().then(() => options.state!.get(key)));
1908
+ if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `state.set('${key}')`))));
1715
1909
  const value = message.args.value;
1716
1910
  try {
1717
1911
  assertWorkflowJsonValue(value, `state.set('${key}') value`);
@@ -1748,6 +1942,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1748
1942
  const steerMessage = message.args.message;
1749
1943
  if (typeof steerMessage !== "string" || !steerMessage.trim()) return respond(Promise.reject(new Error(`runs.steer('${key}') requires a non-empty message.`)));
1750
1944
  const steerOptions = isRecord(message.args.options) ? message.args.options as WorkflowSteerOptions : {};
1945
+ if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `runs.steer('${key}')`))));
1751
1946
  const startedAt = Date.now();
1752
1947
  trace.push({ operation: "steer", key, state: "started" });
1753
1948
  traceChanged();
@@ -1778,6 +1973,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1778
1973
  } catch (error) {
1779
1974
  return respond(Promise.reject(error));
1780
1975
  }
1976
+ if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `runs.host('${key}')`))));
1781
1977
  if (!options.host) return respond(Promise.reject(new Error("runs.host is unavailable in this host context.")));
1782
1978
  if (hostCalls.size >= HOST_STEP_MAX_COUNT) return respond(Promise.reject(new Error(`workflowScript supports at most ${HOST_STEP_MAX_COUNT} runs.host calls.`)));
1783
1979
  const startedAt = Date.now();
@@ -1833,7 +2029,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1833
2029
  const deliver = (promise: Promise<WorkflowScriptChildResult>) => collectFailure
1834
2030
  ? promise
1835
2031
  : promise.then((result) => {
1836
- if (!result.ok && !result.stopped) {
2032
+ const recoverableAcceptanceMetadata = result.recovery?.status === "available-for-review"
2033
+ && result.recovery.reason === "acceptance-metadata-rejected";
2034
+ if (!result.ok && !result.stopped && !recoverableAcceptanceMetadata) {
1837
2035
  const childError = new Error(result.detached ? `Run '${key}' detached: ${result.error ?? result.output}` : `Run '${key}' failed: ${result.error ?? result.output}`) as Error & { workflowErrorKind?: "detached-child" };
1838
2036
  if (result.detached) childError.workflowErrorKind = "detached-child";
1839
2037
  throw childError;
@@ -1897,6 +2095,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1897
2095
  });
1898
2096
  admission = Promise.resolve().then(() => {
1899
2097
  if (settled || finishing) return;
2098
+ for (const call of calls) assertRecoveryBarrierAllowsRun(call.key, call.params);
1900
2099
  return options.admit?.(calls);
1901
2100
  });
1902
2101
  if (batch) batchAdmissions.set(batch.id, admission);
@@ -1957,6 +2156,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1957
2156
  childStopControllers.delete(key);
1958
2157
  if (stoppedLaunches.has(key)) return children.get(key) ?? normalized;
1959
2158
  children.set(key, normalized);
2159
+ recordAcceptanceRecoveryBarrier(key, normalized);
1960
2160
  const state = normalized.ok ? "completed" : normalized.stopped ? "stopped" : normalized.detached ? "detached" : "failed";
1961
2161
  trace.push({ operation: "run", key, state, durationMs: Date.now() - startedAt, ...workflowStringMetadata(params), ...(generatedLaneKey ? { generatedLaneKey } : {}), ...(normalized.agent ? { agent: normalized.agent } : {}), ...(normalized.runId ? { runId: normalized.runId } : {}), ...(!normalized.ok ? { error: normalized.error ?? normalized.output } : {}) });
1962
2162
  traceChanged();
@@ -117,5 +117,5 @@ export function parseWorkflowChildSummary(value: unknown): WorkflowChildSummaryV
117
117
  if (new Set(children.map((child) => child.childId)).size !== children.length) throw new Error("workflowChildren has duplicate childId values.");
118
118
  if (typeof input.parentToolCallId !== "string") throw new Error("workflowChildren.parentToolCallId is invalid.");
119
119
  if (typeof input.workflowRunId !== "string") throw new Error("workflowChildren.workflowRunId is invalid.");
120
- return { version: 1, parentToolCallId: requiredId(input.parentToolCallId, "parentToolCallId"), workflowRunId: requiredId(input.workflowRunId, "workflowRunId"), inventoryComplete: input.inventoryComplete, workflowState, children };
120
+ return { version: 1, parentToolCallId: requiredId(input.parentToolCallId, "workflowChildren.parentToolCallId"), workflowRunId: requiredId(input.workflowRunId, "workflowChildren.workflowRunId"), inventoryComplete: input.inventoryComplete, workflowState, children };
121
121
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
4
- import type { ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState, WorkflowRecoveryAction, WorkflowTerminalOutcome, WorkflowTerminalResolution } from "../shared/types.ts";
4
+ import type { AcceptanceRecoveryMetadata, ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState, WorkflowRecoveryAction, WorkflowResourceProvenanceV1, WorkflowTerminalOutcome, WorkflowTerminalResolution } from "../shared/types.ts";
5
5
  import type { WorkflowReceiptResumeReference, WorkflowScriptChildResult } from "./scripted-workflow.ts";
6
6
  import { parseWorkflowChildSummary } from "./workflow-child-summary.ts";
7
7
  import { HOST_STEP_MAX_COUNT, assertUniqueHostStepIds, parseHostStepNode } from "../runs/shared/host-step-status.ts";
@@ -14,6 +14,7 @@ export const WORKFLOW_RECEIPT_FILE = "workflow-receipt.json";
14
14
  const MAX_WORKFLOW_RECEIPT_BYTES = 2 * 1024 * 1024;
15
15
 
16
16
  const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
17
+ const RESOURCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
17
18
 
18
19
  function assertSafeRunId(value: string, label: string): string {
19
20
  const normalized = value.trim();
@@ -38,6 +39,7 @@ export function buildWorkflowReceipt(input: {
38
39
  children: WorkflowScriptChildResult[];
39
40
  hostSteps?: WorkflowReceipt["hostSteps"];
40
41
  workflowChildren?: WorkflowReceipt["workflowChildren"];
42
+ resource?: WorkflowResourceProvenanceV1;
41
43
  terminalOutcome?: WorkflowTerminalOutcome;
42
44
  createdAt?: number;
43
45
  }): WorkflowReceipt {
@@ -61,6 +63,7 @@ export function buildWorkflowReceipt(input: {
61
63
  ...(child.requestedContext ? { requestedContext: child.requestedContext } : {}),
62
64
  ...(child.resolvedContext ? { resolvedContext: child.resolvedContext } : {}),
63
65
  ...(child.outputReference ? { outputReference: child.outputReference } : {}),
66
+ ...(child.recovery ? { acceptanceRecovery: child.recovery } : {}),
64
67
  ...(child.externalAdapter ? { externalAdapter: child.externalAdapter } : {}),
65
68
  continuation: { runIds },
66
69
  };
@@ -71,7 +74,8 @@ export function buildWorkflowReceipt(input: {
71
74
  if (input.hostSteps && input.hostSteps.length > HOST_STEP_MAX_COUNT) throw new Error(`Workflow receipt has more than ${HOST_STEP_MAX_COUNT} host steps.`);
72
75
  const hostSteps = input.hostSteps?.map((hostStep, index) => parseHostStepNode(hostStep, `workflow receipt hostSteps[${index}]`));
73
76
  if (hostSteps) assertUniqueHostStepIds(hostSteps, "workflow receipt");
74
- return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
77
+ const resource = parseWorkflowResource(input.resource, "workflow receipt");
78
+ return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
75
79
  }
76
80
 
77
81
  export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt): string {
@@ -210,6 +214,20 @@ function parseTerminalOutcome(value: unknown, label: string): WorkflowTerminalOu
210
214
  return { state: "partial", reason: outcome.reason };
211
215
  }
212
216
 
217
+ function parseAcceptanceRecoveryMetadata(value: unknown, label: string): AcceptanceRecoveryMetadata | undefined {
218
+ if (value === undefined) return undefined;
219
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
220
+ const recovery = value as Record<string, unknown>;
221
+ if (recovery.status !== "available-for-review" || recovery.reason !== "acceptance-metadata-rejected") throw new Error(`${label} is invalid.`);
222
+ if (typeof recovery.reportPath !== "string" || !recovery.reportPath.trim() || typeof recovery.reportHash !== "string" || !/^[a-f0-9]{64}$/u.test(recovery.reportHash)) throw new Error(`${label} is invalid.`);
223
+ return {
224
+ status: "available-for-review",
225
+ reason: "acceptance-metadata-rejected",
226
+ reportPath: recovery.reportPath,
227
+ reportHash: recovery.reportHash,
228
+ };
229
+ }
230
+
213
231
  function parseEntry(value: unknown, key: string, source: string): WorkflowReceiptEntry {
214
232
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' must be an object.`);
215
233
  const entry = value as Record<string, unknown>;
@@ -231,10 +249,11 @@ function parseEntry(value: unknown, key: string, source: string): WorkflowReceip
231
249
  if (state === "resumable" && latestRunId === undefined) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' resumable entry has no retained run id.`);
232
250
  if (state === "not-resumable" && (typeof reason !== "string" || !reason.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' non-resumable reason is missing.`);
233
251
  const terminalOutcome = parseTerminalOutcome(entry.terminalOutcome, `Invalid workflow receipt '${source}': entry '${key}' terminalOutcome`);
252
+ const acceptanceRecovery = parseAcceptanceRecoveryMetadata(entry.acceptanceRecovery, `Invalid workflow receipt '${source}': entry '${key}' acceptanceRecovery`);
234
253
  parseExternalCliReceiptMetadata(entry.externalAdapter, key, source);
235
254
  const lane = normalizeWorkflowLaneMetadata(entry.lane, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
236
255
  assertWorkflowLaneKey(lane, key, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
237
- return { ...(value as WorkflowReceiptEntry), ...(lane ? { lane } : {}), ...(terminalOutcome ? { terminalOutcome } : {}) };
256
+ return { ...(value as WorkflowReceiptEntry), ...(lane ? { lane } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(acceptanceRecovery ? { acceptanceRecovery } : {}) };
238
257
  }
239
258
 
240
259
  function parseWorkflowResolution(value: unknown, source: string): WorkflowTerminalResolution | undefined {
@@ -243,6 +262,23 @@ function parseWorkflowResolution(value: unknown, source: string): WorkflowTermin
243
262
  return value;
244
263
  }
245
264
 
265
+ function parseWorkflowResource(value: unknown, source: string): WorkflowResourceProvenanceV1 | undefined {
266
+ if (value === undefined) return undefined;
267
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': resource must be an object.`);
268
+ const resource = value as Record<string, unknown>;
269
+ if (resource.kind !== "workflow" || typeof resource.name !== "string" || !KEY_PATTERN.test(resource.name) || !Number.isInteger(resource.version) || (resource.version as number) < 1 || (resource.version as number) > 1_000_000 || resource.invocation !== "named" || resource.expansion !== "resolved" || typeof resource.id !== "string" || !RESOURCE_ID_PATTERN.test(resource.id)) {
270
+ throw new Error(`Invalid workflow receipt '${source}': resource is invalid.`);
271
+ }
272
+ return {
273
+ kind: "workflow",
274
+ name: resource.name,
275
+ version: resource.version as number,
276
+ invocation: "named",
277
+ expansion: "resolved",
278
+ id: resource.id,
279
+ };
280
+ }
281
+
246
282
  function parseRecovery(value: unknown, workflowRunId: string, entries: Record<string, WorkflowReceiptEntry>, source: string): WorkflowRecoveryAction[] | undefined {
247
283
  if (value === undefined) return undefined;
248
284
  if (!Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': recovery must be an array.`);
@@ -294,9 +330,10 @@ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string)
294
330
  assertUniqueHostStepIds(hostSteps, receiptPath);
295
331
  }
296
332
  const workflowResolution = parseWorkflowResolution(receipt.workflowResolution, receiptPath);
333
+ const resource = parseWorkflowResource(receipt.resource, receiptPath);
297
334
  const terminalOutcome = parseTerminalOutcome(receipt.terminalOutcome, `Invalid workflow receipt '${receiptPath}': terminalOutcome`);
298
335
  const recovery = parseRecovery(receipt.recovery, workflowRunId, entries, receiptPath);
299
- return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
336
+ return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
300
337
  }
301
338
 
302
339
  export function resolveWorkflowReceiptResumeEntry(input: {