@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.
@@ -40,7 +40,7 @@
40
40
  * `changes.labels` matches nothing.
41
41
  */
42
42
 
43
- import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
43
+ import { firstMatchingRule, heldCandidateOr, labelSet, matchedLabel, steerModeOf } from "./predicate.mjs";
44
44
  import { findCloseRule } from "./close.mjs";
45
45
  import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
46
46
 
@@ -109,79 +109,104 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
109
109
  return { enqueue: false, reason: "unhandled-event" };
110
110
  }
111
111
 
112
- 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);
113
118
 
114
- // (4) Build the job from the INT-GITLAB-PAYLOAD-SUBSET fields only. `user.username` is deliberately
115
- // NOT carried: it exists so the receiver could resolve an access level, and it is personal data with no
116
- // downstream reader (no-pii-in-logs). `projectId` rides the job because every GitLab API path the
117
- // worker needs takes the numeric id -- a `group/subgroup/project` path has no fixed segment count, and
118
- // splitting one is how a nested project silently becomes its own parent group.
119
- const job = {
120
- repo: subset.project?.path,
121
- projectId: subset.project?.id,
122
- target: resolved.target,
123
- // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
124
- // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
125
- // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
126
- // literal byte-identical to what it always was -- the same move filter.mjs makes.
127
- ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
128
- ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
129
- ...(resolved.image !== undefined ? { image: resolved.image } : {}),
130
- // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
131
- // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
132
- // it loaded, validated, reached this function on the rule, and then ran on the default. That is
133
- // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
134
- // arriving through the plumbing instead of the spelling, one file downstream of the guard.
135
- ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
136
- // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
137
- ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
138
- // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
139
- // the job without its servers.
140
- ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
141
- // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
142
- // that named only a model would have its provider's absence decide whether the model survives,
143
- // and a dropped model runs the job on the deployment default while the file reads as chosen.
144
- ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
145
- ...(resolved.model !== undefined ? { model: resolved.model } : {}),
146
- ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
147
- ...(resolved.models !== undefined ? { models: resolved.models } : {}),
148
- // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
149
- // while the file reads as narrowed.
150
- ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
151
- // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
152
- // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
153
- // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
154
- // basename(folder) restraint already exists to prevent.
155
- ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
156
- // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
157
- // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
158
- // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
159
- ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
160
- ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
161
- ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
162
- ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
163
- // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
164
- // byte-identical to today's, so the key is absent rather than present-and-undefined.
165
- ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
166
- // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
167
- // resume's rule, so an unflagged job's data stays byte-identical.
168
- ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
169
- // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
170
- ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
171
- // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
172
- // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
173
- // unflagged job's data must stay byte-identical to today's.
174
- ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
175
- trigger: {
176
- event: kind,
177
- action: subset.action,
178
- deliveryId,
179
- sender: { id: subset.user.id },
180
- matched: resolved.matched,
181
- ...(resolved.comment ? { comment: resolved.comment } : {}),
182
- },
183
- };
184
- 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
+ // (4) Build the job from the INT-GITLAB-PAYLOAD-SUBSET fields only. `user.username` is deliberately
123
+ // NOT carried: it exists so the receiver could resolve an access level, and it is personal data with no
124
+ // downstream reader (no-pii-in-logs). `projectId` rides the job because every GitLab API path the
125
+ // worker needs takes the numeric id -- a `group/subgroup/project` path has no fixed segment count, and
126
+ // splitting one is how a nested project silently becomes its own parent group.
127
+ const job = {
128
+ repo: subset.project?.path,
129
+ projectId: subset.project?.id,
130
+ target: resolved.target,
131
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
132
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
133
+ // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
134
+ // literal byte-identical to what it always was -- the same move filter.mjs makes.
135
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
136
+ ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
137
+ ...(resolved.image !== undefined ? { image: resolved.image } : {}),
138
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
139
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
140
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
141
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
142
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
143
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
144
+ // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
145
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
146
+ // Issue #800, each its own spread by the same rule: a dropped list runs the job with what the file takes away.
147
+ ...(resolved.excludeExtensions !== undefined ? { excludeExtensions: resolved.excludeExtensions } : {}),
148
+ ...(resolved.excludeExtensionTools !== undefined ? { excludeExtensionTools: resolved.excludeExtensionTools } : {}),
149
+ // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
150
+ // the job without its servers.
151
+ ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
152
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
153
+ // that named only a model would have its provider's absence decide whether the model survives,
154
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
155
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
156
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
157
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
158
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}), ...(resolved.fallbackModels !== undefined ? { fallbackModels: resolved.fallbackModels } : {}),
159
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
160
+ // while the file reads as narrowed.
161
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
162
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
163
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
164
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
165
+ // basename(folder) restraint already exists to prevent.
166
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
167
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
168
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
169
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
170
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
171
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
172
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
173
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
174
+ // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
175
+ // byte-identical to today's, so the key is absent rather than present-and-undefined.
176
+ ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
177
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
178
+ // resume's rule, so an unflagged job's data stays byte-identical.
179
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
180
+ ...(resolved.gates !== undefined ? { gates: resolved.gates, ...(resolved.gateTimeout !== undefined ? { gateTimeout: resolved.gateTimeout } : {}), ...(resolved.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: resolved.gateBudgetRaiseUsd } : {}) } : {}),
181
+ // Issue #644: the progress tool and the forge status comment, each present only when armed, awaitInput's rule.
182
+ ...(resolved.progress === true ? { progress: true } : {}),
183
+ ...(resolved.statusComment === true ? { statusComment: true } : {}),
184
+ // Issue #645: durable runs, present only when armed, the same rule.
185
+ ...(resolved.recover === true ? { recover: true } : {}),
186
+ // Issue #646: steering a running job, present only when armed, the same rule.
187
+ ...(resolved.steer === true ? { steer: true } : {}),
188
+ // Issue #646: how this phrase comment steers a job of its trigger that is already running, set only on the comment
189
+ // route of a steer-armed trigger (never a parked reply, never a command). The receiver reads it to try the run's
190
+ // inbox before it enqueues, and `enqueueForgeJob` names its fields, so it never reaches a queue. At JOB level, never
191
+ // inside `trigger`, which is copied verbatim into /job/event.json.
192
+ ...(resolved.steerMode !== undefined ? { steerMode: resolved.steerMode } : {}),
193
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
194
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
195
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
196
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
197
+ // unflagged job's data must stay byte-identical to today's.
198
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
199
+ trigger: {
200
+ event: kind,
201
+ action: subset.action,
202
+ deliveryId,
203
+ sender: { id: subset.user.id },
204
+ matched: resolved.matched,
205
+ ...(resolved.comment ? { comment: resolved.comment } : {}),
206
+ },
207
+ };
208
+ return { enqueue: true, job };
209
+ }
185
210
  }
186
211
 
187
212
  /**
@@ -202,15 +227,19 @@ function routeLabel(subset, triggers, targetType) {
202
227
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
203
228
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
204
229
  packages: rule.packages,
205
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
206
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
230
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
231
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
207
232
  skillsDir: rule.skillsDir,
208
233
  secrets: rule.secrets,
209
234
  secretsProfile: rule.secretsProfile,
210
235
  waitFor: rule.waitFor,
211
236
  instructions: rule.instructions,
212
237
  resume: rule.resume,
213
- awaitInput: rule.awaitInput,
238
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
239
+ progress: rule.progress,
240
+ statusComment: rule.statusComment,
241
+ recover: rule.recover,
242
+ steer: rule.steer,
214
243
  replicas: rule.replicas,
215
244
  matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
216
245
  target: buildTarget(subset, targetType),
@@ -268,15 +297,19 @@ function mrResult(subset, rule, matched) {
268
297
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
269
298
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
270
299
  packages: rule.packages,
271
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
272
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
300
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
301
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
273
302
  skillsDir: rule.skillsDir,
274
303
  secrets: rule.secrets,
275
304
  secretsProfile: rule.secretsProfile,
276
305
  waitFor: rule.waitFor,
277
306
  instructions: rule.instructions,
278
307
  resume: rule.resume,
279
- awaitInput: rule.awaitInput,
308
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
309
+ progress: rule.progress,
310
+ statusComment: rule.statusComment,
311
+ recover: rule.recover,
312
+ steer: rule.steer,
280
313
  replicas: rule.replicas,
281
314
  matched,
282
315
  target: buildTarget(subset, "pull_request"),
@@ -316,15 +349,19 @@ function routeIssueClose(subset, triggers, authorized) {
316
349
  enqueue: true,
317
350
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
318
351
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
319
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
320
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
352
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
353
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
321
354
  skillsDir: rule.skillsDir,
322
355
  secrets: rule.secrets,
323
356
  secretsProfile: rule.secretsProfile,
324
357
  waitFor: rule.waitFor,
325
358
  instructions: rule.instructions,
326
359
  resume: rule.resume,
327
- awaitInput: rule.awaitInput,
360
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
361
+ progress: rule.progress,
362
+ statusComment: rule.statusComment,
363
+ recover: rule.recover,
364
+ steer: rule.steer,
328
365
  replicas: rule.replicas,
329
366
  // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
330
367
  // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
@@ -352,15 +389,19 @@ function routeMergeRequestClose(subset, triggers, authorized) {
352
389
  enqueue: true,
353
390
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
354
391
  packages: rule.packages,
355
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
356
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
392
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
393
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
357
394
  skillsDir: rule.skillsDir,
358
395
  secrets: rule.secrets,
359
396
  secretsProfile: rule.secretsProfile,
360
397
  waitFor: rule.waitFor,
361
398
  instructions: rule.instructions,
362
399
  resume: rule.resume,
363
- awaitInput: rule.awaitInput,
400
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
401
+ progress: rule.progress,
402
+ statusComment: rule.statusComment,
403
+ recover: rule.recover,
404
+ steer: rule.steer,
364
405
  replicas: rule.replicas,
365
406
  // No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
366
407
  // number is the target's business; the issue route carries it because the github design does.
@@ -375,7 +416,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
375
416
  * issue branch for something that is already a merge request: wrong work, no error, and it looks like a
376
417
  * successful run.
377
418
  */
378
- function routeNote(subset, triggers, knownFlows) {
419
+ function routeNote(subset, triggers, knownFlows, assumeParked = false) {
379
420
  if (subset.action !== "create") return { enqueue: false, reason: "unhandled-event" };
380
421
 
381
422
  const phrase = triggers?.comment?.phrase;
@@ -386,8 +427,17 @@ function routeNote(subset, triggers, knownFlows) {
386
427
  // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
387
428
  // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
388
429
  // worker refuses the job before any spend unless the key's `parked` sidecar exists.
389
- const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers?.comment?.resume === true && triggers?.comment?.command === undefined && subset.noteableType !== "MergeRequest" && hasParkedLabel(subset.target?.labels);
430
+ const parkedCandidate = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers?.comment?.resume === true && triggers?.comment?.command === undefined && subset.noteableType !== "MergeRequest";
431
+ const parkedReply = parkedCandidate && (assumeParked || hasParkedLabel(subset.target?.labels));
390
432
  if (!phraseHit && !parkedReply) {
433
+ // Issue #638: a reply that fails ONLY for the missing marker (the run asked its question and is still parking, so
434
+ // the label is not on yet) is still a drop, but it carries the route the marker would have taken, built by this
435
+ // same function with the marker assumed (so every other rule above still decided it). The receiver holds it for a
436
+ // run that is parking on this target, or drops it exactly as today.
437
+ if (parkedCandidate) {
438
+ const held = routeNote(subset, triggers, knownFlows, true);
439
+ if (held.enqueue) return { enqueue: false, reason: "no-trigger-phrase", heldResolved: held };
440
+ }
391
441
  return { enqueue: false, reason: "no-trigger-phrase" };
392
442
  }
393
443
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -413,18 +463,27 @@ function routeNote(subset, triggers, knownFlows) {
413
463
  enqueue: true,
414
464
  ...(command !== undefined ? { command } : { flow }),
415
465
  packages: triggers.comment.packages,
416
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
417
- provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
466
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, excludeExtensions: triggers.comment.excludeExtensions, excludeExtensionTools: triggers.comment.excludeExtensionTools, mcp: triggers.comment.mcp,
467
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, fallbackModels: triggers.comment.fallbackModels, maxCostUsd: triggers.comment.maxCostUsd,
418
468
  skillsDir: triggers.comment.skillsDir,
419
469
  secrets: triggers.comment.secrets,
420
470
  secretsProfile: triggers.comment.secretsProfile,
421
471
  waitFor: triggers.comment.waitFor,
422
472
  instructions: triggers.comment.instructions,
423
473
  resume: triggers.comment.resume,
424
- awaitInput: triggers.comment.awaitInput,
474
+ awaitInput: triggers.comment.awaitInput, gates: triggers.comment.gates, gateTimeout: triggers.comment.gateTimeout, gateBudgetRaiseUsd: triggers.comment.gateBudgetRaiseUsd,
475
+ progress: triggers.comment.progress,
476
+ statusComment: triggers.comment.statusComment,
477
+ recover: triggers.comment.recover,
478
+ steer: triggers.comment.steer,
425
479
  replicas: triggers.comment.replicas,
426
480
  matched: { index: triggers.comment.index, type: "comment", phrase },
427
481
  ...(parkedReply && { parkedReply: true }),
482
+ // Issue #646: a phrase comment on a steer-armed trigger may steer a run of it that is already going. Only with the
483
+ // phrase (a parked reply is an answer to a parked run, never a steer) and never on a command rule (the loader refuses
484
+ // `steer` beside `command`; checked again here so a drifted file cannot reach a run). The author gate above already
485
+ // ran: this changes where an admitted comment goes, never who may send one. The `later` grammar is `steerModeOf`'s.
486
+ ...(phraseHit && command === undefined && triggers?.comment?.steer === true && { steerMode: steerModeOf(body, phrase) }),
428
487
  target: buildTarget(subset, targetType),
429
488
  // The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
430
489
  // counterpart exists on GitLab: the approval is the resolved access level, which is a harness