forgedock 1.0.21 → 1.1.1

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 (76) hide show
  1. package/README.md +227 -174
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/demo.mjs +386 -0
  4. package/bin/engine/phases.mjs +148 -0
  5. package/bin/engine/projector.mjs +32 -0
  6. package/bin/engine/reconcile.mjs +11 -0
  7. package/bin/engine/runlog.mjs +79 -0
  8. package/bin/engine/state.mjs +27 -0
  9. package/bin/engine-cli.mjs +50 -0
  10. package/bin/engine.mjs +138 -0
  11. package/bin/forgedock.mjs +693 -1393
  12. package/bin/init-detect.mjs +95 -0
  13. package/bin/journey.mjs +783 -0
  14. package/bin/runner.mjs +847 -0
  15. package/bin/settings-hook.mjs +112 -0
  16. package/bin/tui.mjs +6 -3
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +85 -39
  19. package/commands/deploy-info.md +11 -5
  20. package/commands/design-bench.md +1 -0
  21. package/commands/design-render-critique-loop.md +1 -0
  22. package/commands/design.md +1 -0
  23. package/commands/forge-stats.md +2 -1
  24. package/commands/issue.md +1 -1
  25. package/commands/optimize.md +1 -1
  26. package/commands/orchestrate.md +346 -34
  27. package/commands/quality-gate.md +168 -80
  28. package/commands/review-pr-agents.md +69 -45
  29. package/commands/review-pr.md +54 -6
  30. package/commands/scope.md +11 -3
  31. package/commands/security-audit.md +24 -16
  32. package/commands/sync-ecosystem.md +2 -2
  33. package/commands/work-on/build/architect.md +47 -3
  34. package/commands/work-on/build/context.md +36 -5
  35. package/commands/work-on/build/implement.md +16 -4
  36. package/commands/work-on/build/validate.md +54 -8
  37. package/commands/work-on/build.md +27 -9
  38. package/commands/work-on/close.md +131 -3
  39. package/commands/work-on/investigate.md +24 -1
  40. package/commands/work-on/review.md +24 -0
  41. package/commands/work-on-monolithic.md +1 -0
  42. package/commands/work-on.md +271 -11
  43. package/docs/BRAND.md +6 -2
  44. package/docs/CONFIG.md +39 -34
  45. package/docs/site/.vitepress/config.mjs +85 -0
  46. package/docs/site/annotations-explained.md +55 -0
  47. package/docs/site/command-learning-path.md +104 -0
  48. package/docs/site/command-reference.md +3 -0
  49. package/docs/site/getting-started.md +42 -13
  50. package/docs/site/index.md +1 -1
  51. package/docs/site/troubleshooting.md +208 -0
  52. package/docs/spec/forge-protocol-v1.md +768 -0
  53. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  54. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  55. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  56. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  57. package/examples/forgedock-demo/README.md +113 -0
  58. package/examples/forgedock-demo/bootstrap.sh +125 -0
  59. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  60. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  61. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  62. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  63. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  64. package/examples/forgedock-demo/labels.json +21 -0
  65. package/examples/forgedock-demo/package.json +16 -0
  66. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  67. package/examples/forgedock-demo/src/auth.js +28 -0
  68. package/examples/forgedock-demo/src/db.js +66 -0
  69. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  70. package/examples/forgedock-demo/src/server.js +79 -0
  71. package/package.json +13 -3
  72. package/scripts/verify-env-vars.sh +18 -2
  73. package/scripts/verify-host-headers.sh +4 -4
  74. package/scripts/verify-route-registration.sh +2 -2
  75. package/scripts/worktree-lifecycle.sh +202 -0
  76. package/templates/forge.yaml.minimal +44 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Declarative phase table for the headless work-on pipeline. The ENGINE (not an
3
+ * LLM) chooses the next phase via pickPhase. Each phase's outcome is read from
4
+ * GitHub/git AFTER the run (detectOutcome); the runner's return is advisory.
5
+ * @typedef ... (see plan "Shared types")
6
+ */
7
+
8
+ export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
9
+
10
+ /** Fetch the issue's comment bodies as one blob for marker checks. */
11
+ async function issueMarkers(issue, io) {
12
+ const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", ".[].body"]);
13
+ return out || "";
14
+ }
15
+ /**
16
+ * Count commits on branch ahead of the lane base. On the first build the
17
+ * branch does not exist yet, so real git rejects the ref range — swallow
18
+ * that (and any other git failure) as "0 ahead" rather than letting it
19
+ * propagate and crash runIssue (C1).
20
+ */
21
+ async function commitsAhead(state, io) {
22
+ try {
23
+ const n = await io.git(["rev-list", "--count", `origin/${state.lane}..${state.branch}`]);
24
+ return parseInt(String(n).trim(), 10) || 0;
25
+ } catch {
26
+ return 0;
27
+ }
28
+ }
29
+ function has(blob, marker) { return blob.includes(marker); }
30
+
31
+ /** @type {Phase[]} */
32
+ export const PHASES = [
33
+ {
34
+ id: "investigate",
35
+ command: "work-on/investigate",
36
+ entryCondition: () => true,
37
+ async detectOutcome(state, io) {
38
+ const m = await issueMarkers(state.issue, io);
39
+ if (has(m, "INVESTIGATION:INVALID"))
40
+ return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
41
+ if (has(m, "DECOMPOSE:YES"))
42
+ return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
43
+ if (has(m, "INVESTIGATION:COMPLETE"))
44
+ return { status: "committed", outputs: { verdict: "CONFIRMED" } };
45
+ return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
46
+ },
47
+ isTerminalAfter: (s) => s.terminalReason === "invalid" || s.terminalReason === "decomposed",
48
+ },
49
+ {
50
+ id: "context",
51
+ command: "work-on/build/context",
52
+ entryCondition: (s) => s.committed.includes("investigate"),
53
+ async detectOutcome(state, io) {
54
+ const m = await issueMarkers(state.issue, io);
55
+ // Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
56
+ if (has(m, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
57
+ return { status: "committed", outputs: { skipped: true, which: "context" } };
58
+ },
59
+ },
60
+ {
61
+ id: "architect",
62
+ command: "work-on/build/architect",
63
+ entryCondition: (s) => s.committed.includes("context"),
64
+ async detectOutcome(state, io) {
65
+ const m = await issueMarkers(state.issue, io);
66
+ return has(m, "FORGE:ARCHITECT")
67
+ ? { status: "committed", outputs: {} }
68
+ : { status: "failed", detail: "no FORGE:ARCHITECT" };
69
+ },
70
+ },
71
+ {
72
+ id: "build",
73
+ command: "work-on/build",
74
+ entryCondition: (s) => s.committed.includes("architect"),
75
+ async reconcile(state, io) {
76
+ // Idempotent resume: branch already ahead of base → treat as done, skip the LLM.
77
+ if (state.branch && (await commitsAhead(state, io)) > 0) {
78
+ const m = await issueMarkers(state.issue, io);
79
+ if (has(m, "FORGE:BUILDER:COMPLETE")) return { satisfied: true, outputs: { branch: state.branch } };
80
+ }
81
+ return { satisfied: false };
82
+ },
83
+ async detectOutcome(state, io) {
84
+ const m = await issueMarkers(state.issue, io);
85
+ const complete = has(m, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
86
+ const ahead = state.branch ? await commitsAhead(state, io) : 0; // … AND real commits
87
+ if (complete && ahead > 0) return { status: "committed", outputs: { branch: state.branch } };
88
+ return { status: "failed", detail: `builder complete=${complete} commitsAhead=${ahead}` };
89
+ },
90
+ },
91
+ {
92
+ id: "review",
93
+ command: "work-on/review",
94
+ entryCondition: (s) => s.committed.includes("build"),
95
+ async reconcile(state, io) {
96
+ const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
97
+ return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
98
+ },
99
+ async detectOutcome(state, io) {
100
+ const pr = await prStatusFor(state, io);
101
+ if (!pr) return { status: "failed", detail: "no PR created" };
102
+ if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
103
+ if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
104
+ return { status: "failed", detail: "PR open, not merged" };
105
+ },
106
+ },
107
+ {
108
+ id: "close",
109
+ command: "work-on/close",
110
+ entryCondition: (s) => s.committed.includes("review"),
111
+ async detectOutcome(state, io) {
112
+ const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
113
+ let j;
114
+ try { j = JSON.parse(out || "{}"); } catch { return { status: "failed", detail: "malformed gh response" }; }
115
+ const labels = (j.labels || []).map((l) => l.name || l);
116
+ if (j.state === "CLOSED" || labels.includes("workflow:merged"))
117
+ return { status: "committed", terminalReason: "merged", outputs: {} };
118
+ return { status: "failed", detail: "issue not closed" };
119
+ },
120
+ isTerminalAfter: () => true,
121
+ },
122
+ ];
123
+
124
+ async function openPrFor(state, io) {
125
+ if (!state.branch) return null;
126
+ const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
127
+ try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
128
+ }
129
+ async function prStatusFor(state, io) {
130
+ const n = await openPrFor(state, io);
131
+ if (!n) return null;
132
+ const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
133
+ let j;
134
+ try { j = JSON.parse(out || "{}"); } catch { return null; }
135
+ const labels = (j.labels || []).map((l) => l.name || l);
136
+ return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
137
+ needsHuman: labels.includes("needs-human") };
138
+ }
139
+
140
+ /** The engine's transition function: first uncommitted phase whose gate holds. */
141
+ export function pickPhase(state) {
142
+ if (state.terminal) return null;
143
+ for (const p of PHASES) {
144
+ if (state.committed.includes(p.id)) continue;
145
+ if (p.entryCondition(state)) return p;
146
+ }
147
+ return null;
148
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * GitHub adapter: reads/writes the FORGE:STATE block and workflow labels.
3
+ * `io.gh(args)` is injected (async, returns stdout) so this is testable offline.
4
+ */
5
+ import { parseState, upsertStateBlock } from "./state.mjs";
6
+
7
+ export function makeProjector(io) {
8
+ const gh = io.gh;
9
+
10
+ async function getBody(issue) {
11
+ const out = await gh(["issue", "view", String(issue), "--json", "body"]);
12
+ try { return JSON.parse(out).body ?? ""; } catch { return ""; }
13
+ }
14
+
15
+ return {
16
+ /** @returns {Promise<import("./phases.mjs").RunState|null>} */
17
+ async readState(issue) { return parseState(await getBody(issue)); },
18
+
19
+ async writeState(issue, index) {
20
+ const body = upsertStateBlock(await getBody(issue), index);
21
+ await gh(["issue", "edit", String(issue), "--body", body]);
22
+ },
23
+
24
+ async setLabel(issue, label) {
25
+ await gh(["issue", "edit", String(issue), "--add-label", label]);
26
+ },
27
+
28
+ async removeLabel(issue, label) {
29
+ await gh(["issue", "edit", String(issue), "--remove-label", label]);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Merge the local run-log state and the remote FORGE:STATE index.
3
+ * Rule: GitHub wins on divergence (spec §5.3).
4
+ */
5
+ export function reconcileState(local, remote) {
6
+ if (!remote) return { state: local ?? null, action: local ? "local" : "fresh" };
7
+ if (!local) return { state: remote, action: "hydrate" };
8
+ if (remote.v > local.v) return { state: remote, action: "hydrate" }; // advanced elsewhere
9
+ if (remote.v < local.v) return { state: local, action: "remirror" }; // crashed pre-mirror
10
+ return { state: local, action: "local" };
11
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Append-only per-issue run-log (the crash-safe local hot path).
3
+ * One JSON event per line at {dir}/{issue}.jsonl. seq is assigned monotonically.
4
+ */
5
+ import { readFileSync, appendFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ function logPath(dir, issue) { return join(dir, `${issue}.jsonl`); }
9
+
10
+ /** Append an event; assigns the next seq based on the current line count. */
11
+ export function appendEvent(dir, issue, event) {
12
+ mkdirSync(dir, { recursive: true });
13
+ const path = logPath(dir, issue);
14
+ const seq = readLog(dir, issue).length + 1;
15
+ appendFileSync(path, JSON.stringify({ seq, ...event }) + "\n");
16
+ }
17
+
18
+ /**
19
+ * Overwrite the local run-log with a fresh set of events (fresh seq each).
20
+ * Used to reconstruct the local log from a remote FORGE:STATE index on
21
+ * hydrate (C2), so the local log and the GitHub-wins state stay consistent.
22
+ */
23
+ export function rewriteLog(dir, issue, events) {
24
+ mkdirSync(dir, { recursive: true });
25
+ const path = logPath(dir, issue);
26
+ const lines = events.map((e, i) => JSON.stringify({ seq: i + 1, ...e }));
27
+ writeFileSync(path, lines.length ? lines.join("\n") + "\n" : "");
28
+ }
29
+
30
+ /** Read all complete events; a truncated final line (crash mid-write) is ignored. */
31
+ export function readLog(dir, issue) {
32
+ const path = logPath(dir, issue);
33
+ if (!existsSync(path)) return [];
34
+ const lines = readFileSync(path, "utf-8").split("\n");
35
+ // Drop trailing empty lines to find the last non-empty line.
36
+ while (lines.length > 0 && !lines[lines.length - 1].trim()) lines.pop();
37
+
38
+ const out = [];
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ if (!line.trim()) continue;
42
+ try {
43
+ out.push(JSON.parse(line));
44
+ } catch (err) {
45
+ // Only tolerate parse failure on the final non-empty line (crash mid-write).
46
+ if (i === lines.length - 1) {
47
+ break; // Ignore truncated final line.
48
+ }
49
+ throw new Error(`corrupt run-log line ${i + 1} in ${path}`);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Fold events into a RunState. The commit rule lives here. */
56
+ export function deriveState(events) {
57
+ /** @type {import("./phases.mjs").RunState} */
58
+ const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
59
+ phase: null, branch: null, pr: null, terminal: false,
60
+ terminalReason: null, lease: null };
61
+ for (const e of events) {
62
+ switch (e.event) {
63
+ case "RUN_START":
64
+ s.run = e.run ?? s.run; s.issue = e.issue ?? s.issue; s.lane = e.lane ?? s.lane;
65
+ break;
66
+ case "PHASE_COMMIT":
67
+ if (!s.committed.includes(e.phase)) s.committed.push(e.phase);
68
+ s.v = e.seq;
69
+ if (e.outputs?.branch) s.branch = e.outputs.branch;
70
+ if (e.outputs?.pr != null) s.pr = e.outputs.pr;
71
+ break;
72
+ case "RUN_TERMINAL":
73
+ s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
74
+ break;
75
+ // PHASE_START / PHASE_FAILED do not change committed state
76
+ }
77
+ }
78
+ return s;
79
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Codec for the single machine-readable FORGE:STATE HTML-comment block on an
3
+ * issue body. Carries the COMPACT INDEX only (no rich per-phase outputs).
4
+ */
5
+ const OPEN = "<!-- FORGE:STATE";
6
+ const CLOSE = "-->";
7
+ // NOTE: a literal "-->" inside a serialized value would close the block early; terminalReason etc. should not contain it. Tracked as a follow-up.
8
+ const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
9
+
10
+ /** @param {import("./phases.mjs").RunState} index */
11
+ export function serializeState(index) {
12
+ return `${OPEN}\n${JSON.stringify(index)}\n${CLOSE}`;
13
+ }
14
+
15
+ /** @returns {import("./phases.mjs").RunState|null} */
16
+ export function parseState(issueBody) {
17
+ const m = BLOCK_RE.exec(issueBody || "");
18
+ if (!m) return null;
19
+ try { return JSON.parse(m[1].trim()); } catch { return null; }
20
+ }
21
+
22
+ /** Replace the FORGE:STATE block in place, or append one if absent. */
23
+ export function upsertStateBlock(body, index) {
24
+ const block = serializeState(index);
25
+ if (BLOCK_RE.test(body || "")) return body.replace(BLOCK_RE, () => block);
26
+ return `${body || ""}\n\n${block}`.trimStart();
27
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Headless entry point: `forgedock run-issue <issue>` drives one issue through the
3
+ * durable engine; scanStalls finds dead-lease issues for the orchestrator to resume.
4
+ */
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { runIssue } from "./engine.mjs";
10
+
11
+ const pexec = promisify(execFile);
12
+
13
+ /** Real gh/git accessors. */
14
+ export function makeIo() {
15
+ const run = (bin) => async (args) => {
16
+ const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
17
+ return stdout;
18
+ };
19
+ return { gh: run("gh"), git: run("git") };
20
+ }
21
+
22
+ export function runDir() { return join(homedir(), ".forge", "runs"); }
23
+
24
+ /**
25
+ * @param {number[]} issues
26
+ * @param {{readState:(i:number)=>Promise<{terminal:boolean,lease:?{until:number}}|null>}} io
27
+ * @param {number} now
28
+ * @returns {Promise<number[]>} issues that appear stalled (expired lease, non-terminal)
29
+ */
30
+ export async function scanStalls(issues, io, now) {
31
+ const stalled = [];
32
+ for (const i of issues) {
33
+ const s = await io.readState(i);
34
+ if (s && !s.terminal && s.lease && s.lease.until < now) stalled.push(i);
35
+ }
36
+ return stalled;
37
+ }
38
+
39
+ export async function runFromCli(argv) {
40
+ const issue = parseInt(argv[0], 10);
41
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number>");
42
+ const lane = flag(argv, "--lane") || "staging";
43
+ const io = makeIo();
44
+ const agentId = `cli_${process.pid}`;
45
+ const res = await runIssue({ issue, dir: runDir(), agentId, lane, io,
46
+ runner: (await import("./runner.mjs")).runCommand, now: () => Date.now() });
47
+ console.log(`issue #${issue} → ${res.terminalReason}`);
48
+ return res;
49
+ }
50
+ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
package/bin/engine.mjs ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Durable per-phase engine loop. Drives one pipeline phase at a time via an
3
+ * injected runner (runCommand-shaped), determining each phase's outcome from
4
+ * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
+ */
6
+ import { fileURLToPath } from "node:url";
7
+ import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
8
+ import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
9
+ import { reconcileState } from "./engine/reconcile.mjs";
10
+ import { makeProjector } from "./engine/projector.mjs";
11
+
12
+ const DEFAULT_MAX_ATTEMPTS = 3;
13
+
14
+ export async function runIssue(opts) {
15
+ const { issue, dir, agentId, lane = "staging", io, runner,
16
+ now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
17
+ commandsDir = fileURLToPath(new URL("../commands", import.meta.url)) } = opts;
18
+ const projector = makeProjector(io);
19
+
20
+ // 1. Load + reconcile (GitHub wins).
21
+ const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
22
+ const remote = await projector.readState(issue);
23
+ let { state, action } = reconcileState(local, remote);
24
+
25
+ // I3 (best-effort): GitHub issue-edit is not an atomic CAS, so this is a
26
+ // courtesy check, not a hard mutual-exclusion guarantee — a concurrent
27
+ // start can still race between this read and the next lease write.
28
+ // MUST come before any projector.writeState() — remirror/hydrate branches
29
+ // must not overwrite GitHub state when another agent holds a valid lease.
30
+ if (remote?.lease && remote.lease.until > now() && remote.lease.by !== agentId) {
31
+ return { terminalReason: "deferred", detail: `issue ${issue} leased by ${remote.lease.by}` };
32
+ }
33
+
34
+ if (!state) {
35
+ state = freshState(issue, lane);
36
+ appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
37
+ await projector.writeState(issue, state);
38
+ } else if (action === "remirror") {
39
+ await projector.writeState(issue, state);
40
+ } else if (action === "hydrate") {
41
+ // GitHub is ahead of (or the only source of) local state. Rebuild the
42
+ // local run-log to match the remote compact index so downstream
43
+ // `deriveState(readLog(...))` calls stay consistent — otherwise the
44
+ // post-commit re-derive below would fold over an empty/stale local log
45
+ // and regress committed/issue/v (C2).
46
+ rewriteLog(dir, issue, eventsFromIndex(state));
47
+ state = deriveState(readLog(dir, issue));
48
+ await projector.writeState(issue, state);
49
+ }
50
+
51
+ // 2. Drive phases until terminal.
52
+ let phase;
53
+ while ((phase = pickPhase(state))) {
54
+ // Every issue's build works on a deterministic branch; set it before build runs.
55
+ if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
56
+
57
+ let reconciled;
58
+ try {
59
+ reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
60
+ } catch {
61
+ // A reconcile probe (gh/git) failing must not crash the run — degrade
62
+ // to "not satisfied" so the phase runs normally instead (C1).
63
+ reconciled = { satisfied: false };
64
+ }
65
+ let outcome;
66
+ if (reconciled.satisfied) {
67
+ outcome = { status: "committed", outputs: reconciled.outputs || {} };
68
+ } else {
69
+ if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
70
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts });
71
+ }
72
+
73
+ if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
74
+
75
+ // committed
76
+ appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {} });
77
+ state = deriveState(readLog(dir, issue));
78
+ if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
79
+ await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + 600000 } });
80
+
81
+ if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason))
82
+ return await terminate(state, outcome.terminalReason);
83
+ if (phase.isTerminalAfter && phase.isTerminalAfter(state))
84
+ return await terminate(state, state.terminalReason || "merged");
85
+ }
86
+ return await terminate(state, state.terminalReason || "merged");
87
+
88
+ async function terminate(s, reason, detail) {
89
+ appendEvent(dir, issue, { event: "RUN_TERMINAL", reason });
90
+ const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
91
+ await projector.writeState(issue, final);
92
+ if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
93
+ return { terminalReason: reason, detail };
94
+ }
95
+ }
96
+
97
+ async function runPhaseWithRetry(phase, state, ctx) {
98
+ const { io, runner, dir, issue, commandsDir, maxAttempts } = ctx;
99
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
100
+ appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
101
+ try {
102
+ await runner({ commandsDir, commandName: phase.command, args: [String(issue)] });
103
+ } catch (e) {
104
+ // A missing API key / SDK is a config error, not a transient phase
105
+ // failure — surface it distinctly instead of burning retries and
106
+ // escalating to needs-human as if the LLM run itself misbehaved.
107
+ if (e.code === "NO_API_KEY" || e.code === "NO_SDK") throw e;
108
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message });
109
+ continue;
110
+ }
111
+ const outcome = await phase.detectOutcome(state, io);
112
+ if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
113
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
114
+ }
115
+ // Exhausted transient retries → escalate (spec §7).
116
+ return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
117
+ }
118
+
119
+ /**
120
+ * Build a run-log event sequence that reproduces a compact FORGE:STATE index,
121
+ * used to reconstruct the local run-log on hydrate (C2).
122
+ */
123
+ function eventsFromIndex(idx) {
124
+ const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane }];
125
+ for (const phase of idx.committed) {
126
+ const outputs = {};
127
+ if (phase === "build" && idx.branch) outputs.branch = idx.branch;
128
+ if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
129
+ events.push({ event: "PHASE_COMMIT", phase, outputs });
130
+ }
131
+ if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
132
+ return events;
133
+ }
134
+
135
+ function freshState(issue, lane) {
136
+ return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
137
+ branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
138
+ }