forgedock 1.3.0 → 1.4.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 (45) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. package/scripts/forge-run.sh +35 -4
@@ -5,7 +5,29 @@
5
5
  * @typedef ... (see plan "Shared types")
6
6
  */
7
7
 
8
- export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
8
+ // forge#2378: marker/label strings are single-sourced from packages/protocol's
9
+ // phase registry (itself derived from RESERVED_TYPES' completionSentinel fields
10
+ // where available) — do NOT reintroduce inline "FORGE:..."/"INVESTIGATION:..."/
11
+ // "workflow:merged" literals in this file. bin/hooks/interactive-engine.mjs
12
+ // imports the identical registry, so the two can no longer drift apart the way
13
+ // they did in forge#2375/PR#2395.
14
+ import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
15
+
16
+ // forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
17
+ // failures (e.g. an exhausted retry loop where the runner itself never once
18
+ // succeeded, or a fail-fast CLI_BACKEND_FAILED/NO_API_KEY/NO_SDK throw) — kept
19
+ // separate from "needs-human" so it is never misclassified as a genuine
20
+ // human-judgment block by /orchestrate's classify_predecessor_state().
21
+ //
22
+ // forge#2379: "awaiting-merge" mirrors the `workflow:awaiting-merge` label
23
+ // commands/work-on/remediate.md's Phase M8 sets on a HELD-AWAITING-MERGE
24
+ // re-gate outcome (a clean re-review that didn't clear the #1809 Q1 auto-land
25
+ // bar) — already recognized as a terminal state by work-on.md's Universal
26
+ // Phase Dispatcher; this just gives the `remediate` phase's detectOutcome a
27
+ // matching engine-level terminal reason to report instead of overloading
28
+ // "needs-human" (which would misrepresent a clean-but-unmet-bar re-review as
29
+ // a fresh human-judgment escalation).
30
+ export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge"];
9
31
 
10
32
  /**
11
33
  * Fetch the issue's comments. Returns both:
@@ -50,14 +72,19 @@ async function issueMarkers(issue, io) {
50
72
  *
51
73
  * Returns -1 (rather than 0) when the underlying `git` call itself failed
52
74
  * (lock contention, transient I/O error, ref not yet fetched, etc.) — distinct
53
- * from a genuine, successfully-computed 0. This distinction matters to the
75
+ * from a genuine, successfully-computed 0. More generally, -1 means "this
76
+ * count was not computed" — that includes both a git failure here AND a
77
+ * caller that had no resolvable branch to check in the first place (forge#2211:
78
+ * `detectOutcome` mirrors this same -1 sentinel for its unresolved-branch case
79
+ * rather than synthesizing its own 0). This distinction matters to the
54
80
  * build phase's `detectOutcome` (forge#2176): a *genuine* 0 ahead (git ran
55
81
  * cleanly and reported no new commits) is a stable fixed point safe to mark
56
- * non-retryable, but a transient git error folded into the same 0 would not
57
- * be — the very next attempt could see a different, non-erroring result with
58
- * no external input having changed, so it must remain retryable. Callers that
59
- * only compare `> 0` (reconcile()'s satisfied check) are unaffected: -1 is
60
- * still not `> 0`, so existing behavior there is unchanged.
82
+ * non-retryable, but a transient git error — or a not-yet-resolved branch —
83
+ * folded into the same 0 would not be — the very next attempt could see a
84
+ * different, computed result with no external input having changed, so it
85
+ * must remain retryable. Callers that only compare `> 0` (reconcile()'s
86
+ * satisfied check) are unaffected: -1 is still not `> 0`, so existing
87
+ * behavior there is unchanged.
61
88
  */
62
89
  async function commitsAhead(lane, branch, io) {
63
90
  try {
@@ -89,6 +116,36 @@ async function commitsAhead(lane, branch, io) {
89
116
  */
90
117
  function has(blob, marker) { return blob.includes(marker); }
91
118
 
119
+ /**
120
+ * Fetch the issue's live `state` (OPEN/CLOSED) and `labels` in one call.
121
+ *
122
+ * This is the single data source for two consumers (forge#2352):
123
+ * - the `close` phase's `reconcile`/`detectOutcome` below (which already made
124
+ * this exact call inline before this helper existed — factored out here so
125
+ * both call sites share one shape instead of drifting independently);
126
+ * - the divergence guard in `bin/engine.mjs`'s `runIssue()` phase loop, which
127
+ * calls this once per loop iteration (before running any phase other than
128
+ * `close`) to detect an issue that was closed / labeled `workflow:invalid`
129
+ * / labeled `needs-human` out from under an in-flight run.
130
+ *
131
+ * Returns `{ ok: false, state: null, labels: [] }` on any fetch/parse failure
132
+ * — callers must treat `ok: false` as "could not determine, do not act on
133
+ * this" rather than "issue has no labels/is not closed". This mirrors the
134
+ * existing fail-open behavior `close`'s `reconcile` already had (a `gh`
135
+ * failure there degrades to "not satisfied", never to a false positive).
136
+ */
137
+ export async function issueSnapshot(issue, io) {
138
+ const out = await io.gh(["issue", "view", String(issue), "--json", "state,labels"]);
139
+ let j;
140
+ try {
141
+ j = JSON.parse(out || "{}");
142
+ } catch {
143
+ return { ok: false, state: null, labels: [] };
144
+ }
145
+ const labels = (j.labels || []).map((l) => l.name || l);
146
+ return { ok: true, state: j.state || null, labels };
147
+ }
148
+
92
149
  /**
93
150
  * Parse the real branch name out of the `FORGE:BUILDER` comment's
94
151
  * `**Branch**: \`{BRANCH}\`` field (see `commands/work-on/build/implement.md`
@@ -129,7 +186,7 @@ function parseBranchFromMarkers(comments) {
129
186
  const re = /\*\*Branch\*\*:\s*`([^`]+)`/;
130
187
  for (let i = comments.length - 1; i >= 0; i--) {
131
188
  const body = comments[i];
132
- if (!body || !body.includes("FORGE:BUILDER:COMPLETE")) continue;
189
+ if (!body || !body.includes(PHASE_MARKERS.build.completionMarker)) continue;
133
190
  const match = body.match(re);
134
191
  if (match) return match[1];
135
192
  }
@@ -155,15 +212,42 @@ export const PHASES = [
155
212
  entryCondition: () => true,
156
213
  async detectOutcome(state, io) {
157
214
  const { blob } = await issueMarkers(state.issue, io);
158
- if (has(blob, "INVESTIGATION:INVALID"))
215
+ if (has(blob, PHASE_MARKERS.investigate.invalidMarker))
159
216
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
160
- if (has(blob, "DECOMPOSE:YES"))
217
+ if (has(blob, PHASE_MARKERS.investigate.decomposedMarker))
161
218
  return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
162
- if (has(blob, "INVESTIGATION:COMPLETE"))
219
+ if (has(blob, PHASE_MARKERS.investigate.completionMarker))
163
220
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
164
- return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
221
+ return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
165
222
  },
166
- isTerminalAfter: (s) => s.terminalReason === "invalid" || s.terminalReason === "decomposed",
223
+ // forge#2379: no longer terminal after "decomposed" — that reason now
224
+ // hands off to the "decompose" phase below (see bin/engine.mjs's
225
+ // runIssue(), which special-cases exactly this phase/reason combination
226
+ // to skip its own immediate-terminate check and let pickPhase run again).
227
+ // "invalid" is unaffected — investigate.md never posts anything further
228
+ // after INVESTIGATION:INVALID, so that path still terminates in place.
229
+ isTerminalAfter: (s) => s.terminalReason === "invalid",
230
+ },
231
+ {
232
+ // forge#2379: "decompose" was previously only a terminal reason on
233
+ // investigate (the run stopped the instant DECOMPOSE:YES was seen) —
234
+ // work-on/decompose (sub-issue fan-out, FORGE:DECOMPOSED posting) was
235
+ // never actually dispatched by the engine. This phase closes that gap:
236
+ // entryCondition fires exactly when investigate's own outcome signaled
237
+ // decompose, so pickPhase now genuinely dispatches work-on/decompose
238
+ // before the run terminates.
239
+ id: "decompose",
240
+ command: "work-on/decompose",
241
+ entryCondition: (s) => s.terminalReason === "decomposed",
242
+ async detectOutcome(state, io) {
243
+ const { blob } = await issueMarkers(state.issue, io);
244
+ if (has(blob, PHASE_MARKERS.decompose.completionMarker))
245
+ return { status: "committed", terminalReason: "decomposed", outputs: {} };
246
+ return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
247
+ },
248
+ // Always terminal: decomposition spawns independent sub-issues, each of
249
+ // which runs its own /work-on pipeline — nothing more for THIS run to do.
250
+ isTerminalAfter: () => true,
167
251
  },
168
252
  {
169
253
  id: "context",
@@ -173,12 +257,12 @@ export const PHASES = [
173
257
  // Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
174
258
  // Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
175
259
  const { blob } = await issueMarkers(state.issue, io);
176
- return has(blob, "FORGE:CONTEXT:COMPLETE") ? { satisfied: true } : { satisfied: false };
260
+ return has(blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
177
261
  },
178
262
  async detectOutcome(state, io) {
179
263
  const { blob } = await issueMarkers(state.issue, io);
180
264
  // Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
181
- if (has(blob, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
265
+ if (has(blob, PHASE_MARKERS.context.presenceMarker)) return { status: "committed", outputs: {} };
182
266
  return { status: "committed", outputs: { skipped: true, which: "context" } };
183
267
  },
184
268
  },
@@ -190,13 +274,13 @@ export const PHASES = [
190
274
  // Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
191
275
  // Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
192
276
  const { blob } = await issueMarkers(state.issue, io);
193
- return has(blob, "FORGE:ARCHITECT:COMPLETE") ? { satisfied: true } : { satisfied: false };
277
+ return has(blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
194
278
  },
195
279
  async detectOutcome(state, io) {
196
280
  const { blob } = await issueMarkers(state.issue, io);
197
- return has(blob, "FORGE:ARCHITECT:COMPLETE")
281
+ return has(blob, PHASE_MARKERS.architect.completionMarker)
198
282
  ? { status: "committed", outputs: {} }
199
- : { status: "failed", detail: "no FORGE:ARCHITECT:COMPLETE" };
283
+ : { status: "failed", detail: `no ${PHASE_MARKERS.architect.completionMarker}` };
200
284
  },
201
285
  },
202
286
  {
@@ -209,19 +293,24 @@ export const PHASES = [
209
293
  // check it's already ahead of base → treat as done, skip the LLM (forge#2174).
210
294
  const { blob, comments } = await issueMarkers(state.issue, io);
211
295
  const branch = resolveBranch(state, comments);
212
- if (branch && has(blob, "FORGE:BUILDER:COMPLETE") && (await commitsAhead(state.lane, branch, io)) > 0) {
296
+ if (branch && has(blob, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
213
297
  return { satisfied: true, outputs: { branch } };
214
298
  }
215
299
  return { satisfied: false };
216
300
  },
217
301
  async detectOutcome(state, io) {
218
302
  const { blob, comments } = await issueMarkers(state.issue, io);
219
- const complete = has(blob, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
303
+ const complete = has(blob, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
220
304
  // Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
221
305
  // comment (ground truth), scoped to that specific comment — see
222
306
  // resolveBranch()/parseBranchFromMarkers() above (forge#2174, forge#2184).
223
307
  const branch = resolveBranch(state, comments);
224
- const ahead = branch ? await commitsAhead(state.lane, branch, io) : 0; // … AND real commits
308
+ // forge#2211: an unresolved branch means the commit count was never
309
+ // computed at all — mirror commitsAhead()'s own "-1 = not computed"
310
+ // sentinel here instead of synthesizing a `0`, which is indistinguishable
311
+ // from a genuine git-confirmed zero and would wrongly trip the
312
+ // non-retryable guard below on the very first attempt.
313
+ const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
225
314
  if (complete && ahead > 0) return { status: "committed", outputs: { branch } };
226
315
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
227
316
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
@@ -241,10 +330,14 @@ export const PHASES = [
241
330
  // retry, so this branch intentionally leaves `retryable` unset
242
331
  // (defaults to retryable in bin/engine.mjs's runPhaseWithRetry).
243
332
  //
244
- // `ahead === -1` means commitsAhead() itself failed (transient git
245
- // error), NOT a confirmed zero — that is exactly the kind of failure
246
- // a retry might resolve, so it must stay retryable too. Only a
247
- // successfully-computed ahead of 0 (a real "nothing new to commit"
333
+ // `ahead === -1` means the count was never computed — either
334
+ // commitsAhead() itself failed (transient git error) or the branch
335
+ // could not be resolved at all (forge#2211: `resolveBranch()` returned
336
+ // null, e.g. on the very first build attempt before any
337
+ // FORGE:BUILDER:COMPLETE comment names a branch). Neither is a
338
+ // confirmed zero — both are exactly the kind of failure a retry might
339
+ // resolve, so both must stay retryable. Only a successfully-computed
340
+ // ahead of 0 on a *resolved* branch (a real "nothing new to commit"
248
341
  // result) is the true fixed point this non-retryable signal targets.
249
342
  if (complete && ahead !== -1) return { status: "failed", detail, retryable: false };
250
343
  return { status: "failed", detail };
@@ -266,27 +359,100 @@ export const PHASES = [
266
359
  return { status: "failed", detail: "PR open, not merged" };
267
360
  },
268
361
  },
362
+ {
363
+ // forge#2379: `remediate` re-drives a needs-human PR via
364
+ // commands/work-on/remediate.md (checkout → classify FIXABLE/UNFIXABLE →
365
+ // fix → quality-gate → re-review → #1809 auto-land bar → merge-or-hold),
366
+ // finishing with a `FORGE:REMEDIATION`/`FORGE:REMEDIATION:COMPLETE`
367
+ // marker posted to BOTH the PR and this issue (Phase M8), carrying a
368
+ // `**Re-gate outcome**: AUTO-LANDED | HELD-AWAITING-MERGE | RE-ESCALATED
369
+ // | UNFIXABLE` field. This entry registers that outcome vocabulary in the
370
+ // phase table (closing the literal "remediate appears nowhere in the
371
+ // engine" gap) and is fully unit-tested via `pickPhase`/`detectOutcome`.
372
+ //
373
+ // KNOWN LIMITATION (documented, not fixed here — see forge#2379
374
+ // investigation "What We Found"): `review`'s `"blocked"` outcome (the
375
+ // needs-human escalation) causes `bin/engine.mjs`'s `runIssue()` to
376
+ // `terminate()` immediately, before `review` is ever added to
377
+ // `state.committed` — and the divergence guard just above that also
378
+ // pauses before any non-`close` phase once the issue carries
379
+ // `needs-human`. So a single continuous `runIssue()` walk cannot reach
380
+ // this phase's `entryCondition` today. In practice `remediate.md` is
381
+ // (correctly, per `work-on.md` Phase 0A.1) invoked as its own separate
382
+ // top-level entry point (`/work-on <pr> --remediate`), not as a
383
+ // continuation of the original run — this phase entry documents and
384
+ // tests the target state shape for a future run that reconstructs
385
+ // `committed`/`terminalReason` from live GitHub state (e.g. a dedicated
386
+ // remediation-run entry point) rather than from a fresh local run-log.
387
+ // Making that live wiring real is out of this issue's scope — it needs
388
+ // changes to `review`'s `"blocked"` contract and to `bin/tests/engine.test.mjs`,
389
+ // both outside this issue's declared file set.
390
+ id: "remediate",
391
+ command: "work-on/remediate",
392
+ entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
393
+ async detectOutcome(state, io) {
394
+ const { blob } = await issueMarkers(state.issue, io);
395
+ if (!has(blob, PHASE_MARKERS.remediate.completionMarker))
396
+ return { status: "failed", detail: `no ${PHASE_MARKERS.remediate.completionMarker} marker` };
397
+ // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
398
+ // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
399
+ // first whitespace-delimited token after the colon).
400
+ const match = blob.match(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/);
401
+ const reGateOutcome = match ? match[1] : null;
402
+ switch (reGateOutcome) {
403
+ case "AUTO-LANDED":
404
+ // remediate.md's own Phase M8 already drove close in this case
405
+ // (see that file's "If the outcome was AUTO-LANDED" branch) — the
406
+ // issue should already carry workflow:merged by the time this
407
+ // reads, but the terminal reason here is what THIS phase reports,
408
+ // independent of close's own idempotent detectOutcome re-check.
409
+ return { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } };
410
+ case "HELD-AWAITING-MERGE":
411
+ return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
412
+ case "RE-ESCALATED":
413
+ case "UNFIXABLE":
414
+ // Both leave the issue at needs-human (a fresh escalation, or a
415
+ // policy judgment call respectively) — reuse the existing
416
+ // "needs-human" terminal reason rather than inventing two more,
417
+ // matching the #2352/#2353 precedent of reusing an existing
418
+ // TERMINAL_REASONS value where semantically equivalent.
419
+ return { status: "committed", terminalReason: "needs-human", outputs: { reGateOutcome } };
420
+ default:
421
+ return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
422
+ }
423
+ },
424
+ isTerminalAfter: () => true,
425
+ },
269
426
  {
270
427
  id: "close",
271
428
  command: "work-on/close",
272
429
  entryCondition: (s) => s.committed.includes("review"),
273
430
  async reconcile(state, io) {
274
431
  // Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
275
- const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
276
- let j;
277
- try { j = JSON.parse(out || "{}"); } catch { return { satisfied: false }; }
278
- const labels = (j.labels || []).map((l) => l.name || l);
279
- return (j.state === "CLOSED" || labels.includes("workflow:merged"))
432
+ const snap = await issueSnapshot(state.issue, io);
433
+ if (!snap.ok) return { satisfied: false };
434
+ return (snap.state === "CLOSED" || snap.labels.includes(PHASE_MARKERS.close.completionLabel))
280
435
  ? { satisfied: true }
281
436
  : { satisfied: false };
282
437
  },
283
438
  async detectOutcome(state, io) {
284
- const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
285
- let j;
286
- try { j = JSON.parse(out || "{}"); } catch { return { status: "failed", detail: "malformed gh response" }; }
287
- const labels = (j.labels || []).map((l) => l.name || l);
288
- if (j.state === "CLOSED" || labels.includes("workflow:merged"))
439
+ const snap = await issueSnapshot(state.issue, io);
440
+ if (!snap.ok) return { status: "failed", detail: "malformed gh response" };
441
+ // forge#2353: a bare `state === "CLOSED"` is NOT sufficient evidence that
442
+ // a PR actually merged — the divergence guard in bin/engine.mjs (forge#2352)
443
+ // can now route a closed-as-invalid or otherwise closed-not-merged issue
444
+ // into this phase (see that guard's own comment for why `close` is exempt
445
+ // from it), and reporting `terminalReason: "merged"` for that case would
446
+ // inflate run-log/telemetry merge-rate consumers with runs that never
447
+ // shipped a PR. Only `workflow:merged` — the label the review phase's own
448
+ // merge flow sets — is proof of an actual merge. A CLOSED issue without
449
+ // that label is still a real terminal state (nothing left for this phase
450
+ // to do), just not a "merged" one — reuse the existing "invalid" reason
451
+ // (already in TERMINAL_REASONS) rather than inventing a new one.
452
+ if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
289
453
  return { status: "committed", terminalReason: "merged", outputs: {} };
454
+ if (snap.state === "CLOSED")
455
+ return { status: "committed", terminalReason: "invalid", outputs: {} };
290
456
  return { status: "failed", detail: "issue not closed" };
291
457
  },
292
458
  isTerminalAfter: () => true,
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Issue-set resolution predicate model — standing-query re-resolution (forge#2236).
3
+ *
4
+ * `/orchestrate`'s Phase 1 (`commands/orchestrate/phase-1-resolve.md`) turns
5
+ * `$ARGUMENTS` into a concrete list of issue numbers exactly once, at T0, and
6
+ * that list is frozen for the rest of the run (`commands/orchestrate/phase-4-execution.md`
7
+ * states this plainly: "Phase 1 only runs once, at the start."). Of the 8
8
+ * input patterns Phase 1 accepts, only one (`#1 #2 #3` — an explicit literal
9
+ * set) is actually a one-time snapshot by nature. The other 7
10
+ * (`milestone <slug>`, `next <N>`, `next <N> all-repos`, `fast-lane`,
11
+ * `priority:P0`/`priority:P1`/..., `mcp:fast`/`n8n:next 3`, a bare `<slug>`)
12
+ * are standing queries/predicates — "work all P0s", "work this milestone" —
13
+ * and a new issue matching that predicate mid-run is currently invisible to
14
+ * the DAG until the operator re-invokes `/orchestrate` by hand.
15
+ *
16
+ * This module is the typed reference implementation for two decisions Phase 1
17
+ * and Phase 4 need to make about that gap:
18
+ *
19
+ * 1. `classifyInputPattern` — is this input a `literal` set (never
20
+ * re-resolve — the list of numbers IS the intent) or a `query`
21
+ * (re-resolve periodically — the predicate is the intent)?
22
+ * 2. `shouldReResolve` — given that classification and the run's
23
+ * `orchestration.reresolve` config, should Phase 4's Step 4B loop
24
+ * actually re-run the query this round?
25
+ * 3. `foldNewMatches` — of the issue numbers a re-resolution just
26
+ * returned, which ones are genuinely new (not already in this run's
27
+ * processed-issue registry) and therefore candidates to fold into the
28
+ * live DAG?
29
+ *
30
+ * As with `bin/engine/admission.mjs` (forge#2234), this module does not
31
+ * itself shell out to `gh`/`yq` — the orchestrator is LLM-executed prose,
32
+ * not a `bin/engine/` call site, so `commands/orchestrate/phase-1-resolve.md`
33
+ * and `commands/orchestrate/phase-4-execution.md` mirror these rules by hand
34
+ * in bash. Any change to the classification table or defaults here MUST be
35
+ * mirrored there too (see forge#1837 — mirrored-logic drift between grep ERE
36
+ * and a typed reference is a recurring review-finding class in this pair of
37
+ * files).
38
+ *
39
+ * Hard invariant (by design, not configurable): `foldNewMatches` only ever
40
+ * narrows a re-resolved set down to "not yet processed" — it does not itself
41
+ * decide admission. Every folded-in issue (`newMatches`) MUST still be
42
+ * dispatched through the exact same path a T0-resolved issue takes —
43
+ * standard DAG dependency analysis (`phase-3-dependency.md`) followed by
44
+ * Step 4A/4B's `dispatch_headroom`-gated dispatch (see
45
+ * `phase-4-execution.md` Step 4B.6) — before an agent is dispatched on it.
46
+ * This is deliberately **not** Step 4C's `admission.mjs`'s
47
+ * `evaluateCascadeFinding` chain: that gate's rules (comment/typo keyword
48
+ * heuristic, P3+same-file defer, generation cap) are shaped for
49
+ * cascade-spawned review-findings — a different issue-origin stream — and
50
+ * would incorrectly restrict legitimate re-resolved standing-query issues
51
+ * if applied here. A mid-run check that detects new work but never
52
+ * actually wires into dispatch is exactly the dead-code failure class
53
+ * documented in forge#1832 (`SURFACE_BATCHED_FINDINGS`) — this module must
54
+ * not become a second instance of it.
55
+ */
56
+
57
+ /**
58
+ * @typedef {"literal"|"query"} InputKind
59
+ */
60
+
61
+ /**
62
+ * @typedef {Object} ClassifiedInput
63
+ * @property {InputKind} kind
64
+ * @property {string} pattern - Canonical name of the matched pattern, e.g.
65
+ * "literal-numbers", "milestone", "next-n", "next-n-all-repos",
66
+ * "fast-lane", "priority", "repo-scoped", "bare-slug".
67
+ * @property {string[]} args - The raw argument tokens (excluding the pattern
68
+ * keyword itself) that produced this classification, preserved so a
69
+ * re-resolution round can replay the exact same query. Exceptions:
70
+ * `priority` and `repo-scoped` keep the FULL token list instead of
71
+ * slicing off a leading keyword token — for those two patterns the
72
+ * pattern keyword is fused into the same token as its argument
73
+ * (`priority:P0`, `mcp:fast-lane`, `n8n:next`), so there is no separable
74
+ * leading keyword token to drop the way there is for `milestone <slug>`
75
+ * or `next <N>`. Slicing here would destroy the only copy of the data a
76
+ * re-resolution round needs to replay the query.
77
+ */
78
+
79
+ /**
80
+ * Classify a raw `/orchestrate` input string per the Phase 1 input-pattern
81
+ * table (`commands/orchestrate/phase-1-resolve.md` "Input Patterns").
82
+ *
83
+ * Only an explicit list of bare/`#`-prefixed, optionally repo-prefixed issue
84
+ * numbers (`#1 #2 #3`, `1 2 3`, `#123 mcp:5 n8n:12`) classifies as `literal`.
85
+ * Every other recognized pattern is a `query` — the predicate, not the
86
+ * resolved list, is the caller's actual intent, per the issue's finding that
87
+ * a query re-run mid-batch is what a standing predicate like `priority:P0`
88
+ * or `fast-lane` implies. An input that matches no known pattern still
89
+ * classifies as `query` with `pattern: "unknown"` — unrecognized input is
90
+ * handed to Phase 1's existing milestone/label fallback resolution, which is
91
+ * itself a query (bare `<slug>` row), not a literal set.
92
+ *
93
+ * @param {string} input - Raw `$ARGUMENTS` string (already trimmed).
94
+ * @returns {ClassifiedInput}
95
+ */
96
+ export function classifyInputPattern(input) {
97
+ const trimmed = (input ?? "").trim();
98
+ const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
99
+
100
+ // literal-numbers: every token is a bare integer or a (optionally
101
+ // repo-prefixed) `#`-prefixed integer, e.g. "1 2 3", "#1 #2 #3",
102
+ // "#123 mcp:5 n8n:12". At least one token is required — empty input is
103
+ // not a literal set, it falls through to "unknown".
104
+ const literalNumberToken = /^([a-zA-Z0-9_-]+:)?#?\d+$/;
105
+ if (tokens.length > 0 && tokens.every((t) => literalNumberToken.test(t))) {
106
+ return { kind: "literal", pattern: "literal-numbers", args: tokens };
107
+ }
108
+
109
+ const lower = trimmed.toLowerCase();
110
+
111
+ if (/^milestone\s+/.test(lower)) {
112
+ return { kind: "query", pattern: "milestone", args: tokens.slice(1) };
113
+ }
114
+
115
+ if (/^next\s+\d+\s+all-repos$/.test(lower)) {
116
+ return { kind: "query", pattern: "next-n-all-repos", args: tokens.slice(1) };
117
+ }
118
+
119
+ if (/^next\s+\d+$/.test(lower)) {
120
+ return { kind: "query", pattern: "next-n", args: tokens.slice(1) };
121
+ }
122
+
123
+ if (lower === "fast-lane" || lower === "fast") {
124
+ return { kind: "query", pattern: "fast-lane", args: [] };
125
+ }
126
+
127
+ if (/^priority:p\d+$/.test(lower) || /^[a-zA-Z0-9_-]+:priority:p\d+$/.test(lower)) {
128
+ return { kind: "query", pattern: "priority", args: tokens };
129
+ }
130
+
131
+ // repo-scoped queries: "mcp:fast", "n8n:next 3", "mcp:cascade", etc. — a
132
+ // repo prefix followed by a colon and a recognized query keyword. Checked
133
+ // after the more specific patterns above so e.g. "mcp:5" (a repo-prefixed
134
+ // literal number) is not mis-classified here — literalNumberToken already
135
+ // claimed that shape.
136
+ if (/^[a-zA-Z0-9_-]+:(fast-lane|fast|next|cascade|review-findings|findings)\b/.test(lower)) {
137
+ return { kind: "query", pattern: "repo-scoped", args: tokens };
138
+ }
139
+
140
+ if (/^(cascade|review-findings|findings)\b/.test(lower)) {
141
+ return { kind: "query", pattern: "cascade", args: tokens.slice(1) };
142
+ }
143
+
144
+ if (tokens.length === 1 && tokens[0].length > 0) {
145
+ // bare `<slug>` — try milestone first, then label search (Phase 1's own
146
+ // fallback order). Still a query: the slug's membership can change.
147
+ return { kind: "query", pattern: "bare-slug", args: tokens };
148
+ }
149
+
150
+ return { kind: "query", pattern: "unknown", args: tokens };
151
+ }
152
+
153
+ /**
154
+ * @typedef {Object} ReResolveConfig
155
+ * @property {boolean|string} [enabled] - Explicit off switch. `false` or the
156
+ * string `"off"` (case-insensitive) disables re-resolution regardless of
157
+ * `kind`. Absent/`undefined`/`true`/`"on"` leaves the default (on for
158
+ * query-kind input) in effect. This mirrors the validate-warn-fall-back
159
+ * idiom's spirit but re-resolution has only two valid states, so an
160
+ * unrecognized value is treated as `true` (default-on) rather than an
161
+ * error — the safer default in this context is "keep the existing
162
+ * behavior of periodic re-check", not "silently stop tracking a standing
163
+ * predicate".
164
+ * @property {number} [maxRounds] - Bound on how many times a single run may
165
+ * re-resolve the same predicate. Required to satisfy the issue's
166
+ * "termination is bounded" acceptance criterion — a standing predicate
167
+ * that keeps matching must not re-resolve forever. `undefined`/absent
168
+ * means "no additional bound beyond the caller's own token-budget /
169
+ * generation caps" (those are enforced downstream, by `admission.mjs`,
170
+ * not here).
171
+ */
172
+
173
+ /**
174
+ * Decide whether Phase 4's Step 4B loop should re-run the originating query
175
+ * this round.
176
+ *
177
+ * @param {ClassifiedInput} classified - Result of `classifyInputPattern`.
178
+ * @param {ReResolveConfig} [config]
179
+ * @param {number} [roundsSoFar] - How many re-resolution rounds have already
180
+ * run this batch (0 on the first call).
181
+ * @returns {{ reResolve: boolean, reason: string }}
182
+ */
183
+ export function shouldReResolve(classified, config = {}, roundsSoFar = 0) {
184
+ if (classified.kind === "literal") {
185
+ return {
186
+ reResolve: false,
187
+ reason: "literal issue-number set — the list IS the intent, never re-resolve",
188
+ };
189
+ }
190
+
191
+ const enabledRaw = config.enabled;
192
+ // The `"off"` string branch IS reachable via the real `yq`-based bash
193
+ // mirror in `phase-4-execution.md` Step 4B.6 — not just from direct JS
194
+ // callers (e.g. this file's own unit tests). `yq`'s default core schema
195
+ // only coerces the literal scalars `true`/`false` to booleans; an
196
+ // unquoted `enabled: off` in forge.yaml is parsed as the plain string
197
+ // `"off"` (verified: `yq '.a.enabled' <<< 'a: {enabled: off}'` prints
198
+ // `off`, `... | type` prints `!!str`), which then survives
199
+ // `process.argv[3] === "false" ? false : process.argv[3]` unchanged and
200
+ // reaches this branch as the string `"off"`. Only `enabled: false`
201
+ // (unquoted) is coerced by `yq` to boolean `false` before this code
202
+ // runs. Do not remove this branch as unreachable dead code.
203
+ const disabled =
204
+ enabledRaw === false || (typeof enabledRaw === "string" && enabledRaw.trim().toLowerCase() === "off");
205
+ if (disabled) {
206
+ return { reResolve: false, reason: "orchestration.reresolve.enabled is off" };
207
+ }
208
+
209
+ if (typeof config.maxRounds === "number" && Number.isFinite(config.maxRounds) && roundsSoFar >= config.maxRounds) {
210
+ return {
211
+ reResolve: false,
212
+ reason: `orchestration.reresolve.max_rounds (${config.maxRounds}) reached — bounded termination`,
213
+ };
214
+ }
215
+
216
+ return {
217
+ reResolve: true,
218
+ reason: `query pattern "${classified.pattern}" is a standing predicate — re-resolving`,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Narrow a freshly re-resolved issue-number list down to genuinely new
224
+ * issues — ones not already present in the run's processed-issue registry
225
+ * (T0-resolved issues plus any already admitted in a prior re-resolution
226
+ * round). This function does NOT decide admission; every returned number
227
+ * (`newMatches`) must still be dispatched through the same path a
228
+ * T0-resolved issue takes — standard DAG dependency analysis followed by
229
+ * Step 4A/4B's dispatch (`phase-4-execution.md` Step 4B.6) — NOT through
230
+ * Step 4C's `admission.mjs`'s `evaluateCascadeFinding` chain, which is
231
+ * reserved for the cascade-spawned review-finding stream — see module
232
+ * docstring's "Hard invariant".
233
+ *
234
+ * @param {number[]} reResolvedNumbers - Issue numbers the query returned this round.
235
+ * @param {Iterable<number>} processedRegistry - Issue numbers already
236
+ * resolved at T0 or admitted in an earlier re-resolution round this run.
237
+ * @returns {{ newMatches: number[], alreadyProcessed: number[] }}
238
+ */
239
+ export function foldNewMatches(reResolvedNumbers, processedRegistry) {
240
+ const processed = new Set(processedRegistry);
241
+ const newMatches = [];
242
+ const alreadyProcessed = [];
243
+ const seenThisCall = new Set();
244
+
245
+ for (const n of reResolvedNumbers ?? []) {
246
+ if (seenThisCall.has(n)) continue; // de-dupe within the same re-resolution result
247
+ seenThisCall.add(n);
248
+ if (processed.has(n)) {
249
+ alreadyProcessed.push(n);
250
+ } else {
251
+ newMatches.push(n);
252
+ }
253
+ }
254
+
255
+ return { newMatches, alreadyProcessed };
256
+ }