forgedock 1.3.0 → 1.5.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/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
package/bin/engine/phases.mjs
CHANGED
|
@@ -5,7 +5,29 @@
|
|
|
5
5
|
* @typedef ... (see plan "Shared types")
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
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.
|
|
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
|
|
57
|
-
* be — the very next attempt could see a
|
|
58
|
-
* no external input having changed, so it
|
|
59
|
-
* only compare `> 0` (reconcile()'s
|
|
60
|
-
* still not `> 0`, so existing
|
|
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(
|
|
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,
|
|
215
|
+
if (has(blob, PHASE_MARKERS.investigate.invalidMarker))
|
|
159
216
|
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
160
|
-
if (has(blob,
|
|
217
|
+
if (has(blob, PHASE_MARKERS.investigate.decomposedMarker))
|
|
161
218
|
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
162
|
-
if (has(blob,
|
|
219
|
+
if (has(blob, PHASE_MARKERS.investigate.completionMarker))
|
|
163
220
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
164
|
-
return { status: "failed", detail:
|
|
221
|
+
return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
|
|
165
222
|
},
|
|
166
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
281
|
+
return has(blob, PHASE_MARKERS.architect.completionMarker)
|
|
198
282
|
? { status: "committed", outputs: {} }
|
|
199
|
-
: { status: "failed", detail:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|