forgedock 1.0.22 → 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 (46) hide show
  1. package/README.md +217 -218
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/engine/phases.mjs +148 -0
  4. package/bin/engine/projector.mjs +32 -0
  5. package/bin/engine/reconcile.mjs +11 -0
  6. package/bin/engine/runlog.mjs +79 -0
  7. package/bin/engine/state.mjs +27 -0
  8. package/bin/engine-cli.mjs +50 -0
  9. package/bin/engine.mjs +138 -0
  10. package/bin/forgedock.mjs +464 -1491
  11. package/bin/journey.mjs +783 -0
  12. package/bin/runner.mjs +80 -13
  13. package/bin/settings-hook.mjs +112 -0
  14. package/bin/tui.mjs +6 -3
  15. package/commands/autopilot.md +4 -4
  16. package/commands/deploy-info.md +5 -3
  17. package/commands/design-bench.md +1 -0
  18. package/commands/design-render-critique-loop.md +1 -0
  19. package/commands/design.md +1 -0
  20. package/commands/forge-stats.md +1 -0
  21. package/commands/issue.md +1 -1
  22. package/commands/orchestrate.md +27 -9
  23. package/commands/quality-gate.md +80 -34
  24. package/commands/review-pr-agents.md +7 -7
  25. package/commands/review-pr.md +21 -4
  26. package/commands/work-on/build/implement.md +16 -4
  27. package/commands/work-on/build/validate.md +38 -1
  28. package/commands/work-on/build.md +27 -9
  29. package/commands/work-on/close.md +2 -2
  30. package/commands/work-on/review.md +24 -0
  31. package/commands/work-on-monolithic.md +1 -0
  32. package/commands/work-on.md +45 -3
  33. package/docs/BRAND.md +6 -2
  34. package/docs/CONFIG.md +39 -34
  35. package/docs/site/.vitepress/config.mjs +85 -0
  36. package/docs/site/getting-started.md +15 -8
  37. package/docs/site/index.md +1 -1
  38. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  39. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  40. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  41. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  42. package/package.json +10 -4
  43. package/scripts/verify-env-vars.sh +18 -2
  44. package/scripts/verify-host-headers.sh +4 -4
  45. package/scripts/verify-route-registration.sh +2 -2
  46. package/scripts/worktree-lifecycle.sh +202 -0
package/bin/cinema.mjs ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * bin/cinema.mjs — Cinematic rendering primitives for ForgeDock's TUI.
3
+ *
4
+ * Zero-dependency. Pure presentation: gradients, the block-art F mark, and
5
+ * (in Task 2) shimmer / reveal / progress animation primitives.
6
+ *
7
+ * Two independent gates (spec: degradation ladder):
8
+ * colorMode() — 'truecolor' | '256' | 'none' (NO_COLOR, TTY, TERM)
9
+ * motionEnabled() — animation frames on/off (TTY, FORGE_NO_MOTION, CI, --fast)
10
+ *
11
+ * All functions take env/stdout/argv as parameters (with process defaults)
12
+ * so tests never need module-cache busting.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Palette (spec: Chrome & Ember)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const CHROME_STOPS = ["#f7f3ea", "#d9cfba", "#a99a82", "#6e6252"];
20
+ export const EMBER_STOPS = ["#ff4d00", "#ff8c1a", "#ffd166"];
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Gates
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Decide the color capability of the terminal.
28
+ * @returns {'truecolor'|'256'|'none'}
29
+ */
30
+ export function colorMode(env = process.env, stdout = process.stdout) {
31
+ if (stdout.isTTY !== true) return "none";
32
+ if (env.NO_COLOR) return "none";
33
+ if (env.TERM === "dumb") return "none";
34
+ const ct = (env.COLORTERM || "").toLowerCase();
35
+ if (ct.includes("truecolor") || ct.includes("24bit")) return "truecolor";
36
+ if ((env.TERM || "").includes("256")) return "256";
37
+ // Windows Terminal / modern conhost support truecolor but don't set COLORTERM.
38
+ if (process.platform === "win32") return "truecolor";
39
+ return "256";
40
+ }
41
+
42
+ /**
43
+ * Decide whether animation frames run. Independent of color:
44
+ * NO_COLOR keeps motion (monochrome choreography); non-TTY/CI/--fast kill it.
45
+ * @returns {boolean}
46
+ */
47
+ export function motionEnabled(
48
+ argv = process.argv,
49
+ env = process.env,
50
+ stdout = process.stdout,
51
+ ) {
52
+ if (stdout.isTTY !== true) return false;
53
+ if (env.FORGE_NO_MOTION) return false;
54
+ if (env.CI) return false;
55
+ if (argv.includes("--fast")) return false;
56
+ return true;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Gradient math
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function hexToRgb(hex) {
64
+ const h = hex.replace("#", "");
65
+ return [
66
+ parseInt(h.slice(0, 2), 16),
67
+ parseInt(h.slice(2, 4), 16),
68
+ parseInt(h.slice(4, 6), 16),
69
+ ];
70
+ }
71
+
72
+ /**
73
+ * Sample a multi-stop gradient at position t ∈ [0,1].
74
+ * @returns {[number, number, number]} RGB triple.
75
+ */
76
+ export function sampleGradient(stops, t) {
77
+ const rgb = stops.map(hexToRgb);
78
+ const clamped = Math.min(Math.max(t, 0), 1);
79
+ const seg = Math.min(Math.floor(clamped * (rgb.length - 1)), rgb.length - 2);
80
+ const localT = clamped * (rgb.length - 1) - seg;
81
+ const [a, b] = [rgb[seg], rgb[seg + 1]];
82
+ const mix = (x, y) => Math.round(x + (y - x) * localT);
83
+ return [mix(a[0], b[0]), mix(a[1], b[1]), mix(a[2], b[2])];
84
+ }
85
+
86
+ /** Quantize an RGB triple to the xterm-256 6×6×6 color cube. */
87
+ function to256([r, g, b]) {
88
+ const q = (v) => Math.round((v / 255) * 5);
89
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
90
+ }
91
+
92
+ /** Foreground escape for an RGB triple in the given mode ('' for none). */
93
+ function fg(rgb, mode) {
94
+ if (mode === "none") return "";
95
+ if (mode === "256") return `\x1b[38;5;${to256(rgb)}m`;
96
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
97
+ }
98
+
99
+ /**
100
+ * Render one line of text with a per-character horizontal gradient.
101
+ * Spaces stay uncolored. `phase` shifts the gradient start (for diagonals).
102
+ * In 'none' mode returns the input text verbatim (no reset appended).
103
+ */
104
+ export function gradientLine(text, stops, mode, phase = 0) {
105
+ if (mode === "none") return text;
106
+ const chars = [...text];
107
+ const n = Math.max(chars.length - 1, 1);
108
+ let out = "";
109
+ for (let i = 0; i < chars.length; i++) {
110
+ if (chars[i] === " ") {
111
+ out += " ";
112
+ continue;
113
+ }
114
+ const t = Math.min(i / n + phase, 1);
115
+ out += fg(sampleGradient(stops, t), mode) + chars[i];
116
+ }
117
+ return out + "\x1b[0m";
118
+ }
119
+
120
+ /**
121
+ * Render a block of lines with a diagonal gradient (each row's phase shifts
122
+ * slightly, approximating the logo's 135° gradient).
123
+ * @returns {string[]}
124
+ */
125
+ export function gradientBlock(lines, stops, mode) {
126
+ return lines.map((line, row) => gradientLine(line, stops, mode, row * 0.06));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // The mark — block-art interpretation of the ForgeDock "F" (design approved
131
+ // from the 2026-07-03 brainstorm mockups; geometry may be pixel-tuned).
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export const HERO_MARK = [
135
+ " ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
136
+ " ▄▄██████████████████",
137
+ " ▄█████████████▀▀▀▀▀▀▀▀▀",
138
+ " ▄██████████▀▀",
139
+ " ▀▀▀▀▀ ▄▄███████▀",
140
+ " ▄████████▀",
141
+ " ▄██████▀",
142
+ " ▀▀▀▀",
143
+ ];
144
+
145
+ export const COMPACT_MARK = [
146
+ " ▄▄████████",
147
+ " ▄█████▀▀▀▀▀",
148
+ " ▀▀ ▄████▀",
149
+ " ▀▀▀",
150
+ ];
151
+
152
+ /**
153
+ * Render the chrome-gradient mark.
154
+ * @param {'hero'|'compact'} size
155
+ * @param {'truecolor'|'256'|'none'} mode
156
+ * @returns {string[]}
157
+ */
158
+ export function renderMark(size, mode) {
159
+ const art = size === "hero" ? HERO_MARK : COMPACT_MARK;
160
+ if (mode === "none") return [...art];
161
+ return gradientBlock(art, CHROME_STOPS, mode);
162
+ }
163
+
164
+ /** Ember-gradient text shorthand (wordmarks, act headers). */
165
+ export function ember(text, mode) {
166
+ return gradientLine(text, EMBER_STOPS, mode);
167
+ }
168
+
169
+ /** Promise-based sleep for animation pacing. */
170
+ export function sleep(ms) {
171
+ return new Promise((r) => setTimeout(r, ms));
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Animation primitives (Task 2)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ import { box } from "./tui.mjs";
179
+
180
+ const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
181
+
182
+ /** Ember-tinted status glyph for a result row. */
183
+ function statusGlyph(ok, mode) {
184
+ const rgb = ok ? [255, 179, 71] : [224, 112, 80]; // amber / ember-red
185
+ const glyph = ok ? "✔" : "✖";
186
+ if (mode === "none") return glyph;
187
+ return `${fg(rgb, mode)}${glyph}\x1b[0m`;
188
+ }
189
+
190
+ /** Dim wrapper honoring the mode gate. */
191
+ function dimText(s, mode) {
192
+ return mode === "none" ? s : `\x1b[2m${s}\x1b[22m`;
193
+ }
194
+
195
+ /** Badge like [high] / [med] / [low], colorized per confidence. */
196
+ function badgeText(badge, mode) {
197
+ const label = badge === "medium" ? "med" : badge;
198
+ if (mode === "none") return `[${label}]`;
199
+ const rgb = badge === "high" ? [125, 219, 132] : badge === "medium" ? [232, 192, 96] : [224, 112, 80];
200
+ return `${fg(rgb, mode)}[${label}]\x1b[0m`;
201
+ }
202
+
203
+ /** Ember-bordered fix card (wraps tui.box; border color applied when able). */
204
+ export function fixCard(lines, mode) {
205
+ const boxed = box(lines.map((l) => ` ${l} `), { title: "fix" });
206
+ if (mode === "none") return boxed;
207
+ const ember = fg([255, 107, 53], mode);
208
+ return boxed
209
+ .split("\n")
210
+ .map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${ember}${m}\x1b[0m`))
211
+ .join("\n");
212
+ }
213
+
214
+ function formatRow(label, res, mode) {
215
+ const parts = [` ${statusGlyph(res.ok, mode)} ${label.padEnd(16)}`];
216
+ if (res.detail) parts.push(dimText(String(res.detail), mode));
217
+ if (res.badge) parts.push(badgeText(res.badge, mode));
218
+ return parts.join(" ");
219
+ }
220
+
221
+ /**
222
+ * Reveal rows one at a time: spinner while `run()` is in flight, settling to
223
+ * a ✔/✖ line (+ optional fix card). Without motion: run, print final line.
224
+ * Every row's `run` executes regardless of motion — this is display-only.
225
+ */
226
+ export async function revealRows(
227
+ rows,
228
+ { mode, motion, writer = process.stdout, interval = 80, minDisplayMs = 120 } = {},
229
+ ) {
230
+ const results = [];
231
+ for (const row of rows) {
232
+ let res;
233
+ if (!motion) {
234
+ res = await row.run();
235
+ } else {
236
+ let frame = 0;
237
+ const spinnerColor = fg([255, 107, 53], mode);
238
+ const reset = mode === "none" ? "" : "\x1b[0m";
239
+ writer.write(` ${spinnerColor}${BRAILLE[0]}${reset} ${row.label}\n`);
240
+ const timer = setInterval(() => {
241
+ frame = (frame + 1) % BRAILLE.length;
242
+ writer.write(`\x1b[1A\x1b[2K ${spinnerColor}${BRAILLE[frame]}${reset} ${row.label}\n`);
243
+ }, interval);
244
+ const started = Date.now();
245
+ try {
246
+ res = await row.run();
247
+ const elapsed = Date.now() - started;
248
+ if (elapsed < minDisplayMs) await sleep(minDisplayMs - elapsed);
249
+ } finally {
250
+ clearInterval(timer);
251
+ }
252
+ writer.write("\x1b[1A\x1b[2K");
253
+ }
254
+ writer.write(formatRow(row.label, res, mode) + "\n");
255
+ if (!res.ok && res.fix && res.fix.length > 0) {
256
+ writer.write(fixCard(res.fix, mode) + "\n");
257
+ }
258
+ results.push(res);
259
+ }
260
+ return results;
261
+ }
262
+
263
+ /**
264
+ * One-pass highlight sweep over gradient block art, settling to the static
265
+ * gradientBlock render. In-place redraw via cursor-up; never alt-screen.
266
+ */
267
+ export async function shimmer(
268
+ lines,
269
+ stops,
270
+ { mode, motion, writer = process.stdout, frames = 14, interval = 55 } = {},
271
+ ) {
272
+ const settled = gradientBlock(lines, stops, mode);
273
+ if (!motion || mode === "none") {
274
+ writer.write(settled.join("\n") + "\n");
275
+ return;
276
+ }
277
+ const width = Math.max(...lines.map((l) => l.length));
278
+ for (let f = 0; f <= frames; f++) {
279
+ const band = (f / frames) * (width + 16) - 8;
280
+ const frameLines = lines.map((line, row) => {
281
+ const chars = [...line];
282
+ let out = "";
283
+ for (let i = 0; i < chars.length; i++) {
284
+ if (chars[i] === " ") {
285
+ out += " ";
286
+ continue;
287
+ }
288
+ const t = Math.min(i / Math.max(chars.length - 1, 1) + row * 0.06, 1);
289
+ let rgb = sampleGradient(stops, t);
290
+ const dist = Math.abs(i - band + row * 1.5);
291
+ if (dist < 6) {
292
+ const boost = 1 - dist / 6;
293
+ rgb = rgb.map((v) => Math.round(v + (255 - v) * boost));
294
+ }
295
+ out += `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${chars[i]}`;
296
+ }
297
+ return out + "\x1b[0m";
298
+ });
299
+ if (f > 0) writer.write(`\x1b[${lines.length}A`);
300
+ writer.write(frameLines.join("\n") + "\n");
301
+ await sleep(interval);
302
+ }
303
+ writer.write(`\x1b[${lines.length}A`);
304
+ writer.write(settled.join("\n") + "\n");
305
+ }
306
+
307
+ /**
308
+ * One static frame of the molten progress bar: solid fill with a ▓▒ leading
309
+ * edge and dim ░ remainder. Caller redraws with cursor-up for animation.
310
+ */
311
+ export function moltenBar(current, total, { width = 24, mode } = {}) {
312
+ const ratio = total === 0 ? 1 : Math.min(current / total, 1);
313
+ const filled = Math.round(ratio * width);
314
+ const solidLen = Math.max(filled - 2, 0);
315
+ const edge = "▓▒".slice(0, filled - solidLen);
316
+ const body = "█".repeat(solidLen) + edge;
317
+ const rest = "░".repeat(Math.max(width - filled, 0));
318
+ if (mode === "none") return `[${body}${rest}]`;
319
+ return gradientLine(body, EMBER_STOPS, mode) + dimText(rest, mode);
320
+ }
@@ -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; }