@edgehero/pi-dispatch-receiver 4.2.0 → 4.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch trigger edge: verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks, or polls GitHub with no public URL, and queues one pi coding agent job per event (per replica, if set) for the worker.",
6
6
  "keywords": [
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@octokit/webhooks": "14.2.0",
51
- "@edgehero/pi-dispatch": "^4.2.0",
51
+ "@edgehero/pi-dispatch": "^4.3.0",
52
52
  "bullmq": "5.80.4",
53
53
  "ioredis": "5.11.1"
54
54
  }
package/src/config.mjs CHANGED
@@ -318,9 +318,9 @@ function loadTriggers(env, readFile, fileExists) {
318
318
  }
319
319
  const group = groups[run.kind];
320
320
  if (on.type === "label") {
321
- group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
321
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
322
322
  } else if (on.type === "comment") {
323
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
323
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
324
324
  } else if (on.type === "pull_request") {
325
325
  // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
326
326
  // resolved authority) and every other PR rule through the author gate, so they live in separate
@@ -329,17 +329,17 @@ function loadTriggers(env, readFile, fileExists) {
329
329
  // split reads the SHARED close-word table, never a re-typed word.
330
330
  const closeWord = PR_CLOSE_ACTIONS[run.kind];
331
331
  if (closeWord !== undefined && on.action.includes(closeWord)) {
332
- group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
332
+ group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
333
333
  } else {
334
334
  // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
335
335
  // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
336
- group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
336
+ group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
337
337
  }
338
338
  } else if (on.type === "issue") {
339
339
  // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
340
340
  // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
341
341
  // normalized them -- the filter's close route reads them, nothing else does.
342
- group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
342
+ group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
343
343
  }
344
344
  }
345
345
 
@@ -27,6 +27,7 @@
27
27
  import { createHash } from "node:crypto";
28
28
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
29
29
  import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
30
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
30
31
 
31
32
  /** The `pull_request` actions a trigger may name, mirrored from the loader's azure vocabulary. */
32
33
  export const PR_ACTION_FOR = {
@@ -143,6 +144,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
143
144
  ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
144
145
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
145
146
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
147
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
148
+ // resume's rule, so an unflagged job's data stays byte-identical.
149
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
150
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
151
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
146
152
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
147
153
  // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
148
154
  // unflagged job's data must stay byte-identical to today's.
@@ -214,6 +220,7 @@ function matchLabelRules(subset, triggers, labels, action) {
214
220
  waitFor: rule.waitFor,
215
221
  instructions: rule.instructions,
216
222
  resume: rule.resume,
223
+ awaitInput: rule.awaitInput,
217
224
  replicas: rule.replicas,
218
225
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
219
226
  target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
@@ -224,7 +231,14 @@ function matchLabelRules(subset, triggers, labels, action) {
224
231
  function routeComment(subset, triggers, knownFlows, targetType) {
225
232
  const phrase = triggers.comment?.phrase;
226
233
  const body = subset.comment;
227
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
234
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
235
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
236
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
237
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
238
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
239
+ // worker refuses the job before any spend unless the key's `parked` sidecar exists.
240
+ const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && targetType === "issue" && hasParkedLabel(subset.tags);
241
+ if (!phraseHit && !parkedReply) {
228
242
  return { enqueue: false, reason: "no-trigger-phrase" };
229
243
  }
230
244
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -259,8 +273,10 @@ function routeComment(subset, triggers, knownFlows, targetType) {
259
273
  waitFor: triggers.comment.waitFor,
260
274
  instructions: triggers.comment.instructions,
261
275
  resume: triggers.comment.resume,
276
+ awaitInput: triggers.comment.awaitInput,
262
277
  replicas: triggers.comment.replicas,
263
278
  matched: { index: triggers.comment.index, type: "comment", phrase },
279
+ ...(parkedReply && { parkedReply: true }),
264
280
  // No author_association: Azure has none, and the authority that admitted this comment was resolved
265
281
  // from the graph, not read off the body.
266
282
  comment: { body },
@@ -293,6 +309,7 @@ function routePullRequest(subset, triggers, action) {
293
309
  waitFor: rule.waitFor,
294
310
  instructions: rule.instructions,
295
311
  resume: rule.resume,
312
+ awaitInput: rule.awaitInput,
296
313
  replicas: rule.replicas,
297
314
  matched: { index: rule.index, type: "pull_request", action },
298
315
  target: {
@@ -36,6 +36,7 @@
36
36
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
37
37
  import { findCloseRule } from "./close.mjs";
38
38
  import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
39
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
39
40
 
40
41
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
41
42
  export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
@@ -157,6 +158,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
157
158
  ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
158
159
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
159
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 } : {}),
160
166
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
161
167
  // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
162
168
  // unflagged job's data must stay byte-identical to today's.
@@ -194,6 +200,7 @@ function routeIssueLabel(subset, triggers) {
194
200
  waitFor: rule.waitFor,
195
201
  instructions: rule.instructions,
196
202
  resume: rule.resume,
203
+ awaitInput: rule.awaitInput,
197
204
  replicas: rule.replicas,
198
205
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
199
206
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
@@ -204,7 +211,14 @@ function routeIssueLabel(subset, triggers) {
204
211
  function routeComment(subset, triggers, knownFlows) {
205
212
  const phrase = triggers.comment?.phrase;
206
213
  const body = subset.comment?.body;
207
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
214
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
215
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
216
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
217
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
218
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
219
+ // 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);
221
+ if (!phraseHit && !parkedReply) {
208
222
  return { enqueue: false, reason: "no-trigger-phrase" };
209
223
  }
210
224
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -244,8 +258,10 @@ function routeComment(subset, triggers, knownFlows) {
244
258
  waitFor: triggers.comment.waitFor,
245
259
  instructions: triggers.comment.instructions,
246
260
  resume: triggers.comment.resume,
261
+ awaitInput: triggers.comment.awaitInput,
247
262
  replicas: triggers.comment.replicas,
248
263
  matched: { index: triggers.comment.index, type: "comment", phrase },
264
+ ...(parkedReply && { parkedReply: true }),
249
265
  // The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
250
266
  // authority that admitted this comment was resolved from the API, not read off the body.
251
267
  comment: { body: subset.comment.body },
@@ -276,6 +292,7 @@ function routePullRequest(subset, triggers, action) {
276
292
  waitFor: rule.waitFor,
277
293
  instructions: rule.instructions,
278
294
  resume: rule.resume,
295
+ awaitInput: rule.awaitInput,
279
296
  replicas: rule.replicas,
280
297
  matched: { index: rule.index, type: "pull_request", action },
281
298
  target: {
@@ -332,6 +349,7 @@ function routeIssueClose(subset, triggers, authorized) {
332
349
  waitFor: rule.waitFor,
333
350
  instructions: rule.instructions,
334
351
  resume: rule.resume,
352
+ awaitInput: rule.awaitInput,
335
353
  replicas: rule.replicas,
336
354
  // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
337
355
  // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
@@ -369,6 +387,7 @@ function routePullRequestClose(subset, triggers, authorized) {
369
387
  waitFor: rule.waitFor,
370
388
  instructions: rule.instructions,
371
389
  resume: rule.resume,
390
+ awaitInput: rule.awaitInput,
372
391
  replicas: rule.replicas,
373
392
  // No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
374
393
  // number is the target's business; the issue route carries it because the github design does.
@@ -42,6 +42,7 @@
42
42
 
43
43
  import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
44
44
  import { findCloseRule } from "./close.mjs";
45
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
45
46
 
46
47
  /**
47
48
  /** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
@@ -162,6 +163,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
162
163
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
163
164
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
164
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 } : {}),
165
171
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
166
172
  // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
167
173
  // unflagged job's data must stay byte-identical to today's.
@@ -204,6 +210,7 @@ function routeLabel(subset, triggers, targetType) {
204
210
  waitFor: rule.waitFor,
205
211
  instructions: rule.instructions,
206
212
  resume: rule.resume,
213
+ awaitInput: rule.awaitInput,
207
214
  replicas: rule.replicas,
208
215
  matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
209
216
  target: buildTarget(subset, targetType),
@@ -269,6 +276,7 @@ function mrResult(subset, rule, matched) {
269
276
  waitFor: rule.waitFor,
270
277
  instructions: rule.instructions,
271
278
  resume: rule.resume,
279
+ awaitInput: rule.awaitInput,
272
280
  replicas: rule.replicas,
273
281
  matched,
274
282
  target: buildTarget(subset, "pull_request"),
@@ -316,6 +324,7 @@ function routeIssueClose(subset, triggers, authorized) {
316
324
  waitFor: rule.waitFor,
317
325
  instructions: rule.instructions,
318
326
  resume: rule.resume,
327
+ awaitInput: rule.awaitInput,
319
328
  replicas: rule.replicas,
320
329
  // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
321
330
  // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
@@ -351,6 +360,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
351
360
  waitFor: rule.waitFor,
352
361
  instructions: rule.instructions,
353
362
  resume: rule.resume,
363
+ awaitInput: rule.awaitInput,
354
364
  replicas: rule.replicas,
355
365
  // No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
356
366
  // number is the target's business; the issue route carries it because the github design does.
@@ -370,7 +380,14 @@ function routeNote(subset, triggers, knownFlows) {
370
380
 
371
381
  const phrase = triggers?.comment?.phrase;
372
382
  const body = subset.note;
373
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
383
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
384
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
385
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
386
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
387
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
388
+ // 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);
390
+ if (!phraseHit && !parkedReply) {
374
391
  return { enqueue: false, reason: "no-trigger-phrase" };
375
392
  }
376
393
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -404,8 +421,10 @@ function routeNote(subset, triggers, knownFlows) {
404
421
  waitFor: triggers.comment.waitFor,
405
422
  instructions: triggers.comment.instructions,
406
423
  resume: triggers.comment.resume,
424
+ awaitInput: triggers.comment.awaitInput,
407
425
  replicas: triggers.comment.replicas,
408
426
  matched: { index: triggers.comment.index, type: "comment", phrase },
427
+ ...(parkedReply && { parkedReply: true }),
409
428
  target: buildTarget(subset, targetType),
410
429
  // The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
411
430
  // counterpart exists on GitLab: the approval is the resolved access level, which is a harness
package/src/filter.mjs CHANGED
@@ -47,6 +47,7 @@
47
47
  */
48
48
  import { findCloseRule } from "./close.mjs";
49
49
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
50
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
50
51
 
51
52
  const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
52
53
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
@@ -185,6 +186,11 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
185
186
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
186
187
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
187
188
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
189
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
190
+ // resume's rule, so an unflagged job's data stays byte-identical.
191
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
192
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
193
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
188
194
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
189
195
  // inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
190
196
  // receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
@@ -232,6 +238,7 @@ function routeIssueLabel(subset, triggers) {
232
238
  waitFor: rule.waitFor,
233
239
  instructions: rule.instructions,
234
240
  resume: rule.resume,
241
+ awaitInput: rule.awaitInput,
235
242
  replicas: rule.replicas,
236
243
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
237
244
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
@@ -250,7 +257,14 @@ function routeComment(subset, triggers, knownFlows) {
250
257
  }
251
258
  const phrase = triggers.comment?.phrase;
252
259
  const body = subset.comment?.body;
253
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
260
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
261
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
262
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
263
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
264
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
265
+ // worker refuses the job before any spend unless the key's `parked` sidecar exists.
266
+ const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.issue?.pull_request !== true && hasParkedLabel(subset.issue?.labels);
267
+ if (!phraseHit && !parkedReply) {
254
268
  return { enqueue: false, reason: "no-trigger-phrase" };
255
269
  }
256
270
  // On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
@@ -293,8 +307,10 @@ function routeComment(subset, triggers, knownFlows) {
293
307
  waitFor: triggers.comment.waitFor,
294
308
  instructions: triggers.comment.instructions,
295
309
  resume: triggers.comment.resume,
310
+ awaitInput: triggers.comment.awaitInput,
296
311
  replicas: triggers.comment.replicas,
297
312
  matched: { index: triggers.comment.index, type: "comment", phrase },
313
+ ...(parkedReply && { parkedReply: true }),
298
314
  // The invoking comment rides on the trigger: body and author_association are both named by
299
315
  // INT-WEBHOOK-PAYLOAD-SUBSET, and the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
300
316
  comment: { body: subset.comment.body, author_association: subset.comment.author_association },
@@ -392,6 +408,7 @@ function routePullRequest(subset, triggers, action) {
392
408
  waitFor: rule.waitFor,
393
409
  instructions: rule.instructions,
394
410
  resume: rule.resume,
411
+ awaitInput: rule.awaitInput,
395
412
  replicas: rule.replicas,
396
413
  matched: { index: rule.index, type: "pull_request", action },
397
414
  ...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
@@ -509,6 +526,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
509
526
  waitFor: rule.waitFor,
510
527
  instructions: rule.instructions,
511
528
  resume: rule.resume,
529
+ awaitInput: rule.awaitInput,
512
530
  replicas: rule.replicas,
513
531
  matched: matchedFor(rule),
514
532
  target: targetFor(),