@edgehero/pi-dispatch-receiver 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.
@@ -33,7 +33,7 @@
33
33
  * a paid run, and it must be visible that it was seen and refused.
34
34
  */
35
35
 
36
- import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
36
+ import { escapeRegExp, firstMatchingRule, heldCandidateOr, labelSet, matchedLabel, matchesRule, steerModeOf } from "./predicate.mjs";
37
37
  import { findCloseRule } from "./close.mjs";
38
38
  import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
39
39
  import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
@@ -46,7 +46,8 @@ export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened
46
46
  *
47
47
  * `subset` is a `parseForgejoSubset` projection; `triggers` is the forgejo rule group; `knownFlows` is the
48
48
  * whole file's flow vocabulary; `authorized` is the receiver's resolved verdict; `deliveryId` is the
49
- * `X-GitHub-Delivery` GUID, which Forgejo emits and keeps stable across its own retries.
49
+ * `X-GitHub-Delivery` GUID, which Forgejo emits once per delivery. Forgejo never resends a delivery on its own,
50
+ * and a replay from its UI is a new delivery with a new GUID (receiver.mjs, WHAT A 503 MEANS).
50
51
  */
51
52
  export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, authorized, deliveryId) {
52
53
  // (0) Fail-closed on identity. MUST precede the self compare -- see header.
@@ -108,76 +109,101 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
108
109
  }
109
110
  }
110
111
 
111
- if (!resolved.enqueue) return resolved; // carries the drop reason
112
+ // A drop is returned as it is, except a phrase-free reply that failed ONLY for the missing parked marker (issue #638,
113
+ // DES-AWAIT-INPUT-ENDS-THE-JOB): that one also carries `heldCandidate`, the job the parked-reply route would have
114
+ // built, through the SAME `build` below, so the receiver can hold it for a run that is parking right now and the job
115
+ // that run starts is byte for byte the one the marker would have produced. Still a drop: nothing here enqueues it.
116
+ if (!resolved.enqueue) return heldCandidateOr(resolved, build);
117
+ return build(resolved);
112
118
 
113
- // The job literal mirrors the other two forges' exactly -- `repo`, `target`, `flow`, the three
114
- // conditional execution knobs, and a descriptive `trigger`. `sender.login` is in the subset but is NOT
115
- // carried here: it exists for the permission lookup and nowhere else, and this object is copied verbatim
116
- // into /job/event.json, which must stay free of personal data.
117
- const job = {
118
- repo: subset.repository?.full_name,
119
- target: resolved.target,
120
- // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
121
- // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
122
- // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
123
- // literal byte-identical to what it always was -- the same move filter.mjs makes.
124
- ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
125
- ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
126
- ...(resolved.image !== undefined ? { image: resolved.image } : {}),
127
- // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
128
- // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
129
- // it loaded, validated, reached this function on the rule, and then ran on the default. That is
130
- // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
131
- // arriving through the plumbing instead of the spelling, one file downstream of the guard.
132
- ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
133
- // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
134
- ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
135
- // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
136
- // the job without its servers.
137
- ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
138
- // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
139
- // that named only a model would have its provider's absence decide whether the model survives,
140
- // and a dropped model runs the job on the deployment default while the file reads as chosen.
141
- ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
142
- ...(resolved.model !== undefined ? { model: resolved.model } : {}),
143
- ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
144
- ...(resolved.models !== undefined ? { models: resolved.models } : {}),
145
- // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
146
- // while the file reads as narrowed.
147
- ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
148
- // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
149
- // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
150
- // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
151
- // basename(folder) restraint already exists to prevent.
152
- ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
153
- // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
154
- // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
155
- // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
156
- ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
157
- ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
158
- ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
159
- ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
160
- ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
161
- // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
162
- // resume's rule, so an unflagged job's data stays byte-identical.
163
- ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
164
- // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
165
- ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
166
- // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
167
- // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
168
- // unflagged job's data must stay byte-identical to today's.
169
- ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
170
- trigger: {
171
- event: eventName,
172
- // Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
173
- action: raw,
174
- deliveryId,
175
- sender: { id: subset.sender.id },
176
- matched: resolved.matched,
177
- ...(resolved.comment ? { comment: resolved.comment } : {}),
178
- },
179
- };
180
- return { enqueue: true, job };
119
+ // The job from a resolved route. One function for every route and for the held candidate (issue #638), declared
120
+ // here so it closes over this delivery's subset and ids.
121
+ function build(resolved) {
122
+ // The job literal mirrors the other two forges' exactly -- `repo`, `target`, `flow`, the three
123
+ // conditional execution knobs, and a descriptive `trigger`. `sender.login` is in the subset but is NOT
124
+ // carried here: it exists for the permission lookup and nowhere else, and this object is copied verbatim
125
+ // into /job/event.json, which must stay free of personal data.
126
+ const job = {
127
+ repo: subset.repository?.full_name,
128
+ target: resolved.target,
129
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
130
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
131
+ // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
132
+ // literal byte-identical to what it always was -- the same move filter.mjs makes.
133
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
134
+ ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
135
+ ...(resolved.image !== undefined ? { image: resolved.image } : {}),
136
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
137
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
138
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
139
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
140
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
141
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
142
+ // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
143
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
144
+ // Issue #800, each its own spread by the same rule: a dropped list runs the job with what the file takes away.
145
+ ...(resolved.excludeExtensions !== undefined ? { excludeExtensions: resolved.excludeExtensions } : {}),
146
+ ...(resolved.excludeExtensionTools !== undefined ? { excludeExtensionTools: resolved.excludeExtensionTools } : {}),
147
+ // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
148
+ // the job without its servers.
149
+ ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
150
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
151
+ // that named only a model would have its provider's absence decide whether the model survives,
152
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
153
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
154
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
155
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
156
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}), ...(resolved.fallbackModels !== undefined ? { fallbackModels: resolved.fallbackModels } : {}),
157
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
158
+ // while the file reads as narrowed.
159
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
160
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
161
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
162
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
163
+ // basename(folder) restraint already exists to prevent.
164
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
165
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
166
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
167
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
168
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
169
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
170
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
171
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
172
+ ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
173
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
174
+ // resume's rule, so an unflagged job's data stays byte-identical.
175
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
176
+ ...(resolved.gates !== undefined ? { gates: resolved.gates, ...(resolved.gateTimeout !== undefined ? { gateTimeout: resolved.gateTimeout } : {}), ...(resolved.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: resolved.gateBudgetRaiseUsd } : {}) } : {}),
177
+ // Issue #644: the progress tool and the forge status comment, each present only when armed, awaitInput's rule.
178
+ ...(resolved.progress === true ? { progress: true } : {}),
179
+ ...(resolved.statusComment === true ? { statusComment: true } : {}),
180
+ // Issue #645: durable runs, present only when armed, the same rule.
181
+ ...(resolved.recover === true ? { recover: true } : {}),
182
+ // Issue #646: steering a running job, present only when armed, the same rule.
183
+ ...(resolved.steer === true ? { steer: true } : {}),
184
+ // Issue #646: how this phrase comment steers a job of its trigger that is already running, set only on the comment
185
+ // route of a steer-armed trigger (never a parked reply, never a command). The receiver reads it to try the run's
186
+ // inbox before it enqueues, and `enqueueForgeJob` names its fields, so it never reaches a queue. At JOB level, never
187
+ // inside `trigger`, which is copied verbatim into /job/event.json.
188
+ ...(resolved.steerMode !== undefined ? { steerMode: resolved.steerMode } : {}),
189
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
190
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
191
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
192
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
193
+ // unflagged job's data must stay byte-identical to today's.
194
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
195
+ trigger: {
196
+ event: eventName,
197
+ // Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
198
+ action: raw,
199
+ deliveryId,
200
+ sender: { id: subset.sender.id },
201
+ matched: resolved.matched,
202
+ ...(resolved.comment ? { comment: resolved.comment } : {}),
203
+ },
204
+ };
205
+ return { enqueue: true, job };
206
+ }
181
207
  }
182
208
 
183
209
  /** Issue label path: the label predicate selects, and the resolved permission has already gated. */
@@ -192,15 +218,19 @@ function routeIssueLabel(subset, triggers) {
192
218
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
193
219
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
194
220
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
195
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
196
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
221
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
222
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
197
223
  skillsDir: rule.skillsDir,
198
224
  secrets: rule.secrets,
199
225
  secretsProfile: rule.secretsProfile,
200
226
  waitFor: rule.waitFor,
201
227
  instructions: rule.instructions,
202
228
  resume: rule.resume,
203
- awaitInput: rule.awaitInput,
229
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
230
+ progress: rule.progress,
231
+ statusComment: rule.statusComment,
232
+ recover: rule.recover,
233
+ steer: rule.steer,
204
234
  replicas: rule.replicas,
205
235
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
206
236
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
@@ -208,7 +238,7 @@ function routeIssueLabel(subset, triggers) {
208
238
  }
209
239
 
210
240
  /** Comment path. The authority gate above has already run, so this only has to match the phrase. */
211
- function routeComment(subset, triggers, knownFlows) {
241
+ function routeComment(subset, triggers, knownFlows, assumeParked = false) {
212
242
  const phrase = triggers.comment?.phrase;
213
243
  const body = subset.comment?.body;
214
244
  const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
@@ -217,8 +247,17 @@ function routeComment(subset, triggers, knownFlows) {
217
247
  // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
218
248
  // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
219
249
  // worker refuses the job before any spend unless the key's `parked` sidecar exists.
220
- const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.isPull !== true && hasParkedLabel(subset.issue?.labels);
250
+ const parkedCandidate = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.isPull !== true;
251
+ const parkedReply = parkedCandidate && (assumeParked || hasParkedLabel(subset.issue?.labels));
221
252
  if (!phraseHit && !parkedReply) {
253
+ // Issue #638: a reply that fails ONLY for the missing marker (the run asked its question and is still parking, so
254
+ // the label is not on yet) is still a drop, but it carries the route the marker would have taken, built by this
255
+ // same function with the marker assumed (so every other rule above still decided it). The receiver holds it for a
256
+ // run that is parking on this target, or drops it exactly as today.
257
+ if (parkedCandidate) {
258
+ const held = routeComment(subset, triggers, knownFlows, true);
259
+ if (held.enqueue) return { enqueue: false, reason: "no-trigger-phrase", heldResolved: held };
260
+ }
222
261
  return { enqueue: false, reason: "no-trigger-phrase" };
223
262
  }
224
263
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -250,18 +289,27 @@ function routeComment(subset, triggers, knownFlows) {
250
289
  enqueue: true,
251
290
  ...(command !== undefined ? { command } : { flow }),
252
291
  packages: triggers.comment.packages,
253
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
254
- provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
292
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, excludeExtensions: triggers.comment.excludeExtensions, excludeExtensionTools: triggers.comment.excludeExtensionTools, mcp: triggers.comment.mcp,
293
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, fallbackModels: triggers.comment.fallbackModels, maxCostUsd: triggers.comment.maxCostUsd,
255
294
  skillsDir: triggers.comment.skillsDir,
256
295
  secrets: triggers.comment.secrets,
257
296
  secretsProfile: triggers.comment.secretsProfile,
258
297
  waitFor: triggers.comment.waitFor,
259
298
  instructions: triggers.comment.instructions,
260
299
  resume: triggers.comment.resume,
261
- awaitInput: triggers.comment.awaitInput,
300
+ awaitInput: triggers.comment.awaitInput, gates: triggers.comment.gates, gateTimeout: triggers.comment.gateTimeout, gateBudgetRaiseUsd: triggers.comment.gateBudgetRaiseUsd,
301
+ progress: triggers.comment.progress,
302
+ statusComment: triggers.comment.statusComment,
303
+ recover: triggers.comment.recover,
304
+ steer: triggers.comment.steer,
262
305
  replicas: triggers.comment.replicas,
263
306
  matched: { index: triggers.comment.index, type: "comment", phrase },
264
307
  ...(parkedReply && { parkedReply: true }),
308
+ // Issue #646: a phrase comment on a steer-armed trigger may steer a run of it that is already going. Only with the
309
+ // phrase (a parked reply is an answer to a parked run, never a steer) and never on a command rule (the loader refuses
310
+ // `steer` beside `command`; checked again here so a drifted file cannot reach a run). The author gate above already
311
+ // ran: this changes where an admitted comment goes, never who may send one. The `later` grammar is `steerModeOf`'s.
312
+ ...(phraseHit && command === undefined && triggers.comment.steer === true && { steerMode: steerModeOf(body, phrase) }),
265
313
  // The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
266
314
  // authority that admitted this comment was resolved from the API, not read off the body.
267
315
  comment: { body: subset.comment.body },
@@ -284,15 +332,19 @@ function routePullRequest(subset, triggers, action) {
284
332
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
285
333
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
286
334
  packages: rule.packages,
287
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
288
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
335
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
336
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
289
337
  skillsDir: rule.skillsDir,
290
338
  secrets: rule.secrets,
291
339
  secretsProfile: rule.secretsProfile,
292
340
  waitFor: rule.waitFor,
293
341
  instructions: rule.instructions,
294
342
  resume: rule.resume,
295
- awaitInput: rule.awaitInput,
343
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
344
+ progress: rule.progress,
345
+ statusComment: rule.statusComment,
346
+ recover: rule.recover,
347
+ steer: rule.steer,
296
348
  replicas: rule.replicas,
297
349
  matched: { index: rule.index, type: "pull_request", action },
298
350
  target: {
@@ -341,15 +393,19 @@ function routeIssueClose(subset, triggers, authorized) {
341
393
  enqueue: true,
342
394
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
343
395
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
344
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
345
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
396
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
397
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
346
398
  skillsDir: rule.skillsDir,
347
399
  secrets: rule.secrets,
348
400
  secretsProfile: rule.secretsProfile,
349
401
  waitFor: rule.waitFor,
350
402
  instructions: rule.instructions,
351
403
  resume: rule.resume,
352
- awaitInput: rule.awaitInput,
404
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
405
+ progress: rule.progress,
406
+ statusComment: rule.statusComment,
407
+ recover: rule.recover,
408
+ steer: rule.steer,
353
409
  replicas: rule.replicas,
354
410
  // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
355
411
  // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
@@ -379,15 +435,19 @@ function routePullRequestClose(subset, triggers, authorized) {
379
435
  enqueue: true,
380
436
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
381
437
  packages: rule.packages,
382
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
383
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
438
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
439
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
384
440
  skillsDir: rule.skillsDir,
385
441
  secrets: rule.secrets,
386
442
  secretsProfile: rule.secretsProfile,
387
443
  waitFor: rule.waitFor,
388
444
  instructions: rule.instructions,
389
445
  resume: rule.resume,
390
- awaitInput: rule.awaitInput,
446
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
447
+ progress: rule.progress,
448
+ statusComment: rule.statusComment,
449
+ recover: rule.recover,
450
+ steer: rule.steer,
391
451
  replicas: rule.replicas,
392
452
  // No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
393
453
  // number is the target's business; the issue route carries it because the github design does.