pi-subagents 0.60.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 (57) hide show
  1. package/CHANGELOG.md +32 -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 +12 -3
  8. package/docs/workflows.md +14 -13
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/SKILL.md +6 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +42 -11
  14. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
  16. package/skills/pi-subagents/references/review-and-validation.md +1 -1
  17. package/src/agents/agent-management.ts +102 -63
  18. package/src/agents/agents.ts +527 -221
  19. package/src/api/background-work.ts +7 -2
  20. package/src/api/external-runs.ts +67 -4
  21. package/src/api/preflight.ts +13 -8
  22. package/src/api/shared-types.ts +1 -0
  23. package/src/extension/index.ts +7 -4
  24. package/src/extension/public-execution.ts +47 -4
  25. package/src/extension/rpc.ts +62 -4
  26. package/src/extension/schemas.ts +11 -7
  27. package/src/extension/tool-description.ts +14 -18
  28. package/src/runs/background/async-execution.ts +51 -35
  29. package/src/runs/background/async-job-tracker.ts +62 -3
  30. package/src/runs/background/async-resume.ts +3 -1
  31. package/src/runs/background/async-status.ts +59 -9
  32. package/src/runs/background/auto-drain.ts +1 -1
  33. package/src/runs/background/fleet-view.ts +1 -1
  34. package/src/runs/background/result-watcher.ts +1 -1
  35. package/src/runs/background/resume-guidance.ts +1 -1
  36. package/src/runs/background/run-status.ts +2 -2
  37. package/src/runs/background/subagent-runner.ts +1 -2
  38. package/src/runs/background/subagent-wait.ts +20 -21
  39. package/src/runs/background/wait-completions.ts +1 -1
  40. package/src/runs/background/wait-tool.ts +24 -18
  41. package/src/runs/foreground/execution.ts +61 -2
  42. package/src/runs/foreground/subagent-executor.ts +178 -58
  43. package/src/runs/shared/acceptance.ts +43 -18
  44. package/src/runs/shared/host-step-status.ts +1 -0
  45. package/src/runs/shared/model-fallback.ts +61 -17
  46. package/src/runs/shared/permissions.ts +1 -1
  47. package/src/runs/shared/tool-timeout.ts +1 -1
  48. package/src/runs/shared/workflow-graph.ts +3 -2
  49. package/src/shared/types.ts +40 -3
  50. package/src/shared/workflow-child-permit.ts +91 -0
  51. package/src/slash/prompt-template-bridge.ts +37 -1
  52. package/src/slash/slash-commands.ts +18 -26
  53. package/src/tui/render.ts +95 -52
  54. package/src/workflows/scripted-workflow.ts +153 -4
  55. package/src/workflows/workflow-child-summary.ts +1 -1
  56. package/src/workflows/workflow-receipt.ts +41 -4
  57. package/src/workflows/workflow-resources.ts +150 -0
@@ -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}$/;
@@ -604,7 +605,7 @@ function validateRunCall(key, params, label, fingerprints) {
604
605
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
605
606
  if (hostKeys.has(key)) throw new Error("Workflow key '" + key + "' is already used by runs.host.");
606
607
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error(label + " requires a params object.");
607
- 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")) {
608
609
  const hint = label === "runs.run" ? "; use runs.all(...) and JavaScript control flow for orchestration." : ".";
609
610
  throw new Error(label + " accepts one child via { agent, task } and execution controls only" + hint);
610
611
  }
@@ -975,6 +976,7 @@ export interface WorkflowScriptChildResult {
975
976
  requestedContext?: "fresh" | "fork";
976
977
  resolvedContext?: "fresh" | "fork" | "mixed";
977
978
  outputReference?: string;
979
+ recovery?: AcceptanceRecoveryMetadata;
978
980
  outputPathMapping?: { requestedPath: string; savedPath: string };
979
981
  externalAdapter?: import("../shared/types.ts").ExternalCliReceiptMetadata;
980
982
  resumability?: { state: "resumable" } | { state: "not-resumable"; reason: string };
@@ -1107,6 +1109,138 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1107
1109
  return !!value && typeof value === "object" && !Array.isArray(value);
1108
1110
  }
1109
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
+
1110
1244
  function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
1111
1245
  if (!isRecord(value)) return false;
1112
1246
  const prototype = Object.getPrototypeOf(value);
@@ -1518,7 +1652,7 @@ function setupAbortResumeParams(params: Record<string, unknown>, result: Workflo
1518
1652
  export async function runWorkflowScript(options: RunWorkflowScriptOptions): Promise<WorkflowScriptResult> {
1519
1653
  if (!options.script.trim()) throw new Error("workflowScript must not be empty.");
1520
1654
  if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1)) throw new Error("workflow script timeout must be a positive integer.");
1521
- if (options.globalConcurrencyLimit !== undefined && (!Number.isInteger(options.globalConcurrencyLimit) || options.globalConcurrencyLimit < 1)) {
1655
+ if (options.globalConcurrencyLimit !== undefined && (!Number.isSafeInteger(options.globalConcurrencyLimit) || options.globalConcurrencyLimit < 1)) {
1522
1656
  throw new Error("workflow script global concurrency limit must be a positive integer.");
1523
1657
  }
1524
1658
  const launchSemaphore = new Semaphore(options.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
@@ -1545,6 +1679,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1545
1679
  const observedSteerCalls = new Set<number>();
1546
1680
  const observedHostCalls = new Set<number>();
1547
1681
  const childController = new AbortController();
1682
+ let acceptanceRecoveryBarrier: { key: string } | undefined;
1548
1683
  let settled = false;
1549
1684
  let finishing = false;
1550
1685
 
@@ -1583,6 +1718,13 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1583
1718
  const text = error instanceof Error ? error.message : String(error);
1584
1719
  return { key, ok: false, output: text, error: text, artifactPaths: [] };
1585
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
+ };
1586
1728
  const stopChild = (key: string, message = `Workflow child '${key}' stopped by user.`): boolean => {
1587
1729
  if (!launches.has(key) || children.has(key)) return false;
1588
1730
  stoppedLaunches.add(key);
@@ -1763,6 +1905,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1763
1905
  return respond(Promise.reject(error));
1764
1906
  }
1765
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}')`))));
1766
1909
  const value = message.args.value;
1767
1910
  try {
1768
1911
  assertWorkflowJsonValue(value, `state.set('${key}') value`);
@@ -1799,6 +1942,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1799
1942
  const steerMessage = message.args.message;
1800
1943
  if (typeof steerMessage !== "string" || !steerMessage.trim()) return respond(Promise.reject(new Error(`runs.steer('${key}') requires a non-empty message.`)));
1801
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}')`))));
1802
1946
  const startedAt = Date.now();
1803
1947
  trace.push({ operation: "steer", key, state: "started" });
1804
1948
  traceChanged();
@@ -1829,6 +1973,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1829
1973
  } catch (error) {
1830
1974
  return respond(Promise.reject(error));
1831
1975
  }
1976
+ if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `runs.host('${key}')`))));
1832
1977
  if (!options.host) return respond(Promise.reject(new Error("runs.host is unavailable in this host context.")));
1833
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.`)));
1834
1979
  const startedAt = Date.now();
@@ -1884,7 +2029,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1884
2029
  const deliver = (promise: Promise<WorkflowScriptChildResult>) => collectFailure
1885
2030
  ? promise
1886
2031
  : promise.then((result) => {
1887
- 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) {
1888
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" };
1889
2036
  if (result.detached) childError.workflowErrorKind = "detached-child";
1890
2037
  throw childError;
@@ -1948,6 +2095,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1948
2095
  });
1949
2096
  admission = Promise.resolve().then(() => {
1950
2097
  if (settled || finishing) return;
2098
+ for (const call of calls) assertRecoveryBarrierAllowsRun(call.key, call.params);
1951
2099
  return options.admit?.(calls);
1952
2100
  });
1953
2101
  if (batch) batchAdmissions.set(batch.id, admission);
@@ -2008,6 +2156,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2008
2156
  childStopControllers.delete(key);
2009
2157
  if (stoppedLaunches.has(key)) return children.get(key) ?? normalized;
2010
2158
  children.set(key, normalized);
2159
+ recordAcceptanceRecoveryBarrier(key, normalized);
2011
2160
  const state = normalized.ok ? "completed" : normalized.stopped ? "stopped" : normalized.detached ? "detached" : "failed";
2012
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 } : {}) });
2013
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: {
@@ -0,0 +1,150 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { stableJsonDigest } from "../shared/launch-contract.ts";
3
+ import {
4
+ createWorkflowResourcePermit,
5
+ type WorkflowResourceAuthority,
6
+ type WorkflowResourcePermit,
7
+ } from "../shared/workflow-child-permit.ts";
8
+ import type { WorkflowResourceProvenanceV1 } from "../shared/types.ts";
9
+
10
+ const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
11
+ const MAX_ARGS_BYTES = 16 * 1024;
12
+ const MAX_STRING_BYTES = 16 * 1024;
13
+
14
+ export interface ResolvedWorkflowResource {
15
+ script: string;
16
+ permit: WorkflowResourcePermit;
17
+ provenance: WorkflowResourceProvenanceV1;
18
+ }
19
+
20
+ export type WorkflowResourceResolution =
21
+ | { ok: true; resource: ResolvedWorkflowResource }
22
+ | { ok: false; error: string };
23
+
24
+ type WorkflowResourceDefinition = {
25
+ name: string;
26
+ version: number;
27
+ resolve: (args: Record<string, unknown>) => { script: string; authority: WorkflowResourceAuthority } | { error: string };
28
+ };
29
+
30
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
32
+ const prototype = Object.getPrototypeOf(value);
33
+ return prototype === Object.prototype || prototype === null;
34
+ }
35
+
36
+ function jsonByteLength(value: unknown): number {
37
+ try {
38
+ const encoded = JSON.stringify(value);
39
+ if (encoded === undefined) throw new Error("must contain JSON data");
40
+ return Buffer.byteLength(encoded, "utf8");
41
+ } catch (error) {
42
+ throw new Error(`must contain plain JSON data: ${error instanceof Error ? error.message : String(error)}`);
43
+ }
44
+ }
45
+
46
+ function validatePlainJson(value: unknown, path: string, depth = 0): void {
47
+ if (depth > 8) throw new Error(`${path} is too deeply nested.`);
48
+ if (value === null || typeof value === "boolean") return;
49
+ if (typeof value === "string") {
50
+ if (!value.trim()) throw new Error(`${path} must not be empty.`);
51
+ if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) throw new Error(`${path} exceeds ${MAX_STRING_BYTES} bytes.`);
52
+ return;
53
+ }
54
+ if (typeof value === "number") {
55
+ if (!Number.isFinite(value)) throw new Error(`${path} must be finite.`);
56
+ return;
57
+ }
58
+ if (Array.isArray(value)) {
59
+ if (value.length > 64) throw new Error(`${path} contains too many items.`);
60
+ for (const [index, entry] of value.entries()) validatePlainJson(entry, `${path}[${index}]`, depth + 1);
61
+ return;
62
+ }
63
+ if (!isPlainRecord(value)) throw new Error(`${path} must contain plain JSON data.`);
64
+ if (Object.keys(value).length > 16) throw new Error(`${path} contains too many fields.`);
65
+ for (const [key, entry] of Object.entries(value)) {
66
+ if (!key.trim()) throw new Error(`${path} contains an empty field name.`);
67
+ validatePlainJson(entry, `${path}.${key}`, depth + 1);
68
+ }
69
+ }
70
+
71
+ function normalizeArgs(value: unknown): { args?: Record<string, unknown>; error?: string } {
72
+ if (value === undefined) return { args: {} };
73
+ if (!isPlainRecord(value)) return { error: "workflow args must be a plain JSON object." };
74
+ try {
75
+ validatePlainJson(value, "workflow args");
76
+ if (jsonByteLength(value) > MAX_ARGS_BYTES) return { error: `workflow args exceed ${MAX_ARGS_BYTES} bytes.` };
77
+ return { args: { ...value } };
78
+ } catch (error) {
79
+ return { error: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ }
82
+
83
+ function resolveRunCi(args: Record<string, unknown>): { script: string; authority: WorkflowResourceAuthority } | { error: string } {
84
+ const allowed = new Set(["command", "timeoutMs"]);
85
+ const unsupported = Object.keys(args).filter((key) => !allowed.has(key));
86
+ if (unsupported.length > 0) return { error: `workflow 'run-ci' args contain unsupported fields: ${unsupported.join(", ")}.` };
87
+ const command = args.command === undefined ? "npm test" : args.command;
88
+ if (command !== "npm test" && command !== "npm run typecheck") return { error: "workflow 'run-ci' args.command must be 'npm test' or 'npm run typecheck'." };
89
+ const timeoutMs = args.timeoutMs === undefined ? 120_000 : args.timeoutMs;
90
+ if (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86_400_000) return { error: "workflow 'run-ci' args.timeoutMs must be an integer from 1 to 86400000." };
91
+ const params = { kind: "command", command, timeoutMs, role: "ci" };
92
+ return {
93
+ script: `return await runs.host("ci", ${JSON.stringify(params)});`,
94
+ authority: { host: { keys: ["ci"], commands: [command] } },
95
+ };
96
+ }
97
+
98
+ function resolveReview(args: Record<string, unknown>): { script: string; authority: WorkflowResourceAuthority } | { error: string } {
99
+ const unsupported = Object.keys(args).filter((key) => key !== "task");
100
+ if (unsupported.length > 0) return { error: `workflow 'review' args contain unsupported fields: ${unsupported.join(", ")}.` };
101
+ const task = args.task;
102
+ if (typeof task !== "string" || !task.trim()) return { error: "workflow 'review' requires a non-empty string args.task." };
103
+ return {
104
+ script: `return (await runs.run("review", { agent: "reviewer", task: ${JSON.stringify(task.trim())} })).output;`,
105
+ authority: {},
106
+ };
107
+ }
108
+
109
+ const WORKFLOW_RESOURCES: readonly WorkflowResourceDefinition[] = [
110
+ { name: "review", version: 1, resolve: resolveReview },
111
+ { name: "run-ci", version: 1, resolve: resolveRunCi },
112
+ ];
113
+
114
+ function findWorkflowResource(name: string): WorkflowResourceDefinition | undefined {
115
+ return WORKFLOW_RESOURCES.find((resource) => resource.name === name);
116
+ }
117
+
118
+ function listWorkflowResourceNames(): string[] {
119
+ return WORKFLOW_RESOURCES.map((resource) => resource.name);
120
+ }
121
+
122
+ /** Resolve only extension-owned resources so policy can distinguish them from raw scripts; caller-provided script text is never consulted. */
123
+ export function resolveWorkflowResource(nameValue: unknown, argsValue?: unknown): WorkflowResourceResolution {
124
+ if (typeof nameValue !== "string" || !nameValue.trim()) return { ok: false, error: "workflow must be a non-empty resource name." };
125
+ const name = nameValue.trim();
126
+ if (!RESOURCE_NAME_PATTERN.test(name)) return { ok: false, error: "workflow must use a safe resource name." };
127
+ const resource = findWorkflowResource(name);
128
+ if (!resource) return { ok: false, error: `Unknown workflow resource '${name}'. Available resources: ${listWorkflowResourceNames().join(", ")}.` };
129
+ const normalizedArgs = normalizeArgs(argsValue);
130
+ if (normalizedArgs.error) return { ok: false, error: normalizedArgs.error };
131
+ const resolved = resource.resolve(normalizedArgs.args!);
132
+ if ("error" in resolved) return { ok: false, error: resolved.error };
133
+ const resourceId = randomUUID();
134
+ const permit = createWorkflowResourcePermit({
135
+ resourceName: resource.name,
136
+ resourceVersion: resource.version,
137
+ resourceId,
138
+ scriptDigest: stableJsonDigest(resolved.script),
139
+ authority: resolved.authority,
140
+ });
141
+ const provenance: WorkflowResourceProvenanceV1 = Object.freeze({
142
+ kind: "workflow",
143
+ name: resource.name,
144
+ version: resource.version,
145
+ invocation: "named",
146
+ expansion: "resolved",
147
+ id: resourceId,
148
+ });
149
+ return { ok: true, resource: { script: resolved.script, permit, provenance } };
150
+ }