forgedock 1.10.3 → 1.12.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/engine/phases-args.test.mjs +112 -0
- package/bin/engine/phases.mjs +152 -45
- package/bin/engine/runlog.mjs +9 -0
- package/bin/engine-cli.mjs +13 -2
- package/bin/engine.mjs +144 -18
- package/bin/forgedock.mjs +2 -2
- package/bin/runner.mjs +89 -12
- package/commands/ci-audit.md +3 -3
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-pr.md +6 -6
- package/commands/explain.md +2 -2
- package/commands/fix-ci.md +2 -2
- package/commands/forge-stats.md +1 -1
- package/commands/forgedock-init.md +3 -3
- package/commands/optimize.md +5 -5
- package/commands/orchestrate/phase-1-resolve.md +2 -2
- package/commands/orchestrate/phase-4-execution.md +208 -48
- package/commands/orchestrate/phase-6-report.md +29 -0
- package/commands/quality-gate.md +97 -13
- package/commands/recover-orphans.md +6 -6
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-staging.md +28 -6
- package/commands/review-pr.md +509 -132
- package/commands/security-audit.md +1 -1
- package/commands/spec-doctor.md +2 -2
- package/commands/test-gate.md +7 -7
- package/commands/work-on/build/architect.md +21 -0
- package/commands/work-on/build/context.md +1 -1
- package/commands/work-on/build/implement.md +17 -0
- package/commands/work-on/build/validate.md +60 -9
- package/commands/work-on/build.md +98 -9
- package/commands/work-on/close.md +33 -11
- package/commands/work-on/decompose.md +19 -5
- package/commands/work-on/investigate.md +34 -4
- package/commands/work-on/remediate.md +117 -13
- package/commands/work-on/review.md +66 -26
- package/commands/work-on.md +32 -15
- package/docs/CONFIG.md +30 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/WORK-ON-RUNTIME.md +24 -4
- package/docs/site/architecture.md +1 -1
- package/docs/spec/forge-protocol-v1.md +10 -0
- package/docs/spec/review-protocol.md +1 -0
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -1
- package/packages/protocol/src/types.js +17 -0
- package/scripts/check-branch-ancestry.sh +82 -0
- package/scripts/check-branch-ancestry.test.sh +78 -0
- package/scripts/check-spec-bash.sh +45 -2
- package/scripts/check-spec-bash.test.sh +24 -1
- package/scripts/check-spec-markers.sh +55 -0
- package/scripts/diff-size.sh +84 -0
- package/scripts/diff-size.test.sh +117 -0
- package/scripts/forge-root.test.sh +16 -1
- package/scripts/remediation-marker-match.test.sh +51 -0
- package/scripts/review-pr-arguments.test.sh +180 -0
- package/scripts/spec-no-worktree-script.test.sh +46 -0
- package/scripts/trusted-comments.test.sh +23 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Contract test (forge#3499): the arguments each engine phase passes to its
|
|
2
|
+
// work-on sub-skill must satisfy that spec's `argument-hint`. Fails CI when a
|
|
3
|
+
// phase's `buildArgs` and `commands/<command>.md` drift apart — the failure
|
|
4
|
+
// mode that parked every orchestrate run at architect.
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { PHASES, PhaseArgsError } from "./phases.mjs";
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
13
|
+
|
|
14
|
+
function argumentHint(command) {
|
|
15
|
+
const file = path.join(ROOT, "commands", `${command}.md`);
|
|
16
|
+
assert.ok(existsSync(file), `engine phase command has no spec: ${file}`);
|
|
17
|
+
const m = readFileSync(file, "utf-8").match(/^---\n([\s\S]*?)\n---/);
|
|
18
|
+
assert.ok(m, `${command}.md has no frontmatter`);
|
|
19
|
+
const h = m[1].match(/^argument-hint:\s*(.*)$/m);
|
|
20
|
+
assert.ok(h, `${command}.md has no argument-hint`);
|
|
21
|
+
let v = h[1].trim();
|
|
22
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
23
|
+
return v.replace(/\\"/g, '"');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Required = flags/positional outside [...] groups; optional = inside. */
|
|
27
|
+
function parseHint(hint) {
|
|
28
|
+
const required = hint.replace(/\[[^\]]*\]/g, " ");
|
|
29
|
+
const flagsIn = (t) => [...t.matchAll(/(--[a-z][a-z-]*)/g)].map((x) => x[1]);
|
|
30
|
+
return {
|
|
31
|
+
requiredFlags: flagsIn(required),
|
|
32
|
+
allFlags: flagsIn(hint),
|
|
33
|
+
requiredPositional: /^\s*\{[A-Z_]+\}/.test(required),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const state = { v: 0, run: "r", issue: 3499, lane: "staging", committed: ["investigate", "build", "review"],
|
|
38
|
+
phase: null, branch: "fix/example-3499", pr: 77, terminal: false, terminalReason: null, lease: null };
|
|
39
|
+
const ctx = { repo: "acme/widgets" };
|
|
40
|
+
const io = {
|
|
41
|
+
git: async () =>
|
|
42
|
+
"worktree /repo/.claude/worktrees/fix-example-3499\nHEAD 1111111\nbranch refs/heads/fix/example-3499\n",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe("engine phase args satisfy each target spec's argument-hint (forge#3499)", () => {
|
|
46
|
+
it("context/architect are not engine phases (work-on/build owns them)", () => {
|
|
47
|
+
const ids = PHASES.map((p) => p.id);
|
|
48
|
+
assert.ok(!ids.includes("context") && !ids.includes("architect"), ids.join(","));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
for (const phase of PHASES) {
|
|
52
|
+
it(`${phase.id} (${phase.command})`, async () => {
|
|
53
|
+
assert.equal(typeof phase.buildArgs, "function", `${phase.id} must define buildArgs`);
|
|
54
|
+
const hint = parseHint(argumentHint(phase.command));
|
|
55
|
+
const args = await phase.buildArgs({ ...state, terminalReason: phase.id === "remediate" ? "needs-human" : null }, ctx, io);
|
|
56
|
+
|
|
57
|
+
assert.ok(Array.isArray(args) && args.every((a) => typeof a === "string"), "args must be an array of strings");
|
|
58
|
+
assert.ok(args.length > 1, `${phase.id} must not be invoked with only a positional`);
|
|
59
|
+
for (const a of args) assert.ok(!/[\n\r\t]/.test(a), `control char in arg ${JSON.stringify(a)}`);
|
|
60
|
+
|
|
61
|
+
// positional: issue number (or PR number for remediate) comes first
|
|
62
|
+
const expectedPositional = phase.id === "remediate" ? String(state.pr) : String(state.issue);
|
|
63
|
+
assert.equal(args[0], expectedPositional);
|
|
64
|
+
if (hint.requiredPositional) assert.match(args[0], /^[0-9]+$/);
|
|
65
|
+
|
|
66
|
+
const emitted = args.filter((a) => a.startsWith("--"));
|
|
67
|
+
for (const flag of hint.requiredFlags) {
|
|
68
|
+
const i = args.indexOf(flag);
|
|
69
|
+
assert.ok(i >= 0, `${phase.id}: required ${flag} missing from [${args.join(" ")}]`);
|
|
70
|
+
assert.ok(args[i + 1] !== undefined && args[i + 1] !== "" && !args[i + 1].startsWith("--"),
|
|
71
|
+
`${phase.id}: ${flag} has no value`);
|
|
72
|
+
}
|
|
73
|
+
for (const flag of emitted) {
|
|
74
|
+
assert.ok(hint.allFlags.includes(flag), `${phase.id}: emits ${flag}, which ${phase.command}.md does not declare`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it("--gh-flag is a single quoted token, as in the specs' argument-hint", async () => {
|
|
80
|
+
const args = await PHASES.find((p) => p.id === "investigate").buildArgs(state, ctx, io);
|
|
81
|
+
assert.equal(args[args.indexOf("--gh-flag") + 1], '"-R acme/widgets"');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("fails closed (PhaseArgsError) on a missing or injectable repo/branch/worktree rather than sending a partial arg set", async () => {
|
|
85
|
+
const inv = PHASES.find((p) => p.id === "investigate");
|
|
86
|
+
const review = PHASES.find((p) => p.id === "review");
|
|
87
|
+
for (const repo of [null, "", "no-slash", "a/b --base main", 'a/b"c', "a/b\nc"]) {
|
|
88
|
+
await assert.rejects(() => inv.buildArgs(state, { repo }, io), PhaseArgsError, `repo=${JSON.stringify(repo)}`);
|
|
89
|
+
}
|
|
90
|
+
await assert.rejects(() => review.buildArgs({ ...state, branch: "x y" }, ctx, io), PhaseArgsError);
|
|
91
|
+
await assert.rejects(() => review.buildArgs({ ...state, branch: null }, ctx, io), PhaseArgsError);
|
|
92
|
+
await assert.rejects(() => review.buildArgs(state, ctx, { git: async () => "worktree /repo\nbranch refs/heads/main\n" }), PhaseArgsError);
|
|
93
|
+
await assert.rejects(() => review.buildArgs(state, ctx, { git: async () => { throw new Error("git down"); } }), PhaseArgsError);
|
|
94
|
+
await assert.rejects(() => PHASES.find((p) => p.id === "remediate").buildArgs({ ...state, pr: null }, ctx, io), PhaseArgsError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("close refuses a merged terminal state without a PR (forge#3504)", async () => {
|
|
98
|
+
const close = PHASES.find((p) => p.id === "close");
|
|
99
|
+
await assert.rejects(() => close.buildArgs({ ...state, pr: null, terminalReason: "merged" }, ctx, io), PhaseArgsError);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("close derives --terminal-state from the run's terminal reason", async () => {
|
|
103
|
+
const close = PHASES.find((p) => p.id === "close");
|
|
104
|
+
for (const [reason, expected] of [[null, "merged"], ["invalid", "invalid"], ["decomposed", "decomposed"]]) {
|
|
105
|
+
const args = await close.buildArgs({ ...state, terminalReason: reason }, ctx, io);
|
|
106
|
+
assert.equal(args[args.indexOf("--terminal-state") + 1], expected);
|
|
107
|
+
}
|
|
108
|
+
// worktree is optional for close: absent worktree must not fail it
|
|
109
|
+
const args = await close.buildArgs(state, ctx, { git: async () => "" });
|
|
110
|
+
assert.ok(!args.includes("--worktree"));
|
|
111
|
+
});
|
|
112
|
+
});
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -221,11 +221,90 @@ function resolveBranch(state, comments) {
|
|
|
221
221
|
return parseBranchFromMarkers(comments) || state.branch || null;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* forge#3499: non-blocking evidence about the context / architect children that
|
|
227
|
+
* `work-on/build` now runs itself. Recorded for visibility only — a missing
|
|
228
|
+
* marker NEVER fails build (context is advisory; the TRIVIAL band legitimately
|
|
229
|
+
* skips both children). A BLOCKED / missing-arg child is not hidden here: it
|
|
230
|
+
* shows up as `absent`, never as a fabricated "skipped" commit.
|
|
231
|
+
*/
|
|
232
|
+
function buildChildEvidence(blob, comments) {
|
|
233
|
+
const trivial = complexityBand(comments) === "TRIVIAL";
|
|
234
|
+
const state = (marker) => (has(blob, marker) ? "complete" : trivial ? "skipped-trivial" : "absent");
|
|
235
|
+
return {
|
|
236
|
+
context: state(PHASE_MARKERS.context.completionMarker),
|
|
237
|
+
architect: state(PHASE_MARKERS.architect.completionMarker),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// forge#3499: per-phase argument construction.
|
|
243
|
+
//
|
|
244
|
+
// Every `work-on` phase sub-skill takes ALL of its inputs as arguments (forge#3398
|
|
245
|
+
// forked-phase router) and stops with BLOCKED when one is missing. Each phase's
|
|
246
|
+
// `buildArgs` emits exactly the flags its `commands/<command>.md` `argument-hint`
|
|
247
|
+
// requires; bin/engine/phases-args.test.mjs pins the two together.
|
|
248
|
+
//
|
|
249
|
+
// Values are validated against strict allowlists before use: the runner joins the
|
|
250
|
+
// args array with single spaces into the prompt, so a value with whitespace,
|
|
251
|
+
// quotes or newlines would splice extra arguments (forge#3466 class).
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export class PhaseArgsError extends Error {
|
|
255
|
+
constructor(message) { super(message); this.name = "PhaseArgsError"; this.code = "PHASE_ARGS_INVALID"; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
259
|
+
const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
|
|
260
|
+
const LANE_RE = BRANCH_RE;
|
|
261
|
+
const PATH_RE = /^[A-Za-z0-9_.\/\\:@+~-]+$/;
|
|
262
|
+
|
|
263
|
+
function need(value, name, re) {
|
|
264
|
+
if (value === null || value === undefined || value === "") throw new PhaseArgsError(`missing ${name}`);
|
|
265
|
+
const v = String(value);
|
|
266
|
+
if (!re.test(v) || v.includes("..")) throw new PhaseArgsError(`invalid ${name}: ${JSON.stringify(v)}`);
|
|
267
|
+
return v;
|
|
268
|
+
}
|
|
269
|
+
/** @returns {string[]} the `--repo`/`--gh-flag` pair (gh-flag is ONE quoted token, as in the argument-hint). */
|
|
270
|
+
function repoArgs(ctx) {
|
|
271
|
+
const repo = need(ctx.repo, "repo", REPO_RE);
|
|
272
|
+
return ["--repo", repo, "--gh-flag", `"-R ${repo}"`];
|
|
273
|
+
}
|
|
274
|
+
function baseArgs(state) { return ["--base", need(state.lane, "base (lane)", LANE_RE)]; }
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the linked worktree checked out on `branch` from `git worktree list
|
|
278
|
+
* --porcelain` — the only engine-side ground truth (build.md B1C creates it).
|
|
279
|
+
* Returns null when absent/prunable/unresolvable; never invents a path.
|
|
280
|
+
*/
|
|
281
|
+
export async function resolveWorktree(state, io) {
|
|
282
|
+
if (state.worktree) return state.worktree;
|
|
283
|
+
if (!state.branch) return null;
|
|
284
|
+
let out;
|
|
285
|
+
try { out = await io.git(["worktree", "list", "--porcelain"]); } catch { return null; }
|
|
286
|
+
for (const block of String(out || "").split(/\n\s*\n/)) {
|
|
287
|
+
const lines = block.split("\n");
|
|
288
|
+
const wt = lines.find((l) => l.startsWith("worktree "));
|
|
289
|
+
const br = lines.find((l) => l.startsWith("branch "));
|
|
290
|
+
if (!wt || !br || lines.some((l) => l.startsWith("prunable"))) continue;
|
|
291
|
+
if (br.slice("branch ".length).trim() === `refs/heads/${state.branch}`) return wt.slice("worktree ".length).trim();
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function reviewArgs(state, ctx, io) {
|
|
297
|
+
const worktree = need(await resolveWorktree(state, io), "worktree (no linked worktree for branch)", PATH_RE);
|
|
298
|
+
return [String(state.issue), ...repoArgs(ctx), "--worktree", worktree,
|
|
299
|
+
"--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
|
|
300
|
+
}
|
|
301
|
+
|
|
224
302
|
/** @type {Phase[]} */
|
|
225
303
|
export const PHASES = [
|
|
226
304
|
{
|
|
227
305
|
id: "investigate",
|
|
228
306
|
command: "work-on/investigate",
|
|
307
|
+
buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
|
|
229
308
|
entryCondition: () => true,
|
|
230
309
|
async detectOutcome(state, io) {
|
|
231
310
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -255,6 +334,7 @@ export const PHASES = [
|
|
|
255
334
|
// before the run terminates.
|
|
256
335
|
id: "decompose",
|
|
257
336
|
command: "work-on/decompose",
|
|
337
|
+
buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
|
|
258
338
|
entryCondition: (s) => s.terminalReason === "decomposed",
|
|
259
339
|
async detectOutcome(state, io) {
|
|
260
340
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -266,50 +346,15 @@ export const PHASES = [
|
|
|
266
346
|
// which runs its own /work-on pipeline — nothing more for THIS run to do.
|
|
267
347
|
isTerminalAfter: () => true,
|
|
268
348
|
},
|
|
269
|
-
{
|
|
270
|
-
id: "context",
|
|
271
|
-
command: "work-on/build/context",
|
|
272
|
-
entryCondition: (s) => s.committed.includes("investigate"),
|
|
273
|
-
async reconcile(state, io) {
|
|
274
|
-
const markers = await issueMarkers(state.issue, io);
|
|
275
|
-
if (complexityBand(markers.comments) === "TRIVIAL") {
|
|
276
|
-
return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "context" } };
|
|
277
|
-
}
|
|
278
|
-
// Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
|
|
279
|
-
// Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
|
|
280
|
-
return has(markers.blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
281
|
-
},
|
|
282
|
-
async detectOutcome(state, io) {
|
|
283
|
-
const { blob } = await issueMarkers(state.issue, io);
|
|
284
|
-
// Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
|
|
285
|
-
if (has(blob, PHASE_MARKERS.context.presenceMarker)) return { status: "committed", outputs: {} };
|
|
286
|
-
return { status: "committed", outputs: { skipped: true, which: "context" } };
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
id: "architect",
|
|
291
|
-
command: "work-on/build/architect",
|
|
292
|
-
entryCondition: (s) => s.committed.includes("context"),
|
|
293
|
-
async reconcile(state, io) {
|
|
294
|
-
const markers = await issueMarkers(state.issue, io);
|
|
295
|
-
if (complexityBand(markers.comments) === "TRIVIAL") {
|
|
296
|
-
return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "architect" } };
|
|
297
|
-
}
|
|
298
|
-
// Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
|
|
299
|
-
// Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
|
|
300
|
-
return has(markers.blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
301
|
-
},
|
|
302
|
-
async detectOutcome(state, io) {
|
|
303
|
-
const { blob } = await issueMarkers(state.issue, io);
|
|
304
|
-
return has(blob, PHASE_MARKERS.architect.completionMarker)
|
|
305
|
-
? { status: "committed", outputs: {} }
|
|
306
|
-
: { status: "failed", detail: `no ${PHASE_MARKERS.architect.completionMarker}` };
|
|
307
|
-
},
|
|
308
|
-
},
|
|
309
349
|
{
|
|
310
350
|
id: "build",
|
|
311
351
|
command: "work-on/build",
|
|
312
|
-
|
|
352
|
+
buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)],
|
|
353
|
+
// forge#3499: work-on/build owns worktree creation and runs the context /
|
|
354
|
+
// architect / implement / validate children itself (it needs the worktree
|
|
355
|
+
// as `--repo-path`), so the engine no longer schedules context/architect as
|
|
356
|
+
// top-level phases — build is eligible as soon as investigate committed.
|
|
357
|
+
entryCondition: (s) => s.committed.includes("investigate"),
|
|
313
358
|
async reconcile(state, io) {
|
|
314
359
|
// Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
|
|
315
360
|
// comment) rather than trusting a possibly-stale/absent state.branch, then
|
|
@@ -334,7 +379,9 @@ export const PHASES = [
|
|
|
334
379
|
// from a genuine git-confirmed zero and would wrongly trip the
|
|
335
380
|
// non-retryable guard below on the very first attempt.
|
|
336
381
|
const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
|
|
337
|
-
if (complete && ahead > 0)
|
|
382
|
+
if (complete && ahead > 0) {
|
|
383
|
+
return { status: "committed", outputs: { branch, ...buildChildEvidence(blob, comments) } };
|
|
384
|
+
}
|
|
338
385
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
339
386
|
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
340
387
|
// but the resolved (real, ground-truth) branch has zero commits ahead of
|
|
@@ -369,17 +416,33 @@ export const PHASES = [
|
|
|
369
416
|
{
|
|
370
417
|
id: "review",
|
|
371
418
|
command: "work-on/review",
|
|
419
|
+
buildArgs: reviewArgs,
|
|
372
420
|
entryCondition: (s) => s.committed.includes("build"),
|
|
373
421
|
async reconcile(state, io) {
|
|
374
422
|
const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
|
|
375
423
|
return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
|
|
376
424
|
},
|
|
377
|
-
async detectOutcome(state, io) {
|
|
425
|
+
async detectOutcome(state, io, result) {
|
|
378
426
|
const pr = await prStatusFor(state, io);
|
|
379
427
|
if (!pr) return { status: "failed", detail: "no PR created" };
|
|
380
428
|
if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
|
|
381
|
-
|
|
382
|
-
|
|
429
|
+
// forge#3521: the spec (commands/work-on/review.md) signals an in-PR fix
|
|
430
|
+
// by labelling the ISSUE needs-human and returning REVIEW_RESULT
|
|
431
|
+
// `status: NEXT / next: remediate`; the PR label is back-compat only.
|
|
432
|
+
// The PR number from GitHub (openPrFor) always wins over parsed text.
|
|
433
|
+
const outputs = { pr: pr.number };
|
|
434
|
+
const escalated = { status: "blocked", detail: "review escalated", outputs };
|
|
435
|
+
if (pr.needsHuman) return escalated;
|
|
436
|
+
const rr = parseReviewResult(result?.text);
|
|
437
|
+
if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
|
|
438
|
+
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
439
|
+
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
440
|
+
// needs-human but are NOT remediation handoffs, so skip the label fallback.
|
|
441
|
+
if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
|
|
442
|
+
let snap = null;
|
|
443
|
+
try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
|
|
444
|
+
if (snap?.ok && snap.labels.includes("needs-human")) return escalated;
|
|
445
|
+
return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
|
|
383
446
|
},
|
|
384
447
|
},
|
|
385
448
|
{
|
|
@@ -398,6 +461,9 @@ export const PHASES = [
|
|
|
398
461
|
// handing the PR to remediation in the same run.
|
|
399
462
|
id: "remediate",
|
|
400
463
|
command: "work-on/remediate",
|
|
464
|
+
buildArgs: async (state, ctx) => [
|
|
465
|
+
need(state.pr, "pr", /^[0-9]+$/), "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state),
|
|
466
|
+
],
|
|
401
467
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
|
402
468
|
async detectOutcome(state, io) {
|
|
403
469
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -435,6 +501,25 @@ export const PHASES = [
|
|
|
435
501
|
{
|
|
436
502
|
id: "close",
|
|
437
503
|
command: "work-on/close",
|
|
504
|
+
buildArgs: async (state, ctx, io) => {
|
|
505
|
+
// Only --terminal-state is required by close.md; the rest are optional context.
|
|
506
|
+
// forge#3506: fail closed — `merged` only from a null/merged reason. A residual
|
|
507
|
+
// non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
|
|
508
|
+
// be closed as merged.
|
|
509
|
+
const reason = state.terminalReason ?? null;
|
|
510
|
+
if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
|
|
511
|
+
throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
|
|
512
|
+
const terminal = reason === "decomposed" ? "decomposed"
|
|
513
|
+
: reason === "invalid" ? "invalid" : "merged";
|
|
514
|
+
const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
|
|
515
|
+
// forge#3504: a merged close with no PR is fabricated evidence — require it.
|
|
516
|
+
if (state.pr != null || terminal === "merged") args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
|
|
517
|
+
if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
|
|
518
|
+
const wt = await resolveWorktree(state, io);
|
|
519
|
+
if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
|
|
520
|
+
args.push("--terminal-state", terminal);
|
|
521
|
+
return args;
|
|
522
|
+
},
|
|
438
523
|
entryCondition: (s) => s.committed.includes("review"),
|
|
439
524
|
async reconcile(state, io) {
|
|
440
525
|
// Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
|
|
@@ -468,6 +553,28 @@ export const PHASES = [
|
|
|
468
553
|
},
|
|
469
554
|
];
|
|
470
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Parse the LAST `REVIEW_RESULT:` block from a phase's final reply (forge#3521).
|
|
558
|
+
* Untrusted model output: only the fixed keys below are read, via anchored
|
|
559
|
+
* line matches; `pr_number` must be digits-only. Returns null when absent.
|
|
560
|
+
*/
|
|
561
|
+
export function parseReviewResult(text) {
|
|
562
|
+
if (typeof text !== "string" || !text) return null;
|
|
563
|
+
const lines = text.split(/\r?\n/);
|
|
564
|
+
let start = -1;
|
|
565
|
+
for (let i = 0; i < lines.length; i++) if (/^\s*REVIEW_RESULT:\s*$/.test(lines[i])) start = i;
|
|
566
|
+
if (start < 0) return null;
|
|
567
|
+
const out = {};
|
|
568
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
569
|
+
const m = /^\s+(status|next|remediation|pr_number):[ \t]*(\S*)[ \t]*$/.exec(lines[i]);
|
|
570
|
+
if (m) { if (!(m[1] in out)) out[m[1]] = m[2]; continue; }
|
|
571
|
+
if (/^\s+[A-Za-z_]+:/.test(lines[i]) || !lines[i].trim()) continue;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
if (out.pr_number !== undefined && !/^[0-9]+$/.test(out.pr_number)) delete out.pr_number;
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
471
578
|
async function openPrFor(state, io) {
|
|
472
579
|
if (!state.branch) return null;
|
|
473
580
|
const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -68,10 +68,19 @@ export function deriveState(events) {
|
|
|
68
68
|
s.v = e.seq;
|
|
69
69
|
if (e.outputs?.branch) s.branch = e.outputs.branch;
|
|
70
70
|
if (e.outputs?.pr != null) s.pr = e.outputs.pr;
|
|
71
|
+
// forge#3504: a blocked phase persists its reason so a resumed run still routes to
|
|
72
|
+
// remediate instead of defaulting to a "merged" close.
|
|
73
|
+
if (e.terminalReason) s.terminalReason = e.terminalReason;
|
|
71
74
|
break;
|
|
72
75
|
case "RUN_TERMINAL":
|
|
73
76
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
74
77
|
break;
|
|
78
|
+
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
79
|
+
// terminal. Bumping `v` makes the local state newer than the stale
|
|
80
|
+
// remote terminal index so reconcile remirrors instead of re-terminating.
|
|
81
|
+
case "RUN_REOPEN":
|
|
82
|
+
s.terminal = false; s.terminalReason = null; s.v = e.seq;
|
|
83
|
+
break;
|
|
75
84
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
76
85
|
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
|
77
86
|
// pause is, by construction, not yet committed (the engine is about to
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -337,16 +337,21 @@ export function lastLocalRun(dir) {
|
|
|
337
337
|
* `runCommand()` call; only applies on the "api" backend.
|
|
338
338
|
* Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
|
|
339
339
|
* or its built-in default).
|
|
340
|
+
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
341
|
+
* (e.g. a session-limit hit) and resume from its last committed
|
|
342
|
+
* phase. Other terminal reasons are not reopened.
|
|
340
343
|
*/
|
|
341
344
|
export async function runFromCli(argv, deps = {}) {
|
|
342
345
|
const issue = parseInt(argv[0], 10);
|
|
343
|
-
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
|
|
346
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry]");
|
|
344
347
|
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
345
348
|
const lane = flag(argv, "--lane");
|
|
346
349
|
if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
|
|
347
350
|
const repo = flag(argv, "--repo");
|
|
348
351
|
const backend = flag(argv, "--backend");
|
|
349
352
|
const model = flag(argv, "--model");
|
|
353
|
+
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
354
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
350
355
|
const io = deps.io ?? makeIo();
|
|
351
356
|
await assertRepoMatchesCwd(io, repo);
|
|
352
357
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
@@ -375,13 +380,19 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
375
380
|
else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
|
|
376
381
|
};
|
|
377
382
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
383
|
+
...(repo ? { repo } : {}),
|
|
378
384
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
379
385
|
onProgress,
|
|
380
386
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
381
387
|
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
382
388
|
...(backend ? { backend } : {}),
|
|
383
|
-
...(model ? { model } : {})
|
|
389
|
+
...(model ? { model } : {}),
|
|
390
|
+
...(retry ? { retry: true } : {}) });
|
|
384
391
|
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
392
|
+
if (res.terminalReason === "not-retryable") {
|
|
393
|
+
console.log(res.detail ?? "run is not retryable");
|
|
394
|
+
return res;
|
|
395
|
+
}
|
|
385
396
|
// forge#2175: a non-success termination previously printed nothing beyond
|
|
386
397
|
// the bare reason above — the actual failing phase/attempt/reason was only
|
|
387
398
|
// recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
|