@edgehero/pi-dispatch 4.3.1 → 5.0.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 (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
package/src/triggers.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { validateAnswerRules, validateStandingAnswers } from "./answerer.mjs";
2
+ import { validateZeroTouchConfig } from "./question-groups.mjs";
1
3
  /**
2
4
  * Shared trigger-file schema + validator (issue #20). One `triggers.json` of `{ on, run }` entries is
3
5
  * the single reviewed source of standing triggers for BOTH services: the worker owns `on.type:"cron"`
@@ -23,9 +25,11 @@
23
25
  * Custom: triggers validated inline per config.mjs/schedules.mjs precedent; zod not in deps
24
26
  */
25
27
 
26
- import { BACKEND_NAMES, backendFor } from "./backends.mjs";
28
+ import { BACKEND_NAMES, backendFor, servesOf } from "./backends.mjs";
29
+ import { SCHEDULER_JOB_ID_OVERHEAD, SLOT_DIGITS_MAX } from "./bullmq-internals.mjs";
27
30
  import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
28
31
  import { imageRefProblem } from "./image-ref.mjs";
32
+ import { LEFT_OUT_NAMES_MAX, LOADED_JOB_TOOLS, LOADED_OVERLAY_NAME_RE, LOADED_TOOL_NAME_RE } from "./loaded-record.mjs";
29
33
  // SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
30
34
  // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
31
35
  // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
@@ -34,10 +38,19 @@ import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS, RU
34
38
  import { findDuplicateKey } from "./json-duplicates.mjs";
35
39
  // The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
36
40
  import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
41
+ import { isModelRolesField, validateModelRoles } from "./model-roles.mjs";
42
+ import { isPackageName } from "./package-names.mjs";
37
43
  import { validateModelRef } from "./model-ref.mjs";
38
44
  import { parseUsdMicros } from "./money.mjs";
45
+ import { validateApprovalConfig } from "./approval-policy.mjs";
46
+ // The project id rule, from its import-free module, so a bundle's `project` is held to the one rule projects.json is.
47
+ import { PROJECT_ID_RE } from "./project-id.mjs";
39
48
  import { PROVIDER_STEERING_VARS } from "./provider-steering.mjs";
49
+ import { normalizeWatchSources, normalizeWatchRule, watchDurationMs } from "./watch-contract.mjs";
50
+ import { validateWatchDecision } from "./watch-decision.mjs";
51
+ import { builtinModel } from "./model-catalog.mjs";
40
52
  import { CONTAINER_ENV_NAMES, RUNNER_ENV_NAMES } from "./reserved-env.mjs";
53
+ import { neededVersion, underDeclared, valueNeededVersion, valueUnderDeclared, versionProblem } from "./store-version.mjs";
41
54
  // The wait grammar's two shared halves (issue #230). `afterInstantMs` is imported rather than restated so
42
55
  // the loader and the pickup gate cannot disagree about what a legal instant is: a second spelling here is
43
56
  // how a file that loads clean starts holding for an instant nobody wrote. wait-for.mjs is pure and
@@ -46,10 +59,10 @@ import { WAIT_CONDITION_KEYS, WAIT_CONDITION_MAX, afterInstantMs } from "./wait-
46
59
 
47
60
  // Re-exported so the console checks a provider or model it is about to write with the loader's own
48
61
  // grammar, through the subpath it already imports, rather than a copy that could drift.
49
- export { validateModelRef };
62
+ export { validateModelRef, isModelRolesField };
50
63
 
51
64
  // EXPORTED for the same reason as PR_ACTIONS: the admin re-states this vocabulary to a MODEL.
52
- export const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
65
+ export const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue", "watch"]);
53
66
 
54
67
  /**
55
68
  * The `on.type` a DISARMED one-shot normalizes to (issue #231). Producible only by this validator:
@@ -155,6 +168,21 @@ export const ISSUE_ACTIONS = {
155
168
  forgejo: new Set(["closed"]),
156
169
  };
157
170
 
171
+ /**
172
+ * The row of a per-forge action table (`PR_ACTIONS`, `ISSUE_ACTIONS`) for the entry's forge, or a configError naming
173
+ * the trigger, its type and the forge. `run.kind` is a known forge by the time a normalizer asks, so only a forge
174
+ * added to FORGES without a row reaches the refusal; a plain index would hand the caller's spread `undefined`, a bare
175
+ * "allowed is not iterable" that names nothing. An own key, so no name finds a value on the prototype. Azure has no
176
+ * issue row and is refused by name before the issue normalizer asks, which says why; this says only that a row is
177
+ * missing.
178
+ *
179
+ * EXPORTED so a test can show the refusal for a table missing a row, which no shipped table is.
180
+ */
181
+ export function forgeActions(table, kind, at, type, path) {
182
+ if (typeof kind === "string" && Object.hasOwn(table, kind)) return table[kind];
183
+ throw configError(`${at}: a ${type} trigger has no action list for ${kind}, so this build cannot check its on.action: ${path}`);
184
+ }
185
+
158
186
  /**
159
187
  * The verdicts a submitted GitHub review can carry, in the webhook's own (lower-case) spelling, and the
160
188
  * vocabulary of the optional `on.reviewState` narrowing (issue #66).
@@ -191,6 +219,12 @@ const REVIEW_ACTION = "review_submitted";
191
219
  */
192
220
  // EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
193
221
  export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
222
+ /**
223
+ * The built-in tools a job has when nothing narrows them: the pinned pi's `DEFAULT_TOOL_NAMES`, a subset of the set above. The rest
224
+ * stay off unless an extension turns them on, so taking one of those out of `run.excludeTools` hands a job nothing by itself.
225
+ * Bolted to the artifact in `worker/test/exclude-tools.pinned.test.mjs`.
226
+ */
227
+ export const DEFAULT_ON_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
194
228
  /**
195
229
  * The tools a job's MCP brings (REQ-MCP-OPERATOR-SERVERS, image/runner/src/mcp.mjs), excludable only by a trigger that
196
230
  * names servers: codemode, tool_search and pi's three resource tools. One server's tools are excluded as a whole by
@@ -219,6 +253,20 @@ export function mcpServerToolPattern(name) {
219
253
  // parse; the charset also excludes `:` and the dedicated check names the reason.
220
254
  const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
221
255
 
256
+ /**
257
+ * The longest cron `on.id` (issue #737). The id is its scheduler's key, which every admin surface reads back from
258
+ * Valkey (the panel, the console, the status tool) and bounds there, since anything holding the Valkey's URL can plant
259
+ * a scheduler and a surface prints the key in running text; and each run the scheduler mints is the job
260
+ * `repeat:<id>:<slot>`, the slot the run's instant in ms (at most 16 digits), which every reader of a job id takes at
261
+ * most 200 characters long (`DEFER_ID_RE`, the console's `RUN_ID_RE`). So the id is at most 200 less the run id's
262
+ * frame (`SCHEDULER_JOB_ID_OVERHEAD`, bullmq-internals.mjs): every run of every legal id is a job id the readers take,
263
+ * and a longer id, a schedule whose runs no surface could name, is refused here. A surface's bound on a scheduler key
264
+ * is the wider `SCHEDULER_ID_MAX` (200), so a scheduler an earlier release made for a longer id is still named there.
265
+ */
266
+ export const CRON_ID_MAX = 200 - SCHEDULER_JOB_ID_OVERHEAD;
267
+ // The 200 characters the readers of a run id take, which the refusal of a longer cron id names: the bound above, read back.
268
+ const RUN_ID_READ_MAX = CRON_ID_MAX + SCHEDULER_JOB_ID_OVERHEAD;
269
+
222
270
  /**
223
271
  * The ceiling on `run.replicas` (REQ-REPLICA-RUNS). Three, because `PI_CONCURRENCY` defaults to 3
224
272
  * (config.mjs) and replicas above the default concurrency would queue rather than race -- a cap that
@@ -226,7 +274,7 @@ const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
226
274
  * concurrency setting: this validator is pure and fs-free, and the operator who raises concurrency to 10
227
275
  * is the operator who can raise this line too, in a reviewed commit.
228
276
  */
229
- const REPLICAS_MAX = 3;
277
+ export const REPLICAS_MAX = 3;
230
278
 
231
279
  /**
232
280
  * The ceiling on how many references one trigger may name (issue #225). Not tidiness: the worker resolves
@@ -246,7 +294,7 @@ const SECRETS_MAX = 16;
246
294
  * a name is `-e NAME=VALUE` in the docker argv, so `=` would split in the wrong place, and the leading
247
295
  * digit is excluded because a shell cannot expand `$1FOO` as that variable.
248
296
  */
249
- const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
297
+ export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
250
298
 
251
299
  /**
252
300
  * The env variable names a trigger may NOT bind, and why each set is here.
@@ -267,12 +315,129 @@ function isNonEmptyString(value) {
267
315
  return typeof value === "string" && value.trim() !== "";
268
316
  }
269
317
 
318
+ /**
319
+ * The highest triggers file version this build reads (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`): a top-level
320
+ * `version`, absent meaning 1, which `writeTriggers` owns and writes only when the content needs more than 1, so every
321
+ * file of today's fields stays byte-identical.
322
+ */
323
+ export const TRIGGERS_FILE_VERSION = 2;
324
+
325
+ /**
326
+ * Each `run` key whose ABSENCE an older build would misread, with the version that introduced it. The raw `run` keys
327
+ * of an entry are carried by every writer, and an older loader DROPS a key it does not know: harmless for most, a
328
+ * widened job for a key that narrows one. Every row of `AUTHORED_NARROWING_FIELDS` added after issue #761 must appear
329
+ * here (and in `JOB_DATA_FIELD_VERSIONS`) at 2 or above; a test holds that.
330
+ *
331
+ * `excludeExtensions` and `excludeExtensionTools` (issue #800): an older loader drops either and runs the job with the
332
+ * extension or the tool the file takes away, on a clean record.
333
+ */
334
+ export const TRIGGER_RUN_FIELD_VERSIONS = Object.freeze({ excludeExtensions: 2, excludeExtensionTools: 2 });
335
+
336
+ /** Each top-level block (beside `triggers`) whose absence an older build would misread, as above. Empty until one does. */
337
+ export const TRIGGERS_BLOCK_VERSIONS = Object.freeze({});
338
+
339
+ /**
340
+ * Each EXISTING field of a trigger entry whose range a later release widens, as `field -> (value) => version` (the
341
+ * shared rule, `valueNeededVersion`): a new `on.type`, say, or a new field inside `on`. A key-name table cannot say
342
+ * this, and an older loader meeting the wider value refuses the whole file as broken (its schedules keep running, and
343
+ * its next start fails) where a stamp makes it defer. Empty until a value needs one.
344
+ */
345
+ export const TRIGGER_VALUE_VERSIONS = Object.freeze({});
346
+
347
+ /**
348
+ * Each EXISTING `run` key whose range a later release widens (a new `run.waitFor` condition kind, or a new `run.kind`,
349
+ * say), as above.
350
+ */
351
+ export const TRIGGER_RUN_VALUE_VERSIONS = Object.freeze({});
352
+
353
+ /**
354
+ * Each EXISTING top-level block whose range a later release widens, keyed by the block's name (`secretBundles`,
355
+ * `modelRoles`, `answerRules`, ...), as above: a new field in a bundle, say, which this loader refuses by name. The rule
356
+ * is handed the whole block. Empty until a value needs one.
357
+ */
358
+ export const TRIGGERS_BLOCK_VALUE_VERSIONS = Object.freeze({});
359
+
360
+ /**
361
+ * The lowest version a parsed triggers document needs: the raw `run` keys of every entry, the values of every entry's
362
+ * fields and of its `run` keys, and every top-level key and its value (`version` itself aside). 1 while nothing in it
363
+ * needs more.
364
+ */
365
+ export function triggersFileVersionFor(doc, { runTable = TRIGGER_RUN_FIELD_VERSIONS, blockTable = TRIGGERS_BLOCK_VERSIONS, entryValueTable = TRIGGER_VALUE_VERSIONS, runValueTable = TRIGGER_RUN_VALUE_VERSIONS, blockValueTable = TRIGGERS_BLOCK_VALUE_VERSIONS } = {}) {
366
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return 1;
367
+ const { version: _version, ...blocks } = doc;
368
+ let needed = Math.max(neededVersion(Object.keys(blocks), blockTable), valueNeededVersion(blocks, blockValueTable));
369
+ for (const entry of Array.isArray(doc.triggers) ? doc.triggers : []) {
370
+ if (entry === null || typeof entry !== "object") continue;
371
+ const byEntry = valueNeededVersion(entry, entryValueTable);
372
+ if (byEntry > needed) needed = byEntry;
373
+ const run = entry.run;
374
+ if (run === null || typeof run !== "object") continue;
375
+ const needs = Math.max(neededVersion(Object.keys(run), runTable), valueNeededVersion(run, runValueTable));
376
+ if (needs > needed) needed = needs;
377
+ }
378
+ return needed;
379
+ }
380
+
381
+ /**
382
+ * The problem with a parsed triggers document's RAW `version`, or null: `{ newer: true, message }` above this build,
383
+ * `{ newer: false, message }` when it is not an integer of 1 or more. The one rule every reader of the file asks
384
+ * through, the loader, the writers and the worker's per-job reads alike.
385
+ */
386
+ export function triggersFileVersionProblem(doc, { supported = TRIGGERS_FILE_VERSION } = {}) {
387
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc) || !Object.hasOwn(doc, "version")) return null;
388
+ return versionProblem(doc.version, { supported, what: "triggers file" });
389
+ }
390
+
391
+ /**
392
+ * Whether a triggers file (its text, or the parsed document) was written by a newer pi-dispatch. A text that does not
393
+ * parse is not newer: it is broken, and each caller keeps its own answer for a broken file. For the worker's per-job
394
+ * reads, which must DEFER on a newer file rather than fail open: a one-shot's disarm may be written in a shape this
395
+ * build cannot see, and a narrowing field this build does not know cannot be checked at all.
396
+ *
397
+ * A text with a duplicate key is broken too, never newer, the loader's order (`parseTriggersDocument` refuses the
398
+ * duplicate before it reads `version`): `JSON.parse` keeps the LAST value, so `{"version": 1, "version": 2}` would read
399
+ * as newer here while the loader calls it broken, and the jobs that read it would wait for an upgrade that changes
400
+ * nothing. Only the TEXT can show a duplicate, so the per-job readers pass the text; a parsed document is judged on its
401
+ * `version` alone.
402
+ */
403
+ export function triggersFileNewer(textOrDoc, { supported = TRIGGERS_FILE_VERSION } = {}) {
404
+ let doc = textOrDoc;
405
+ if (typeof textOrDoc === "string") {
406
+ try {
407
+ doc = JSON.parse(textOrDoc);
408
+ } catch {
409
+ return false;
410
+ }
411
+ if (findDuplicateKey(textOrDoc)) return false;
412
+ }
413
+ return triggersFileVersionProblem(doc, { supported })?.newer === true;
414
+ }
415
+
270
416
  /**
271
417
  * Parse, validate, and normalize the unified triggers file text. Returns an array of normalized
272
418
  * `{ on, run }` entries (unknown fields dropped, so consumers only ever read validated fields). Throws
273
- * `configError` (fail-loud) on any malformed entry. The `path` is for error messages only.
419
+ * `configError` (fail-loud) on any malformed entry. The `path` is for error messages only. `versions` is
420
+ * `parseTriggersDocument`'s, for tests.
421
+ */
422
+ export function parseTriggers(text, path, versions = {}) {
423
+ return parseTriggersDocument(text, path, versions).triggers;
424
+ }
425
+
426
+ /**
427
+ * `parseTriggers`, plus the file's other validated top-level key: `{ triggers, secretBundles }` (issue #704).
428
+ * `secretBundles` is `{}` when the file has none. One function rather than a second parser, so the worker's
429
+ * bundle read and every loader judge the file by ONE rule: a file whose `secretBundles` is malformed is
430
+ * refused WHOLE, by the receiver, the worker's cron loader, doctor and every writer alike, exactly as a
431
+ * malformed trigger is (see `validateSecretBundles` for why that is the right blast radius).
432
+ *
433
+ * The file's `version` (issue #761) is judged right after the duplicate-key check and BEFORE the `triggers` array: a
434
+ * newer file may have reshaped anything below it, and its true message is "upgrade", not a complaint about a shape.
435
+ * A newer file throws a configError with `.newer === true`; a broken `version`, or a `run` key, a top-level block or a
436
+ * block, entry or `run` value that needs a higher version than the file declares, throws the ordinary configError.
437
+ * `supported`, `runTable`, `blockTable`, `entryValueTable`, `runValueTable` and `blockValueTable` default to this
438
+ * build's and are injectable for tests.
274
439
  */
275
- export function parseTriggers(text, path) {
440
+ export function parseTriggersDocument(text, path, { supported = TRIGGERS_FILE_VERSION, runTable = TRIGGER_RUN_FIELD_VERSIONS, blockTable = TRIGGERS_BLOCK_VERSIONS, entryValueTable = TRIGGER_VALUE_VERSIONS, runValueTable = TRIGGER_RUN_VALUE_VERSIONS, blockValueTable = TRIGGERS_BLOCK_VALUE_VERSIONS } = {}) {
276
441
  let parsed;
277
442
  try {
278
443
  parsed = JSON.parse(text);
@@ -297,13 +462,60 @@ export function parseTriggers(text, path) {
297
462
  );
298
463
  }
299
464
 
465
+ const versionTrouble = triggersFileVersionProblem(parsed, { supported });
466
+ if (versionTrouble) {
467
+ const error = configError(`${versionTrouble.message}: ${path}`);
468
+ if (versionTrouble.newer) error.newer = true;
469
+ throw error;
470
+ }
471
+
300
472
  const entries = parsed?.triggers;
301
473
  if (!Array.isArray(entries)) {
302
474
  throw configError(`triggers file must have a "triggers" array: ${path}`);
303
475
  }
304
476
 
305
- const state = { seenCronIds: new Set(), commentCounts: {} };
306
- return entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
477
+ const watchSources = normalizeWatchSources(parsed.watchSources);
478
+ const state = { seenCronIds: new Set(), seenWatchIds: new Set(), watchSources, commentCounts: {} };
479
+ const triggers = entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
480
+ // AFTER the triggers, so a file broken in both places names the trigger first, the message every operator
481
+ // of an older file already knows.
482
+ // A misspelt top-level block (`secret_bundles`, `SecretBundles`, `secretBundle`) would be ignored as an unknown key,
483
+ // granting nothing while the file reads as granting a project its environment: refused, the sweep's rule.
484
+ for (const key of Object.keys(parsed)) {
485
+ if (key !== "secretBundles" && isSecretBundleNearMiss(key)) {
486
+ throw configError(`triggers file: top-level ${JSON.stringify(key)} is not a key -- did you mean "secretBundles"? A bundle block the loader ignores grants nothing while the file reads as granting it, so a near miss is refused rather than dropped: ${path}`);
487
+ }
488
+ }
489
+ for (const key of Object.keys(parsed)) {
490
+ const shape = key.toLowerCase().replace(/[_ .-]/g, "");
491
+ if ((shape.startsWith("answerrule") && key !== "answerRules") || (shape.startsWith("standinganswer") && key !== "standingAnswers")) throw configError("triggers file: use answerRules and standingAnswers exactly");
492
+ }
493
+ let answerRules, standingAnswers;
494
+ try { answerRules = validateAnswerRules(parsed.answerRules); standingAnswers = validateStandingAnswers(parsed.standingAnswers); } catch (error) { throw configError(error.message); }
495
+ const secretBundles = validateSecretBundles(parsed.secretBundles, path);
496
+ const modelRoles = validateModelRoles(parsed.modelRoles);
497
+ for (const key of Object.keys(parsed)) if (key !== "modelRoles" && isModelRolesField(key)) throw configError("triggers file: use modelRoles");
498
+ let zeroTouch;
499
+ try { zeroTouch = validateZeroTouchConfig(parsed.zeroTouch); } catch (error) { throw configError(error.message); }
500
+ // Issue #761: a block, a `run` key or a value that needs a higher version than the file declares, after every other
501
+ // check so a broken file names its first broken entry as it always did. Such a file was hand-edited or written by a
502
+ // writer that dropped the stamp, and an older loader would read it without the key, or refuse the value as broken
503
+ // rather than newer: refused like any malformed entry.
504
+ const { version: _version, ...blocks } = parsed;
505
+ const shortBlock = underDeclared(Object.keys(blocks), blockTable, parsed.version);
506
+ if (shortBlock) throw configError(`triggers file: top-level ${JSON.stringify(shortBlock.field)} needs triggers file version ${shortBlock.needs}, and the file declares ${parsed.version ?? 1}: ${path}`);
507
+ const shortBlockValue = valueUnderDeclared(blocks, blockValueTable, parsed.version);
508
+ if (shortBlockValue) throw configError(`triggers file: top-level ${JSON.stringify(shortBlockValue.field)} holds a value that needs triggers file version ${shortBlockValue.needs}, and the file declares ${parsed.version ?? 1}: ${path}`);
509
+ entries.forEach((entry, index) => {
510
+ const declared = parsed.version ?? 1;
511
+ const short = underDeclared(Object.keys(entry.run), runTable, parsed.version);
512
+ if (short) throw configError(`trigger at index ${index}: run.${short.field} needs triggers file version ${short.needs}, and the file declares ${declared}: ${path}`);
513
+ const shortEntry = valueUnderDeclared(entry, entryValueTable, parsed.version);
514
+ if (shortEntry) throw configError(`trigger at index ${index}: ${shortEntry.field} holds a value that needs triggers file version ${shortEntry.needs}, and the file declares ${declared}: ${path}`);
515
+ const shortRun = valueUnderDeclared(entry.run, runValueTable, parsed.version);
516
+ if (shortRun) throw configError(`trigger at index ${index}: run.${shortRun.field} holds a value that needs triggers file version ${shortRun.needs}, and the file declares ${declared}: ${path}`);
517
+ });
518
+ return { triggers, secretBundles, modelRoles, ...(parsed.answerRules !== undefined ? { answerRules } : {}), ...(parsed.standingAnswers !== undefined ? { standingAnswers } : {}), ...(parsed.zeroTouch !== undefined ? { zeroTouch } : {}), ...(parsed.watchSources !== undefined ? { watchSources } : {}) };
307
519
  }
308
520
 
309
521
  function normalizeTrigger(entry, index, path, state) {
@@ -319,6 +531,7 @@ function normalizeTrigger(entry, index, path, state) {
319
531
  if (run === null || typeof run !== "object") {
320
532
  throw configError(`${at}: "run" must be an object: ${path}`);
321
533
  }
534
+ try { validateApprovalConfig(run); } catch (error) { throw configError(`${at}: ${error.message}: ${path}`); }
322
535
  // Joined from the set for the joined-vocabulary rule stated over the run.kind check below -- a type
323
536
  // added to the vocabulary can never be
324
537
  // refused by a message that does not mention it. This is also what keeps `DISARMED_TYPE` refused
@@ -334,6 +547,7 @@ function normalizeTrigger(entry, index, path, state) {
334
547
  }
335
548
 
336
549
  // The on x run matrix -- the trust boundary, fail-loud (mirrors the old schedules kind:github refusal).
550
+ if (on.type === "watch") return normalizeWatch(entry, index, path, state);
337
551
  if (on.type === "cron") {
338
552
  if (run.kind !== "local") {
339
553
  throw configError(`${at}: a cron trigger has no webhook delivery, issue/PR number, title, or body; run.kind must be "local" (got ${JSON.stringify(run.kind)}): ${path}`);
@@ -364,6 +578,66 @@ function normalizeTrigger(entry, index, path, state) {
364
578
  return normalized;
365
579
  }
366
580
 
581
+ function normalizeWatch(entry, index, path, state) {
582
+ const on = normalizeWatchRule(entry.on, state.watchSources);
583
+ if (state.seenWatchIds.has(on.id)) throw configError("watch: duplicate trigger id");
584
+ state.seenWatchIds.add(on.id);
585
+ const raw = entry.run;
586
+ const mode = raw.mode ?? "investigate";
587
+ if (!["investigate", "issue"].includes(mode)) throw configError("watch: mode must be investigate or issue; direct fixes need a human approval");
588
+ const allowed = new Set(["kind", "mode", "folder", "flow", "task", "excludeTools", "maxCostUsd", "provider", "model", "maxTurns", "models", "image", "backend", "secrets", "secretBundle", "skillsDir", "issue", "queryLogs"]);
589
+ for (const key of Object.keys(raw)) if (!allowed.has(key)) throw configError("watch: unsupported run field");
590
+ if (raw.kind !== "local") throw configError("watch: run.kind must be local");
591
+ let issue;
592
+ if (raw.issue !== undefined) {
593
+ const target = raw.issue;
594
+ if (!target || typeof target !== "object" || Array.isArray(target) || Object.keys(target).some(k => !["kind", "repo", "closeAfter"].includes(k)) || !FORGE_KINDS.includes(target.kind) || typeof target.repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(target.repo)) throw configError("watch: issue requires a reviewed forge kind and owner/repository target");
595
+ if (target.closeAfter !== undefined) watchDurationMs(target.closeAfter);
596
+ issue = { kind: target.kind, repo: target.repo, ...(target.closeAfter !== undefined ? { closeAfter: target.closeAfter } : {}) };
597
+ if (target.kind === "azure") throw configError("watch: Azure issue mode needs a reviewed project and work-item contract; use github, gitlab or forgejo");
598
+ if (target.repo.split("/").some(part => part === "." || part === "..")) throw configError("watch: invalid issue repository");
599
+ }
600
+ if (mode === "issue") {
601
+ if (!issue) throw configError("watch: issue mode requires run.issue");
602
+ if (Object.keys(raw).some(k => !["kind", "mode", "issue"].includes(k))) throw configError("watch: issue mode cannot carry agent execution settings");
603
+ if (entry.decide !== undefined) throw configError("watch: decide is configured on investigate rules with an issue fallback");
604
+ return { on, run: { kind: "local", mode, issue } };
605
+ }
606
+ if (raw.maxCostUsd === undefined || parseUsdMicros(raw.maxCostUsd) === null) throw configError("watch: investigate requires maxCostUsd");
607
+ // Reuse the local execution validators with a synthetic schedule that is never returned or installed.
608
+ // Every admitted field is projected above; webhook-only authority cannot enter through this path.
609
+ const local = normalizeCron({ type: "cron", id: on.id, pattern: "0 0 * * *" }, { ...raw, packages: false, github: false }, index, path, { seenCronIds: new Set() });
610
+ if (local.run.model?.startsWith("role:")) throw configError("watch: investigate requires an explicit chat model or the deployment default");
611
+ if (local.run.backend && !["local", "podman"].includes(local.run.backend)) throw configError("watch: investigate requires a venue with read-only local bind mounts");
612
+ const excludeTools = [...new Set([...(local.run.excludeTools ?? []), "edit", "write", "bash", "powershell"])];
613
+ return { on, run: { ...local.run, mode, excludeTools, ...(issue ? { issue } : {}), ...(raw.queryLogs !== undefined ? { queryLogs: normalizeWatchQueries(raw.queryLogs) } : {}) }, ...(entry.decide !== undefined ? { decide: normalizeWatchDecisionConfig(entry.decide, issue) } : {}) };
614
+ }
615
+
616
+ function normalizeWatchQueries(value) {
617
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 16) throw configError("watch: queryLogs must name at most sixteen reviewed queries");
618
+ const out = {};
619
+ for (const [id, q] of Object.entries(value)) {
620
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(id) || !q || typeof q !== "object" || Object.keys(q).some(k => !["endpoint", "query", "credentialEnv"].includes(k)) || typeof q.query !== "string" || Buffer.byteLength(q.query) > 4096 || typeof q.endpoint !== "string" || (q.credentialEnv !== undefined && (typeof q.credentialEnv !== "string" || !ENV_NAME.test(q.credentialEnv)))) throw configError("watch: invalid reviewed log query");
621
+ let url;
622
+ try { url = new URL(q.endpoint); } catch { throw configError("watch: invalid log query endpoint"); }
623
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) throw configError("watch: log queries require HTTPS without URL credentials");
624
+ out[id] = { endpoint: url.href, query: q.query, ...(q.credentialEnv ? { credentialEnv: q.credentialEnv } : {}) };
625
+ }
626
+ return out;
627
+ }
628
+
629
+ function normalizeWatchDecisionConfig(value, issue) {
630
+ if (!issue) throw configError("watch: decide requires a reviewed run.issue fallback");
631
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(k => !["model", "maxCostUsd", "questions", "act", "fields", "shadow"].includes(k))) throw configError("watch: invalid decide block");
632
+ if (typeof value.model !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./:@~-]+$/.test(value.model) || value.maxCostUsd === undefined) throw configError("watch: decide requires a model and cost cap");
633
+ parseUsdMicros(value.maxCostUsd);
634
+ if (value.shadow !== undefined && typeof value.shadow !== "boolean") throw configError("watch: decide.shadow must be boolean");
635
+ const { shadow = true, ...policy } = value;
636
+ const checked = validateWatchDecision({ ...policy, maxCostUsd: String(policy.maxCostUsd) }, { resolveModel: ref => { const i = ref.indexOf("/"); return builtinModel(ref.slice(0, i), ref.slice(i + 1)); } });
637
+ if (!checked.ok) throw configError("watch: decide-refused; require a known input-only classifier and bounded questions");
638
+ return { ...checked.config, shadow };
639
+ }
640
+
367
641
  function normalizeCron(on, run, index, path, state) {
368
642
  const at = `trigger at index ${index}`;
369
643
 
@@ -377,6 +651,9 @@ function normalizeCron(on, run, index, path, state) {
377
651
  if (!ID_CHARSET.test(id)) {
378
652
  throw configError(`cron trigger "${id}": on.id must match [A-Za-z0-9._-]+: ${path}`);
379
653
  }
654
+ if (id.length > CRON_ID_MAX) {
655
+ throw configError(`${at}: cron on.id is ${id.length} characters, more than ${CRON_ID_MAX}: each run's job id is repeat:<id>:<up to ${SLOT_DIGITS_MAX} digits> and must stay within the ${RUN_ID_READ_MAX} characters the readers take, so a longer id would run with no surface naming it: ${path}`);
656
+ }
380
657
  if (state.seenCronIds.has(id)) {
381
658
  throw configError(`cron trigger "${id}": duplicate on.id (cron ids must be unique): ${path}`);
382
659
  }
@@ -428,6 +705,12 @@ function normalizeCron(on, run, index, path, state) {
428
705
  // Called for its refusal only (issue #625): `true` needs run.resume, which cron refuses, so the returned `run`
429
706
  // grows no `awaitInput` key, exactly as it grows no `replicas` one.
430
707
  validateAwaitInputFlag(run, `cron trigger "${id}"`, path);
708
+ // Returned: run.progress is legal on cron, and run.statusComment survives only as `false` (issue #644).
709
+ const { progress, statusComment } = validateProgressFlags(run, `cron trigger "${id}"`, path, { onType: "cron" });
710
+ // Returned, statusComment's posture (issue #645): `true` is refused on cron, so run.recover survives only as `false`.
711
+ const recover = validateRecoverFlag(run, `cron trigger "${id}"`, path, { onType: "cron" });
712
+ // Returned, run.progress's posture (issue #646): steering is legal on cron, an operator can steer a folder job too.
713
+ const steer = validateSteerFlag(run, `cron trigger "${id}"`, path);
431
714
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
432
715
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
433
716
  validateReplicas(run, `cron trigger "${id}"`, path);
@@ -437,6 +720,7 @@ function normalizeCron(on, run, index, path, state) {
437
720
  validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
438
721
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
439
722
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
723
+ const secretBundle = validateSecretBundleFlag(on, run, `cron trigger "${id}"`, path);
440
724
  const backend = validateBackend(on, run, `cron trigger "${id}"`, path, { localWorkspace: true });
441
725
  // Called for its refusal only: the returned `run` below deliberately grows no `waitFor` key, exactly as
442
726
  // it grows no `replicas` one, because a cron entry can never carry either (issue #230).
@@ -444,6 +728,8 @@ function normalizeCron(on, run, index, path, state) {
444
728
  // A cron job's session narrows like any other: the field selects what the container's agent can do,
445
729
  // which is orthogonal to what triggered it (issue #291).
446
730
  const excludeTools = validateExcludeTools(on, run, `cron trigger "${id}"`, path);
731
+ const excludeExtensions = validateExcludeExtensions(on, run, `cron trigger "${id}"`, path);
732
+ const excludeExtensionTools = validateExcludeExtensionTools(on, run, `cron trigger "${id}"`, path);
447
733
  const mcp = validateMcp(on, run, `cron trigger "${id}"`, path, { kind: "local" });
448
734
  // Validated since #502; before it these three were copied untouched, so `run.provider: 7` loaded.
449
735
  const ref = validateRunModel(on, run, `cron trigger "${id}"`, path);
@@ -459,7 +745,7 @@ function normalizeCron(on, run, index, path, state) {
459
745
  // freeze today's default into every stored repeatable.
460
746
  return {
461
747
  on: { type: "cron", id, pattern },
462
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
748
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(secretBundle !== undefined && { secretBundle }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(excludeExtensions !== undefined && { excludeExtensions }), ...(excludeExtensionTools !== undefined && { excludeExtensionTools }), ...(mcp !== undefined && { mcp }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.fallbackModels !== undefined && { fallbackModels: ref.fallbackModels }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }), ...(progress !== undefined && { progress }), ...(statusComment !== undefined && { statusComment }), ...(recover !== undefined && { recover }), ...(steer !== undefined && { steer }) },
463
749
  };
464
750
  }
465
751
 
@@ -579,7 +865,91 @@ function validateAwaitInputFlag(run, at, path) {
579
865
  if (run.awaitInput === true && run.resume !== true) {
580
866
  throw configError(`${at}: run.awaitInput needs run.resume: true beside it -- a run that stops to wait for an answer is continued by the next run on the same issue or pull request, and only run.resume keeps the transcript it would continue from: ${path}`);
581
867
  }
582
- return run.awaitInput;
868
+ return run.gates !== undefined ? true : run.awaitInput;
869
+ }
870
+
871
+ /**
872
+ * Validate the per-trigger progress flags (issue #644). Strictly boolean, validateAwaitInputFlag's rule and for its
873
+ * reason: a truthy `"false"` would arm what the operator meant to leave off.
874
+ *
875
+ * - `run.progress`: the job is offered the report_progress tool, and the worker reads the phases it reports. Legal on
876
+ * every kind: the tool and the reducer need no forge, and a cron job's progress shows in the console like any other.
877
+ * - `run.statusComment`: the worker posts one comment on the issue or pull request and edits it as the run goes. Only
878
+ * where the job has one to post on: a cron trigger has no issue or pull request (`run.github` mints a token, it
879
+ * names no target), so `true` there is refused rather than accepted to do nothing. `false` is today's default and
880
+ * is carried, `run.resume`'s rule.
881
+ *
882
+ * Neither needs the other: a status comment without run.progress shows turns and steps and no phase.
883
+ * Returns both, each undefined when absent, so an unflagged trigger normalizes byte-identically to today's.
884
+ * Exported for the admin's trigger tools, which judge the two before the operator's confirm with this message.
885
+ */
886
+ export function validateProgressFlags(run, at, path, { onType }) {
887
+ for (const key of ["progress", "statusComment"]) {
888
+ if (run[key] !== undefined && typeof run[key] !== "boolean") {
889
+ throw configError(`${at}: run.${key} must be true or false when present: ${path}`);
890
+ }
891
+ }
892
+ if (onType === "cron" && run.statusComment === true) {
893
+ throw configError(`${at}: run.statusComment is only available on a forge trigger: a cron job has no issue or pull request to post its status on: ${path}`);
894
+ }
895
+ return { progress: run.progress, statusComment: run.statusComment };
896
+ }
897
+
898
+ /**
899
+ * Validate the per-trigger `run.recover` flag (issue #645, durable runs): whether a run the worker loses mid-flight (a
900
+ * crash, a restart, a host that went away) is continued from its last checkpoint instead of failing. Strictly boolean,
901
+ * validateAwaitInputFlag's rule and for its reason: a truthy `"false"` would arm what the operator meant to leave off.
902
+ *
903
+ * Two refusals, each a combination that cannot do what it says rather than one that is merely unusual:
904
+ * - on cron. The checkpoint is a forge job's session, keyed by its issue or pull request; a cron job's /workspace IS
905
+ * the operator's folder, edited in place, so a recovery would replay a transcript over a working tree the lost run
906
+ * already changed, with nothing to put it back. `run.resume` is refused there for the same missing key.
907
+ * - beside `run.command`. A recovering run continues the transcript; a command job's prompt is one `/name args` line,
908
+ * and replaying it would dispatch the command a second time, which is exactly the side effect a checkpoint exists
909
+ * to avoid repeating.
910
+ *
911
+ * Deliberately NOT tied to `run.resume`: resume carries a transcript from one run to the NEXT, recovery carries one
912
+ * run's own checkpoint into its own continuation, and either is useful without the other.
913
+ *
914
+ * Returns the flag, undefined when absent, so an unflagged trigger normalizes byte-identically to today's. Exported
915
+ * for the admin's trigger tools, which judge it before the operator's confirm with this message.
916
+ */
917
+ export function validateRecoverFlag(run, at, path, { onType }) {
918
+ if (run.recover !== undefined && typeof run.recover !== "boolean") {
919
+ throw configError(`${at}: run.recover must be true or false when present: ${path}`);
920
+ }
921
+ if (run.recover === true && onType === "cron") {
922
+ throw configError(`${at}: run.recover is only available on a forge trigger: a cron job's /workspace is the operator's folder, edited in place, so a recovered run would replay over a tree the lost run already changed: ${path}`);
923
+ }
924
+ if (run.recover === true && run.command !== undefined) {
925
+ throw configError(`${at}: run.recover cannot be set beside run.command: a recovering run continues its transcript, and a command job's prompt is the one /name args line, so recovering it would dispatch the command a second time: ${path}`);
926
+ }
927
+ return run.recover;
928
+ }
929
+
930
+ /**
931
+ * Validate the per-trigger `run.steer` flag (issue #646, steering a running job): whether an operator (and, once comment
932
+ * routing lands, a collaborator using the trigger phrase) may send messages into the job while it runs. Strictly
933
+ * boolean, validateRecoverFlag's rule and for its reason: a truthy `"false"` would arm what the operator meant to leave
934
+ * off.
935
+ *
936
+ * Legal on every trigger type, cron included, unlike run.recover: a steering message is delivered into the live
937
+ * session and needs no issue or pull request to key anything by, so a folder job can be steered by its operator as
938
+ * well as a forge job can. The one refusal is beside `run.command`: a command job's prompt is one `/name args` line
939
+ * that dispatches a workflow, and a message pushed into that session would be read by whatever the command started,
940
+ * not by an agent the operator asked to listen. Refusing the pair says so before anything spends.
941
+ *
942
+ * Returns the flag, undefined when absent, so an unflagged trigger normalizes byte-identically to today's. Exported
943
+ * for the admin's trigger tools, which judge it before the operator's confirm with this message.
944
+ */
945
+ export function validateSteerFlag(run, at, path) {
946
+ if (run.steer !== undefined && typeof run.steer !== "boolean") {
947
+ throw configError(`${at}: run.steer must be true or false when present: ${path}`);
948
+ }
949
+ if (run.steer === true && run.command !== undefined) {
950
+ throw configError(`${at}: run.steer cannot be set beside run.command: a command job's prompt is the one /name args line that dispatches a workflow, so a steering message would reach whatever the command started rather than an agent listening for it: ${path}`);
951
+ }
952
+ return run.steer;
583
953
  }
584
954
 
585
955
  /**
@@ -1151,6 +1521,202 @@ function validateSecretsProfile(run, at, path) {
1151
1521
  return profile;
1152
1522
  }
1153
1523
 
1524
+ /**
1525
+ * The most variables one bundle may carry (issue #704), and so the most `endpoints` it may name. Sixty-four, four
1526
+ * times `SECRETS_MAX`, and the justification is the one that cap gives, re-measured for one call: a bundle is ONE
1527
+ * resolver call with ONE timeout, so the count multiplies no slot time; what it multiplies is the host argv
1528
+ * (`-e NAME=VALUE` per variable on local and podman, OQ-028) and the redaction set, and 64 x 64 KiB is bounded
1529
+ * anyway by the 256 KiB total the resolver's output may have (`secret-bundles.mjs`). A project with more than 64
1530
+ * variables in one environment is one whose blast radius this feature should not make one grant.
1531
+ */
1532
+ export const SECRET_BUNDLE_VARS_MAX = 64;
1533
+
1534
+ /** An endpoint's auth scheme: a bearer token, HTTP Basic, or a named header (`header:X-Api-Key`). */
1535
+ const BUNDLE_ENDPOINT_AUTH = /^(?:bearer|basic|header:[A-Za-z0-9-]{1,64})$/;
1536
+
1537
+ /** A DNS host name, no scheme, no port, no path: the shape a policy endpoint names. */
1538
+ const BUNDLE_ENDPOINT_HOST = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
1539
+
1540
+ /** The fields a bundle may carry. Closed: an unknown field refuses the file (see below). */
1541
+ const BUNDLE_FIELDS = new Set(["profile", "ref", "project", "forkReviews", "endpoints"]);
1542
+
1543
+ /**
1544
+ * The file's top-level `secretBundles` (issue #704, INT-TRIGGERS-FILE-CONTRACT): a project's whole environment,
1545
+ * granted ONCE. `{ "<id>": { profile, ref, project, forkReviews?, endpoints? } }`. Returns a frozen normalized
1546
+ * copy, `{}` when absent. Throws `configError` on anything malformed.
1547
+ *
1548
+ * STRICT, unlike the trigger entries' drop-unknown posture, and on the condition rule's reasoning (an unknown key
1549
+ * inside a condition is refused because it would be a term of a gate that does nothing while the job runs): a
1550
+ * misspelt `forkreviews` or `project_id` here is a narrowing or a binding the operator reads as written and the
1551
+ * worker never applies. This block grants credentials, so a field the code does not read is refused by name.
1552
+ *
1553
+ * REFUSING THE WHOLE FILE is the blast radius, deliberately, rather than dropping the one bad bundle. A dropped
1554
+ * bundle is the silent no-op this project refuses: the project's jobs would run without their environment and
1555
+ * the file would read as granting it. Refusing it is the posture every malformed trigger already has, and each
1556
+ * reader's existing reaction to an invalid file is the right one here too: the receiver keeps its running set
1557
+ * and logs `triggers_reload_invalid` on a reload (and refuses to boot on one), every writer refuses to write it,
1558
+ * doctor fails, and the worker keeps answering from its last good read (and retries a project's jobs while it has
1559
+ * never had one, triggers-file.mjs `makeFindSecretBundle`). An
1560
+ * OLDER receiver ignores the key entirely, which is safe for the same reason it is safe for the worker to be the
1561
+ * only reader that acts on it: the binding is read by the worker from its own file at pickup, never carried on
1562
+ * the job, so a receiver of any version can neither drop nor forge it.
1563
+ *
1564
+ * What this cannot check, because it is pure and fs-free: whether `profile` is declared (PI_SECRET_BUNDLES and
1565
+ * the overlay are host state, refused per job as `secret-bundle-profile-unknown`), whether `project` exists in
1566
+ * projects.json (doctor fails on it), and whether an `endpoints` name is a variable the bundle actually holds
1567
+ * (only knowable once it is read; the openshell venue refuses that at use).
1568
+ */
1569
+ function validateSecretBundles(value, path) {
1570
+ if (value === undefined) return Object.freeze({});
1571
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1572
+ throw configError(`secretBundles must be an object mapping bundle ids to { profile, ref, project } (got ${JSON.stringify(value)}): ${path}`);
1573
+ }
1574
+ const out = {};
1575
+ const projects = new Map();
1576
+ for (const id of Object.keys(value)) {
1577
+ const at = `secretBundles ${JSON.stringify(id)}`;
1578
+ // The cron-id charset, for its reasons: the id is named in log lines and the run record, and `__proto__` and
1579
+ // friends pass it, so every write below defines an own property rather than assigning one.
1580
+ // 1 to 128 characters: the run record's `bundle.id` holds at most that (run-history.mjs `recordedBundle`), so a
1581
+ // longer id would load here and then void the record's key on every run of the project.
1582
+ if (!ID_CHARSET.test(id) || id.length > 128 || id === "__proto__") {
1583
+ throw configError(`${at}: a bundle id is 1 to 128 letters, digits, dots, dashes and underscores: ${path}`);
1584
+ }
1585
+ const bundle = value[id];
1586
+ if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
1587
+ throw configError(`${at}: must be an object { profile, ref, project }: ${path}`);
1588
+ }
1589
+ const unknown = Object.keys(bundle).find((k) => !BUNDLE_FIELDS.has(k));
1590
+ if (unknown !== undefined) {
1591
+ throw configError(`${at}: unknown field ${JSON.stringify(unknown)} (a bundle reads only ${[...BUNDLE_FIELDS].join(", ")}): a field the worker does not read is a grant or a narrowing that is not applied: ${path}`);
1592
+ }
1593
+ const { profile, ref, project } = bundle;
1594
+ if (!isNonEmptyString(profile) || !ID_CHARSET.test(profile)) {
1595
+ throw configError(`${at}: profile must name one of the bundle resolvers this deployment declares (PI_SECRET_BUNDLES or the panel), letters, digits, dot, dash and underscore: ${path}`);
1596
+ }
1597
+ // `ref` takes run.secrets' reference rules verbatim, for their reasons: it is the resolver's first argument.
1598
+ if (!isNonEmptyString(ref)) {
1599
+ throw configError(`${at}: ref must be a non-empty string reference for the bundle's resolver to read (got ${JSON.stringify(ref)}): ${path}`);
1600
+ }
1601
+ if (ref !== ref.trim()) {
1602
+ throw configError(`${at}: ref must not have leading or trailing whitespace -- the file is the reviewed artifact and silently trimming it would make the file disagree with what the resolver is asked for: ${path}`);
1603
+ }
1604
+ if (ref.startsWith("-")) {
1605
+ throw configError(`${at}: ref must not start with "-" -- it is passed as the resolver's first argument, where a leading dash parses as a flag: ${path}`);
1606
+ }
1607
+ if (typeof project !== "string" || !PROJECT_ID_RE.test(project)) {
1608
+ throw configError(`${at}: project must be a projects.json id (lowercase letters, digits and "-", 1 to 32 characters): ${path}`);
1609
+ }
1610
+ // ONE bundle per project. Two would need a merge rule, and every merge rule is a variable silently won by one
1611
+ // grant over another; the operator who wants two sources writes one resolver that prints both.
1612
+ if (projects.has(project)) {
1613
+ throw configError(`${at}: project ${JSON.stringify(project)} is already bound by bundle ${JSON.stringify(projects.get(project))} -- a project has at most one bundle: ${path}`);
1614
+ }
1615
+ projects.set(project, id);
1616
+ if (bundle.forkReviews !== undefined && typeof bundle.forkReviews !== "boolean") {
1617
+ throw configError(`${at}: forkReviews must be true or false when present -- a truthy string must not quietly hand a stranger's pull request every project secret: ${path}`);
1618
+ }
1619
+ const endpoints = validateBundleEndpoints(bundle.endpoints, at, path);
1620
+ Object.defineProperty(out, id, {
1621
+ value: Object.freeze({ profile, ref, project, ...(bundle.forkReviews !== undefined && { forkReviews: bundle.forkReviews }), ...(endpoints !== undefined && { endpoints }) }),
1622
+ enumerable: true,
1623
+ });
1624
+ }
1625
+ return Object.freeze(out);
1626
+ }
1627
+
1628
+ /**
1629
+ * A bundle's `endpoints` (issue #704): `{ NAME: { host, auth } }`, the variables the openshell venue may hold as a
1630
+ * per-job provider key bound to that one host. Shape-checked here on every venue so the file is one file; USED
1631
+ * only on openshell, and on every other venue a named variable goes in plain like the rest, which is what it
1632
+ * would be without the field (OQ-028 says so).
1633
+ */
1634
+ function validateBundleEndpoints(endpoints, at, path) {
1635
+ if (endpoints === undefined) return undefined;
1636
+ if (endpoints === null || typeof endpoints !== "object" || Array.isArray(endpoints)) {
1637
+ throw configError(`${at}: endpoints must be an object mapping variable names to { host, auth }: ${path}`);
1638
+ }
1639
+ const names = Object.keys(endpoints);
1640
+ if (names.length > SECRET_BUNDLE_VARS_MAX) {
1641
+ throw configError(`${at}: endpoints names ${names.length} variables, over the ${SECRET_BUNDLE_VARS_MAX} a bundle may carry: ${path}`);
1642
+ }
1643
+ const out = {};
1644
+ for (const name of names) {
1645
+ if (!ENV_NAME.test(name) || name === "__proto__") {
1646
+ throw configError(`${at}: endpoints key ${JSON.stringify(name)} is not an environment variable name (letters, digits and underscore, not starting with a digit): ${path}`);
1647
+ }
1648
+ if (RESERVED_ENV_NAMES.has(name)) {
1649
+ throw configError(`${at}: endpoints key ${JSON.stringify(name)} is reserved: this deployment's own runtime reads or writes it, so a bundle can never carry it: ${path}`);
1650
+ }
1651
+ const entry = endpoints[name];
1652
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1653
+ throw configError(`${at}: endpoints.${name} must be an object { host, auth }: ${path}`);
1654
+ }
1655
+ const extra = Object.keys(entry).find((k) => k !== "host" && k !== "auth");
1656
+ if (extra !== undefined) {
1657
+ throw configError(`${at}: endpoints.${name} has an unknown field ${JSON.stringify(extra)} (only host and auth are read): ${path}`);
1658
+ }
1659
+ if (typeof entry.host !== "string" || !BUNDLE_ENDPOINT_HOST.test(entry.host)) {
1660
+ throw configError(`${at}: endpoints.${name}.host must be a host name with no scheme, port or path (e.g. "api.example.com"): ${path}`);
1661
+ }
1662
+ if (typeof entry.auth !== "string" || !BUNDLE_ENDPOINT_AUTH.test(entry.auth)) {
1663
+ throw configError(`${at}: endpoints.${name}.auth must be "bearer", "basic" or "header:<Name>": ${path}`);
1664
+ }
1665
+ Object.defineProperty(out, name, { value: Object.freeze({ host: entry.host.toLowerCase(), auth: entry.auth }), enumerable: true });
1666
+ }
1667
+ return Object.freeze(out);
1668
+ }
1669
+
1670
+ /**
1671
+ * Whether `key` is a spelling of `secretBundle` / `secretBundles` (issue #704 review round 2): every case and
1672
+ * separator variant of either, singular or plural, and a non-ASCII key whose surviving letters are an in-order subset
1673
+ * of either (a homoglyph), `validateBackend`'s rule. The caller skips the exact spelling where it is legal.
1674
+ */
1675
+ export function isSecretBundleNearMiss(key) {
1676
+ const normalized = String(key).replace(/[^a-z0-9]/gi, "").toLowerCase();
1677
+ const targets = ["secretbundle", "secretbundles"];
1678
+ if (/^[\x20-\x7E]*$/.test(String(key))) return targets.includes(normalized);
1679
+ const isSubsequence = (needle, hay) => {
1680
+ let i = 0;
1681
+ for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
1682
+ return i === needle.length;
1683
+ };
1684
+ return normalized.length >= 6 && targets.some((t) => isSubsequence(normalized, t));
1685
+ }
1686
+
1687
+ /**
1688
+ * `run.secretBundle` (issue #704): `false` keeps this trigger's jobs out of their project's bundle. Strictly
1689
+ * boolean, `run.github`'s rule: a truthy `"false"` must not quietly leave a job holding a project's every secret.
1690
+ * `true` is the default and changes nothing. Returned when present, so an unflagged entry normalizes
1691
+ * byte-identically.
1692
+ *
1693
+ * The WORKER reads this from its own triggers file at pickup (triggers-file.mjs `makeFindSecretBundle`), never
1694
+ * from the job data, so no receiver carries it and a receiver older than this field cannot drop it: the
1695
+ * opt-out is a narrowing whose absence would WIDEN the job, the `AUTHORED_NARROWING_FIELDS` class, and reading
1696
+ * it where the binding is read closes that skew without a version floor.
1697
+ */
1698
+ function validateSecretBundleFlag(on, run, at, path) {
1699
+ // The near-miss sweep `run.backend` and `run.excludeTools` keep, for the sharpest version of their reason: the
1700
+ // opt-out is a NARROWING, so a misspelt `secret_bundle: false` dropped as an unknown key would hand the trigger's
1701
+ // jobs their project's whole environment while the file reads as keeping them out. On `run` the exact spelling is
1702
+ // the field; on `on` every spelling is wrong, the exact one included.
1703
+ for (const [label, source, exactIsLegal] of [
1704
+ ["run", run, true],
1705
+ ["on", on, false],
1706
+ ]) {
1707
+ for (const key of Object.keys(source ?? {})) {
1708
+ if (exactIsLegal && key === "secretBundle") continue;
1709
+ if (!isSecretBundleNearMiss(key)) continue;
1710
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.secretBundle? An opt-out the loader drops gives the trigger's jobs their project's secret bundle while the file reads as keeping them out, so a near miss is refused rather than dropped: ${path}`);
1711
+ }
1712
+ }
1713
+ if (run.secretBundle === undefined) return undefined;
1714
+ if (typeof run.secretBundle !== "boolean") {
1715
+ throw configError(`${at}: run.secretBundle must be true or false when present (false keeps this trigger's jobs out of their project's secret bundle): ${path}`);
1716
+ }
1717
+ return run.secretBundle;
1718
+ }
1719
+
1154
1720
  /**
1155
1721
  * Would running a folder-bound job in this venue be impossible? Exported ONLY so it can be driven.
1156
1722
  *
@@ -1167,7 +1733,9 @@ function validateSecretsProfile(run, at, path) {
1167
1733
  * physics. A test pins that every entry declares it, so both halves have to fail together.
1168
1734
  */
1169
1735
  export function refusesLocalWorkspace(entry, localWorkspace) {
1170
- return Boolean(localWorkspace) && entry?.remote !== false;
1736
+ // Issue #683: a venue that is on this host and still cannot edit a folder in place, because it COPIES the job's files
1737
+ // (`openshell`): it declares `localFolders` absent, and is refused for the same physics in different words.
1738
+ return Boolean(localWorkspace) && (entry?.remote !== false || entry?.declares?.localFolders === "absent");
1171
1739
  }
1172
1740
 
1173
1741
  /**
@@ -1255,12 +1823,34 @@ function validateBackend(on, run, at, path, { localWorkspace }) {
1255
1823
  // world imposes, and discovering it per job would burn a pickup for an answer the file already had.
1256
1824
  // The decision itself is `refusesLocalWorkspace` above, so it can be driven; see its comment.
1257
1825
  if (refusesLocalWorkspace(entry, localWorkspace)) {
1258
- throw configError(`${at}: run.backend ${JSON.stringify(backend)} is remote, and this trigger runs against a folder on THIS machine that has to be bind-mounted and edited in place -- there is no volume to hide behind (DES-WORKER-ON-HOST): ${path}`);
1826
+ const why = entry?.remote !== false ? "is remote" : "copies a job's files into its sandbox instead of mounting them (DES-OPENSHELL-VENUE)";
1827
+ throw configError(`${at}: run.backend ${JSON.stringify(backend)} ${why}, and this trigger runs against a folder on THIS machine that has to be bind-mounted and edited in place -- there is no volume to hide behind (DES-WORKER-ON-HOST): ${path}`);
1259
1828
  }
1260
1829
 
1830
+ // Issue #712: `run.recover` on a venue that does not serve recovery (`serves.recovery: "no"`, the `openshell` copy
1831
+ // mode) is refused before spend on EVERY delivery (`openshell-recover-unsupported`), so the file already knows the
1832
+ // answer and is refused at load and write, refusesLocalWorkspace's reasoning. Only for an EXPLICIT venue: a trigger
1833
+ // that names none runs on PI_BACKENDS[0], which is the deployment's and not the file's, so that pairing stays the
1834
+ // processor's pre-spend refusal (the admin's trigger tools also judge it against PI_BACKENDS[0] before the confirm).
1835
+ const recoverRefused = run.recover === true ? recoverVenueProblem(backend) : null;
1836
+ if (recoverRefused !== null) throw configError(`${at}: ${recoverRefused}: ${path}`);
1837
+
1261
1838
  return backend;
1262
1839
  }
1263
1840
 
1841
+ /**
1842
+ * Why `run.recover` cannot be armed on a trigger whose jobs run in `venue`, or null (issue #712): the venue's
1843
+ * `serves.recovery` is `no`. One sentence, so the loader (`validateBackend`, explicit venues) and the admin's trigger tools
1844
+ * (which also judge a trigger naming no venue against PI_BACKENDS[0]) refuse in the same words. A name the table does not
1845
+ * hold is null here: the loader's own unknown-venue refusal says that.
1846
+ */
1847
+ export function recoverVenueProblem(venue) {
1848
+ if (servesOf(venue)?.recovery !== "no") return null;
1849
+ // The venues that do serve it come from the same table, so a new venue changes this sentence without an edit here.
1850
+ const serving = BACKEND_NAMES.filter((name) => servesOf(name)?.recovery === "yes");
1851
+ return `run.recover cannot be armed on a trigger whose jobs run in the ${venue} venue: that venue does not serve recovery, so every delivery would be refused before it spends. Remove run.recover, or name a venue that serves it (${serving.join(", ")})`;
1852
+ }
1853
+
1264
1854
  /**
1265
1855
  * The `run` field a key spells or nearly spells (`run.provider`, `run.model`, `run.models`, `run.maxTurns`,
1266
1856
  * `run.maxCostUsd`), or null. The exact field names match too: the CALLER decides whether an exact name is legal
@@ -1297,6 +1887,7 @@ const RUN_MODEL_NEAR_MISS_TARGETS = Object.freeze({
1297
1887
  provider: "run.provider", providers: "run.provider", providerid: "run.provider", providerids: "run.provider", providername: "run.provider",
1298
1888
  model: "run.model", modelid: "run.model", modelname: "run.model",
1299
1889
  models: "run.models", modelids: "run.models", modelnames: "run.models", allowedmodels: "run.models", allowedmodel: "run.models",
1890
+ fallbackmodels: "run.fallbackModels", fallbackmodel: "run.fallbackModels", fallbackmodelid: "run.fallbackModels", fallbackmodelids: "run.fallbackModels", fallbackmodelname: "run.fallbackModels", fallbackmodelnames: "run.fallbackModels",
1300
1891
  maxturns: "run.maxTurns", maxturn: "run.maxTurns",
1301
1892
  maxcostusd: "run.maxCostUsd", maxcost: "run.maxCostUsd",
1302
1893
  });
@@ -1322,7 +1913,7 @@ const RUN_MODEL_NEAR_MISS_TARGETS = Object.freeze({
1322
1913
  * were refused here before it was.
1323
1914
  */
1324
1915
  function validateRunModel(on, run, at, path) {
1325
- const exact = new Set(["provider", "model", "maxTurns", "models", "maxCostUsd"]);
1916
+ const exact = new Set(["provider", "model", "maxTurns", "models", "fallbackModels", "maxCostUsd"]);
1326
1917
  for (const [label, source, exactIsLegal] of [
1327
1918
  ["run", run, true],
1328
1919
  ["on", on, false],
@@ -1349,6 +1940,150 @@ function validateMaxCostUsd(run, at, path) {
1349
1940
  return { maxCostUsd: run.maxCostUsd };
1350
1941
  }
1351
1942
 
1943
+ /**
1944
+ * Whether `key` spells `run.excludeTools` or nearly does, the exact name included: the CALLER decides whether an exact name
1945
+ * is legal where it found it, `runModelFieldNearMiss`'s rule (the loader allows it under `run`, never under `on`; the
1946
+ * console's trigger tools refuse it in any spelling, issue #800). One sweep, so the two cannot disagree on what a near miss is.
1947
+ *
1948
+ * Three targets. The first catches every case and separator variant (exclude_tools, exclude-tools, EXCLUDETOOLS). The
1949
+ * SINGULAR is the likeliest miss of all: an operator removing one tool writes `excludeTool` the way English does, and unlike
1950
+ * `backend` there is no env-var mnemonic anchoring the right form. The past participle is how the field reads back in prose
1951
+ * ("the excluded tools"), one grammatical step with no other plausible meaning. Deliberately NOT a synonym hunt (denyTools,
1952
+ * toolsOff): that is the general unknown-key sweep this file's forward-compatibility posture rejects.
1953
+ */
1954
+ export function excludeToolsNearMiss(key) {
1955
+ return nearMissOf(["excludetools", "excludetool", "excludedtools"], key);
1956
+ }
1957
+
1958
+ /** Whether `a` becomes `b` in at most `limit` single edits. */
1959
+ function withinEdits(a, b, limit) {
1960
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
1961
+ for (let i = 1; i <= a.length; i++) {
1962
+ const row = [i];
1963
+ for (let j = 1; j <= b.length; j++) row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1964
+ previous = row;
1965
+ }
1966
+ return previous[b.length] <= limit;
1967
+ }
1968
+
1969
+ /**
1970
+ * The one body of every exclusion key's sweep: a case or separator variant of a target, or a key outside plain ASCII whose letters
1971
+ * are a target's in order, which can be drawn exactly like it. `slips` adds a key that many edits from a target. Only on a key new enough
1972
+ * that no file holds one: `excludeToolz` stays a key the loader ignores, as it was, since refusing it now would stop a service on a
1973
+ * file it read yesterday.
1974
+ */
1975
+ function nearMissOf(targets, key, { slips = 0 } = {}) {
1976
+ const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
1977
+ const isSubsequence = (needle, hay) => {
1978
+ let i = 0;
1979
+ for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
1980
+ return i === needle.length;
1981
+ };
1982
+ if (!/^[\x20-\x7E]*$/.test(key)) return normalized.length >= 4 && targets.some((t) => isSubsequence(normalized, t));
1983
+ return targets.some((t) => t === normalized || (slips > 0 && withinEdits(normalized, t, slips)));
1984
+ }
1985
+
1986
+ /**
1987
+ * How far a spelling of these two keys may be from the name and still mean it. Two: a key this long holds a slip in each of its words
1988
+ * (`exludeExtentions`), and a participle comes with a plural (`excludedExtension`, `excludeExtensionsTools`), where a list of each word's forms
1989
+ * would be a list of misspellings. Nothing else the loader reads comes within two; a test holds every key against that.
1990
+ */
1991
+ const EXTENSION_KEY_SLIPS = 2;
1992
+
1993
+ /** Whether `key` spells `run.excludeExtensions` or nearly does, the exact name and the word cut short (`excludeExt`) included: the caller decides where an exact name is legal, `excludeToolsNearMiss`'s rule. */
1994
+ export function excludeExtensionsNearMiss(key) {
1995
+ return nearMissOf(["excludeextensions", "excludeext"], key, { slips: EXTENSION_KEY_SLIPS });
1996
+ }
1997
+
1998
+ /** Whether `key` spells `run.excludeExtensionTools` or nearly does, the exact name and the word cut short (`excludeExtTools`) included: the caller decides where an exact name is legal, `excludeToolsNearMiss`'s rule. */
1999
+ export function excludeExtensionToolsNearMiss(key) {
2000
+ return nearMissOf(["excludeextensiontools", "excludeexttools"], key, { slips: EXTENSION_KEY_SLIPS });
2001
+ }
2002
+
2003
+ /**
2004
+ * Refuses a near miss of a list key under `run` or `on` (an exact spelling is the field under `run` and wrong under `on`), and
2005
+ * the list's own shape: a non-empty array of distinct strings, each passing `check`, which names what is wrong with one or says
2006
+ * nothing. A dropped or empty exclusion runs the job WITH what the file reads as gone, so none of this is left to drop silently.
2007
+ */
2008
+ function validateNameList({ field, nearMiss, check, what }, on, run, at, path) {
2009
+ for (const [label, source, exactIsLegal] of [
2010
+ ["run", run, true],
2011
+ ["on", on, false],
2012
+ ]) {
2013
+ for (const key of Object.keys(source ?? {})) {
2014
+ if (exactIsLegal && key === field) continue;
2015
+ if (!nearMiss(key)) continue;
2016
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.${field}? An exclusion the loader drops runs the job WITH what the file reads as gone, so a near miss is refused rather than dropped: ${path}`);
2017
+ }
2018
+ }
2019
+ const list = run?.[field];
2020
+ if (list === undefined) return undefined;
2021
+ if (!Array.isArray(list) || list.length === 0) throw configError(`${at}: run.${field} must be a non-empty array of ${what} when present (got ${JSON.stringify(list)}): ${path}`);
2022
+ if (list.length > LEFT_OUT_NAMES_MAX) throw configError(`${at}: run.${field} names ${list.length}, and it takes at most ${LEFT_OUT_NAMES_MAX}, what a run's record holds: ${path}`);
2023
+ const seen = new Set();
2024
+ for (let i = 0; i < list.length; i++) {
2025
+ const name = list[i];
2026
+ if (typeof name !== "string") throw configError(`${at}: run.${field}[${i}] must be a string naming ${what} (got ${JSON.stringify(name)}): ${path}`);
2027
+ const wrong = check(name);
2028
+ if (wrong) throw configError(`${at}: run.${field}[${i}] ${JSON.stringify(name)} ${wrong}: ${path}`);
2029
+ if (seen.has(name)) throw configError(`${at}: run.${field} names ${JSON.stringify(name)} twice -- the entries are a removal set, so the second can never change the answer: ${path}`);
2030
+ seen.add(name);
2031
+ }
2032
+ return list;
2033
+ }
2034
+
2035
+ /**
2036
+ * `run.excludeExtensions` (issue #800): extensions this trigger's jobs do not load, each named by what a person reads on a host:
2037
+ * a staged package's manifest name, or an overlay entry's file stem or folder name. A name means every extension of that name,
2038
+ * so one that is both takes out both. The worker omits a package's path and the runner filters the overlay's entries before any
2039
+ * factory runs (`DES-PER-TRIGGER-LOAD-EXCLUSIONS`). Excluding a package also removes its skills, prompts and themes.
2040
+ *
2041
+ * NOT MEMBERSHIP-CHECKED, the one thing `run.excludeTools` is for: what a host holds is that host's, and the file is shared, so a
2042
+ * name no host here holds is not knowable at load. A name not on a host is no refusal there: its record says so and doctor warns.
2043
+ * A shape, so the list stays comma-safe in the container's environment and a record can hold it.
2044
+ */
2045
+ export function validateExcludeExtensions(on, run, at, path) {
2046
+ return validateNameList({
2047
+ field: "excludeExtensions",
2048
+ nearMiss: excludeExtensionsNearMiss,
2049
+ what: "extension names",
2050
+ check: (name) => (isPackageName(name) || LOADED_OVERLAY_NAME_RE.test(name) ? null : "is neither a package's manifest name nor an overlay extension's file stem or folder name"),
2051
+ }, on, run, at, path);
2052
+ }
2053
+
2054
+ /**
2055
+ * `run.excludeExtensionTools` (issue #800): tools an extension brings, which this trigger's sessions must not have. Its own key, and
2056
+ * `run.excludeTools` stays closed: that list is checked against the pinned pi, which is what makes a typo in it a refusal at load,
2057
+ * and this one cannot be, since an extension's tools are known only once it runs. The runner stops a job on a name no staged package
2058
+ * or overlay extension registered, where it can tell (`exclude-tool-not-loaded`). Refused here is a name this key would mistake: pi's own
2059
+ * tools and a server's are `run.excludeTools`', and a tool pi-dispatch gives some jobs (`LOADED_JOB_TOOLS`) is theirs: a run field arms some, a role map or a label trigger's draft
2060
+ * others, and taking one away could leave `run.progress` or `run.awaitInput` armed with nothing to call. Each compared with the name's case folded:
2061
+ * `run.excludeTools` refuses `Bash` by membership, and here it would load, match nothing and read as though the shell were gone. A staged
2062
+ * package's or an overlay extension's real tool that differs from one of those only in case is reached by excluding it; a repository extension's is not. REJECTED: a `*` pattern,
2063
+ * which pi takes and which would match tools no person named.
2064
+ */
2065
+ const EXTENSION_TOOL_WAY_OUT = "to remove a staged package's or an overlay extension's tool of that name, put that package's or extension's name in run.excludeExtensions";
2066
+ export function validateExcludeExtensionTools(on, run, at, path) {
2067
+ return validateNameList({
2068
+ field: "excludeExtensionTools",
2069
+ nearMiss: excludeExtensionToolsNearMiss,
2070
+ what: "tool names",
2071
+ check: (name) => {
2072
+ if (!LOADED_TOOL_NAME_RE.test(name)) return "is not a tool's name (a letter, then letters, digits, _ or -, at most 48, and no pattern)";
2073
+ const folded = name.toLowerCase();
2074
+ // The spelling that list takes: it holds names by exact membership, so sending `BASH` there as written is a second refusal. That list removes pi's `bash` and leaves an overlay's `BASH`, so each refusal also names the way out for an extension's.
2075
+ if (EXCLUDABLE_TOOL_NAMES.has(folded)) return `matches, ignoring case, one of pi's own tools, which this key does not take: run.excludeTools removes pi's, spelled ${JSON.stringify(folded)}; ${EXTENSION_TOOL_WAY_OUT}`;
2076
+ // REJECTED: "is not an extension's tool", since an overlay extension can register any of these names, and a bare "it belongs on run.excludeTools", which refuses these on a trigger with no run.mcp.
2077
+ if (MCP_EXCLUDABLE_TOOL_NAMES.has(folded)) return `matches, ignoring case, one of the MCP tools, which this key does not take: on a trigger with run.mcp, run.excludeTools removes those, spelled ${JSON.stringify(folded)}; ${EXTENSION_TOOL_WAY_OUT}`;
2078
+ // REJECTED: "is a server's tool", false of `MCP__X__Y`, which no server can bring, since a server's name is lower case.
2079
+ if (folded.startsWith("mcp__")) return `starts, ignoring case, like a server's tool, which this key does not take: on a trigger whose run.mcp names that server, run.excludeTools removes its tools all at once, as mcp__<server>__*; ${EXTENSION_TOOL_WAY_OUT}`;
2080
+ // REJECTED: "another run field arms it; turn that field off", which is false of `subagent` (a role map arms it) and of `decide` and `read_repository` on a label trigger. REJECTED: "a tool the job itself offers", false of a plain job, which offers none of these and does offer an overlay extension's tool of such a name.
2081
+ if (LOADED_JOB_TOOLS.includes(folded)) return `matches, ignoring case, the name of a tool pi-dispatch gives some jobs, which this key does not take; ${EXTENSION_TOOL_WAY_OUT}`;
2082
+ return null;
2083
+ },
2084
+ }, on, run, at, path);
2085
+ }
2086
+
1352
2087
  /**
1353
2088
  * `run.excludeTools` -- pi tool names this trigger's session must NOT have (issue #291).
1354
2089
  *
@@ -1379,24 +2114,7 @@ function validateExcludeTools(on, run, at, path) {
1379
2114
  ]) {
1380
2115
  for (const key of Object.keys(source ?? {})) {
1381
2116
  if (exactIsLegal && key === "excludeTools") continue;
1382
- // Three targets. The first catches every case and separator variant (exclude_tools,
1383
- // exclude-tools, EXCLUDETOOLS). The SINGULAR is the likeliest miss of all: an operator
1384
- // removing one tool writes `excludeTool` the way English does, and unlike `backend` there is
1385
- // no env-var mnemonic anchoring the right form. The past participle is how the field reads
1386
- // back in prose ("the excluded tools"), one grammatical step with no other plausible meaning.
1387
- // Deliberately NOT a synonym hunt (denyTools, toolsOff): that is the general unknown-key
1388
- // sweep this file's forward-compatibility posture rejects.
1389
- const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
1390
- const targets = ["excludetools", "excludetool", "excludedtools"];
1391
- const isSubsequence = (needle, hay) => {
1392
- let i = 0;
1393
- for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
1394
- return i === needle.length;
1395
- };
1396
- const suspicious = /^[\x20-\x7E]*$/.test(key)
1397
- ? targets.includes(normalized)
1398
- : normalized.length >= 4 && targets.some((t) => isSubsequence(normalized, t));
1399
- if (!suspicious) continue;
2117
+ if (!excludeToolsNearMiss(key)) continue;
1400
2118
  throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.excludeTools? An exclusion the loader drops runs the job WITH the tool while the file reads as though it was off, so a near miss is refused rather than dropped: ${path}`);
1401
2119
  }
1402
2120
  }
@@ -1675,18 +2393,24 @@ function normalizeLabel(on, run, index, path) {
1675
2393
  const instructions = validateInstructions(run, at, path);
1676
2394
  const resume = validateResumeFlag(run, at, path);
1677
2395
  const awaitInput = validateAwaitInputFlag(run, at, path);
2396
+ const { progress, statusComment } = validateProgressFlags(run, at, path, { onType: on.type });
2397
+ const recover = validateRecoverFlag(run, at, path, { onType: on.type });
2398
+ const steer = validateSteerFlag(run, at, path);
1678
2399
  const repository = validateRepository(run, "label", at, path);
1679
2400
  const replicas = validateReplicas(run, at, path);
1680
2401
  const secrets = validateSecrets(run, at, path);
1681
2402
  const secretsProfile = validateSecretsProfile(run, at, path);
2403
+ const secretBundle = validateSecretBundleFlag(on, run, at, path);
1682
2404
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1683
2405
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1684
2406
  const excludeTools = validateExcludeTools(on, run, at, path);
2407
+ const excludeExtensions = validateExcludeExtensions(on, run, at, path);
2408
+ const excludeExtensionTools = validateExcludeExtensionTools(on, run, at, path);
1685
2409
  const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1686
2410
  const ref = validateRunModel(on, run, at, path);
1687
2411
  return {
1688
2412
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
1689
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
2413
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...validateApprovalConfig(run), ...(progress !== undefined && { progress }), ...(statusComment !== undefined && { statusComment }), ...(recover !== undefined && { recover }), ...(steer !== undefined && { steer }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(secretBundle !== undefined && { secretBundle }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(excludeExtensions !== undefined && { excludeExtensions }), ...(excludeExtensionTools !== undefined && { excludeExtensionTools }), ...(mcp !== undefined && { mcp }), ...ref },
1690
2414
  };
1691
2415
  }
1692
2416
 
@@ -1719,18 +2443,24 @@ function normalizeComment(on, run, index, path, state) {
1719
2443
  const instructions = validateInstructions(run, at, path);
1720
2444
  const resume = validateResumeFlag(run, at, path);
1721
2445
  const awaitInput = validateAwaitInputFlag(run, at, path);
2446
+ const { progress, statusComment } = validateProgressFlags(run, at, path, { onType: on.type });
2447
+ const recover = validateRecoverFlag(run, at, path, { onType: on.type });
2448
+ const steer = validateSteerFlag(run, at, path);
1722
2449
  const repository = validateRepository(run, "comment", at, path);
1723
2450
  const replicas = validateReplicas(run, at, path);
1724
2451
  const secrets = validateSecrets(run, at, path);
1725
2452
  const secretsProfile = validateSecretsProfile(run, at, path);
2453
+ const secretBundle = validateSecretBundleFlag(on, run, at, path);
1726
2454
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1727
2455
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1728
2456
  const excludeTools = validateExcludeTools(on, run, at, path);
2457
+ const excludeExtensions = validateExcludeExtensions(on, run, at, path);
2458
+ const excludeExtensionTools = validateExcludeExtensionTools(on, run, at, path);
1729
2459
  const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1730
2460
  const ref = validateRunModel(on, run, at, path);
1731
2461
  return {
1732
2462
  on: { type: "comment", phrase: on.phrase },
1733
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
2463
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...validateApprovalConfig(run), ...(progress !== undefined && { progress }), ...(statusComment !== undefined && { statusComment }), ...(recover !== undefined && { recover }), ...(steer !== undefined && { steer }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(secretBundle !== undefined && { secretBundle }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(excludeExtensions !== undefined && { excludeExtensions }), ...(excludeExtensionTools !== undefined && { excludeExtensionTools }), ...(mcp !== undefined && { mcp }), ...ref },
1734
2464
  };
1735
2465
  }
1736
2466
 
@@ -1760,7 +2490,7 @@ function normalizeIssue(on, run, index, path) {
1760
2490
  throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
1761
2491
  }
1762
2492
  // Validated against THIS entry's forge, in that forge's own words -- normalizePullRequest's rule.
1763
- const allowed = ISSUE_ACTIONS[run.kind];
2493
+ const allowed = forgeActions(ISSUE_ACTIONS, run.kind, at, "issue", path);
1764
2494
  const expected = [...allowed].join("|");
1765
2495
  for (const a of actions) {
1766
2496
  if (!allowed.has(a)) {
@@ -1790,13 +2520,19 @@ function normalizeIssue(on, run, index, path) {
1790
2520
  const instructions = validateInstructions(run, at, path);
1791
2521
  const resume = validateResumeFlag(run, at, path);
1792
2522
  const awaitInput = validateAwaitInputFlag(run, at, path);
2523
+ const { progress, statusComment } = validateProgressFlags(run, at, path, { onType: on.type });
2524
+ const recover = validateRecoverFlag(run, at, path, { onType: on.type });
2525
+ const steer = validateSteerFlag(run, at, path);
1793
2526
  validateRepository(run, "issue", at, path);
1794
2527
  const replicas = validateReplicas(run, at, path);
1795
2528
  const secrets = validateSecrets(run, at, path);
1796
2529
  const secretsProfile = validateSecretsProfile(run, at, path);
2530
+ const secretBundle = validateSecretBundleFlag(on, run, at, path);
1797
2531
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1798
2532
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1799
2533
  const excludeTools = validateExcludeTools(on, run, at, path);
2534
+ const excludeExtensions = validateExcludeExtensions(on, run, at, path);
2535
+ const excludeExtensionTools = validateExcludeExtensionTools(on, run, at, path);
1800
2536
  const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1801
2537
  const ref = validateRunModel(on, run, at, path);
1802
2538
  return {
@@ -1808,7 +2544,7 @@ function normalizeIssue(on, run, index, path) {
1808
2544
  ...(number !== undefined && { number }),
1809
2545
  ...(once !== undefined && { once }),
1810
2546
  },
1811
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
2547
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...validateApprovalConfig(run), ...(progress !== undefined && { progress }), ...(statusComment !== undefined && { statusComment }), ...(recover !== undefined && { recover }), ...(steer !== undefined && { steer }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(secretBundle !== undefined && { secretBundle }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(excludeExtensions !== undefined && { excludeExtensions }), ...(excludeExtensionTools !== undefined && { excludeExtensionTools }), ...(mcp !== undefined && { mcp }), ...ref },
1812
2548
  };
1813
2549
  }
1814
2550
 
@@ -1821,7 +2557,7 @@ function normalizePullRequest(on, run, index, path) {
1821
2557
  }
1822
2558
  // Validated against THIS entry's forge, in that forge's own words. A GitHub word on a GitLab trigger
1823
2559
  // (or the reverse) is refused here rather than left to never match at run time.
1824
- const allowed = PR_ACTIONS[run.kind];
2560
+ const allowed = forgeActions(PR_ACTIONS, run.kind, at, "pull_request", path);
1825
2561
  const expected = [...allowed].join("|");
1826
2562
  for (const a of actions) {
1827
2563
  if (!allowed.has(a)) {
@@ -1879,13 +2615,19 @@ function normalizePullRequest(on, run, index, path) {
1879
2615
  const instructions = validateInstructions(run, at, path);
1880
2616
  const resume = validateResumeFlag(run, at, path);
1881
2617
  const awaitInput = validateAwaitInputFlag(run, at, path);
2618
+ const { progress, statusComment } = validateProgressFlags(run, at, path, { onType: on.type });
2619
+ const recover = validateRecoverFlag(run, at, path, { onType: on.type });
2620
+ const steer = validateSteerFlag(run, at, path);
1882
2621
  validateRepository(run, "pull_request", at, path);
1883
2622
  const replicas = validateReplicas(run, at, path);
1884
2623
  const secrets = validateSecrets(run, at, path);
1885
2624
  const secretsProfile = validateSecretsProfile(run, at, path);
2625
+ const secretBundle = validateSecretBundleFlag(on, run, at, path);
1886
2626
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1887
2627
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1888
2628
  const excludeTools = validateExcludeTools(on, run, at, path);
2629
+ const excludeExtensions = validateExcludeExtensions(on, run, at, path);
2630
+ const excludeExtensionTools = validateExcludeExtensionTools(on, run, at, path);
1889
2631
  const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1890
2632
  const ref = validateRunModel(on, run, at, path);
1891
2633
  return {
@@ -1902,7 +2644,7 @@ function normalizePullRequest(on, run, index, path) {
1902
2644
  ...(number !== undefined && { number }),
1903
2645
  ...(once !== undefined && { once }),
1904
2646
  },
1905
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
2647
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...validateApprovalConfig(run), ...(progress !== undefined && { progress }), ...(statusComment !== undefined && { statusComment }), ...(recover !== undefined && { recover }), ...(steer !== undefined && { steer }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(secretBundle !== undefined && { secretBundle }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(excludeExtensions !== undefined && { excludeExtensions }), ...(excludeExtensionTools !== undefined && { excludeExtensionTools }), ...(mcp !== undefined && { mcp }), ...ref },
1906
2648
  };
1907
2649
  }
1908
2650