forgedock 1.0.22 → 1.1.2

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 (184) hide show
  1. package/README.md +255 -211
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/cinema.mjs +320 -0
  4. package/bin/demo.mjs +92 -7
  5. package/bin/engine/phases.mjs +168 -0
  6. package/bin/engine/projector.mjs +32 -0
  7. package/bin/engine/reconcile.mjs +11 -0
  8. package/bin/engine/runlog.mjs +79 -0
  9. package/bin/engine/state.mjs +32 -0
  10. package/bin/engine-cli.mjs +222 -0
  11. package/bin/engine.mjs +138 -0
  12. package/bin/forgedock.mjs +896 -1540
  13. package/bin/hooks/interactive-engine.mjs +509 -0
  14. package/bin/hooks/pre-tool-use.mjs +498 -0
  15. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  16. package/bin/journey.mjs +841 -0
  17. package/bin/registry.mjs +139 -0
  18. package/bin/report.mjs +447 -0
  19. package/bin/runner.mjs +136 -20
  20. package/bin/settings-hook.mjs +346 -0
  21. package/bin/tui.mjs +6 -3
  22. package/commands/adopt.md +4 -1
  23. package/commands/analytics.md +4 -1
  24. package/commands/audit-agents.md +110 -27
  25. package/commands/audit.md +20 -1
  26. package/commands/autopilot.md +84 -12
  27. package/commands/changelog.md +2 -1
  28. package/commands/ci-audit.md +2 -1
  29. package/commands/cleanup.md +161 -1
  30. package/commands/compat-audit.md +2 -1
  31. package/commands/deploy-info.md +63 -4
  32. package/commands/design-bench.md +6 -1
  33. package/commands/design-render-critique-loop.md +2 -1
  34. package/commands/design.md +1 -0
  35. package/commands/diagnose.md +2 -1
  36. package/commands/explain.md +2 -1
  37. package/commands/forge-stats.md +122 -2
  38. package/commands/forgedock-init.md +1 -1
  39. package/commands/geo-audit.md +3 -0
  40. package/commands/incident-response.md +2 -1
  41. package/commands/issue.md +24 -3
  42. package/commands/milestone.md +77 -12
  43. package/commands/optimize.md +2 -1
  44. package/commands/orchestrate/config.md +67 -0
  45. package/commands/orchestrate/phase-1-resolve.md +159 -0
  46. package/commands/orchestrate/phase-2-triage.md +126 -0
  47. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  48. package/commands/orchestrate/phase-3-dependency.md +591 -0
  49. package/commands/orchestrate/phase-4-execution.md +962 -0
  50. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  51. package/commands/orchestrate/phase-6-report.md +198 -0
  52. package/commands/orchestrate/safety.md +62 -0
  53. package/commands/orchestrate.md +25 -2027
  54. package/commands/pipeline-health/config.md +34 -0
  55. package/commands/pipeline-health/phase-1-context.md +180 -0
  56. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  57. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  58. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  59. package/commands/pipeline-health/phase-5-report.md +284 -0
  60. package/commands/pipeline-health/phase-6-summary.md +52 -0
  61. package/commands/pipeline-health.md +25 -2401
  62. package/commands/pipeline-resume.md +24 -1
  63. package/commands/pipeline-status.md +65 -0
  64. package/commands/qa-sweep.md +4 -1
  65. package/commands/quality-gate.md +326 -42
  66. package/commands/replay.md +2 -1
  67. package/commands/review-pr-agents/api.md +135 -0
  68. package/commands/review-pr-agents/auth.md +137 -0
  69. package/commands/review-pr-agents/billing.md +101 -0
  70. package/commands/review-pr-agents/concurrency.md +145 -0
  71. package/commands/review-pr-agents/database.md +103 -0
  72. package/commands/review-pr-agents/frontend.md +89 -0
  73. package/commands/review-pr-agents/infra.md +385 -0
  74. package/commands/review-pr-agents/protocols.md +185 -0
  75. package/commands/review-pr-agents/scraper.md +185 -0
  76. package/commands/review-pr-agents/security.md +406 -0
  77. package/commands/review-pr-agents.md +45 -1722
  78. package/commands/review-pr-staging.md +196 -40
  79. package/commands/review-pr.md +612 -56
  80. package/commands/rollback.md +2 -1
  81. package/commands/scope.md +2 -1
  82. package/commands/security-audit.md +2 -1
  83. package/commands/signal-planner.md +623 -0
  84. package/commands/sync-ecosystem.md +1 -0
  85. package/commands/test-gate.md +17 -1
  86. package/commands/validate.md +2 -1
  87. package/commands/work-on/build/architect.md +28 -0
  88. package/commands/work-on/build/context.md +2 -0
  89. package/commands/work-on/build/implement.md +19 -5
  90. package/commands/work-on/build/validate.md +62 -6
  91. package/commands/work-on/build.md +187 -30
  92. package/commands/work-on/close.md +132 -5
  93. package/commands/work-on/decompose.md +19 -1
  94. package/commands/work-on/investigate.md +117 -2
  95. package/commands/work-on/review.md +48 -4
  96. package/commands/work-on-monolithic.md +8 -2
  97. package/commands/work-on.md +247 -7
  98. package/docs/BRAND.md +16 -5
  99. package/docs/CI.md +2 -2
  100. package/docs/CODEX.md +36 -1
  101. package/docs/CONFIG.md +44 -42
  102. package/docs/FORGE-PROTOCOL.md +15 -1064
  103. package/docs/anthropic-devrel-outreach.md +2 -2
  104. package/docs/articles/command-decomposition-token-savings.md +214 -0
  105. package/docs/articles/model-release-playbook.md +131 -0
  106. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  107. package/docs/claude-breakpoints.json +75 -5
  108. package/docs/design/abc-benchmark.md +1 -1
  109. package/docs/design/design-spec-schema.md +1 -1
  110. package/docs/design/divergent-generation.md +1 -1
  111. package/docs/design/eval-gate-runbook.md +178 -0
  112. package/docs/design/show-hn-draft.md +26 -1
  113. package/docs/design/token-cost-baseline-1298.md +59 -0
  114. package/docs/eval/README.md +117 -0
  115. package/docs/eval/corpus/seed-v1.json +6 -0
  116. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  117. package/docs/site/annotations-explained.md +24 -0
  118. package/docs/site/command-learning-path.md +51 -14
  119. package/docs/site/command-reference.md +339 -113
  120. package/docs/site/for-companies.md +95 -0
  121. package/docs/site/forge-annotation-protocol.md +12 -256
  122. package/docs/site/getting-started.md +58 -12
  123. package/docs/site/github-is-the-memory.md +113 -0
  124. package/docs/site/how-it-works.md +3 -13
  125. package/docs/site/index.md +10 -2
  126. package/docs/spec/eval-run-result.md +147 -0
  127. package/docs/spec/forge-protocol-v1.md +441 -18
  128. package/docs/spec/label-state-machine.md +55 -0
  129. package/docs/spec/review-protocol.md +158 -0
  130. package/docs/spec-graph-schema.md +28 -0
  131. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  132. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  133. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  134. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  135. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  136. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  137. package/examples/forgedock-demo/README.md +41 -22
  138. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  139. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  140. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  141. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  142. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  143. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  144. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  145. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  146. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  147. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  148. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  149. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  150. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  151. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  152. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  153. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  154. package/examples/forgedock-demo/src/db.js +36 -11
  155. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  156. package/examples/forgedock-demo/src/server.js +4 -1
  157. package/package.json +12 -4
  158. package/packages/protocol/LICENSE +21 -0
  159. package/packages/protocol/src/cli.js +135 -0
  160. package/packages/protocol/src/emit.js +146 -0
  161. package/packages/protocol/src/index.js +43 -0
  162. package/packages/protocol/src/parse.js +229 -0
  163. package/packages/protocol/src/types.js +171 -0
  164. package/packages/protocol/src/validate.js +122 -0
  165. package/scripts/bench-topology-cost.mjs +372 -0
  166. package/scripts/check-command-docs-drift.sh +198 -0
  167. package/scripts/check-protocol-restatements.sh +176 -0
  168. package/scripts/check-registry/README.md +72 -0
  169. package/scripts/check-registry/manifest.json +6 -0
  170. package/scripts/code-index.sh +454 -0
  171. package/scripts/conformance-check.mjs +168 -0
  172. package/scripts/doctor-pipeline-state.sh +484 -0
  173. package/scripts/eval-gate-baseline.json +17 -0
  174. package/scripts/eval-gate-scorecard.mjs +242 -0
  175. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  176. package/scripts/flaky-quarantine.sh +241 -0
  177. package/scripts/forge-annotation.sh +567 -0
  178. package/scripts/issue-dedup.sh +236 -0
  179. package/scripts/validate-annotation-node.mjs +233 -0
  180. package/scripts/verify-env-vars.sh +18 -2
  181. package/scripts/verify-host-headers.sh +4 -4
  182. package/scripts/verify-route-registration.sh +2 -2
  183. package/scripts/worktree-lifecycle.sh +202 -0
  184. package/templates/devdocs/agent/using-forgedock.md +14 -0
@@ -0,0 +1,222 @@
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
+ * `forgedock resume-stalled [--dry-run] [--lane <lane>]` enumerates all in-flight
5
+ * issues, calls scanStalls, and re-dispatches each stalled issue via run-issue.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { runIssue } from "./engine.mjs";
12
+ import { makeProjector } from "./engine/projector.mjs";
13
+
14
+ const pexec = promisify(execFile);
15
+
16
+ /** Real gh/git accessors. */
17
+ export function makeIo() {
18
+ const run = (bin) => async (args) => {
19
+ const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
20
+ return stdout;
21
+ };
22
+ return { gh: run("gh"), git: run("git") };
23
+ }
24
+
25
+ export function runDir() { return join(homedir(), ".forge", "runs"); }
26
+
27
+ /**
28
+ * Resolves the repo `gh` would target by default (i.e. resolved from the cwd
29
+ * git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
30
+ * against the ambient context — see `assertRepoMatchesCwd`.
31
+ * @param {{gh: Function}} io
32
+ * @returns {Promise<string|null>} "owner/repo", or null if it can't be determined
33
+ */
34
+ async function resolveDefaultRepo(io) {
35
+ try {
36
+ const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
37
+ const repo = String(out).trim();
38
+ return repo || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Guards against cross-repo state confusion (forge#1593): `--repo` used to be
46
+ * threaded into the `gh issue list` enumeration call but never into the
47
+ * projector's state reads/writes or into re-dispatch, so a mismatched
48
+ * `--repo` silently read/wrote FORGE:STATE in the cwd-resolved repo instead
49
+ * of the requested one. Fully threading `--repo` through every `io.gh`/
50
+ * `io.git` call site the engine makes during a run (phases.mjs, reconcile.mjs,
51
+ * projector.mjs) would be a much larger, riskier change. Instead, this fails
52
+ * closed: if `--repo` is given and doesn't match the repo `gh` would use by
53
+ * default, refuse to run at all rather than silently mixing repos.
54
+ * @param {{gh: Function}} io
55
+ * @param {string|null} repo
56
+ * @returns {Promise<void>}
57
+ */
58
+ async function assertRepoMatchesCwd(io, repo) {
59
+ if (!repo) return;
60
+ const defaultRepo = await resolveDefaultRepo(io);
61
+ if (defaultRepo === null) {
62
+ throw new Error(
63
+ `--repo ${repo} was given, but the current repo could not be determined (\`gh repo view\` failed) to ` +
64
+ `verify it matches. Refusing to run cross-repo without verification.`
65
+ );
66
+ }
67
+ if (defaultRepo !== repo) {
68
+ throw new Error(
69
+ `--repo ${repo} does not match the current repo (${defaultRepo}). Cross-repo dispatch is not supported — ` +
70
+ `state reads/writes (issue view/edit) are cwd-scoped and would silently target ${defaultRepo} instead of ` +
71
+ `${repo}. Run this command with cwd set to a checkout of ${repo}, or omit --repo to operate on ${defaultRepo}.`
72
+ );
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {number[]} issues
78
+ * @param {{readState:(i:number)=>Promise<{terminal:boolean,lease:?{until:number}}|null>}} io
79
+ * @param {number} now
80
+ * @returns {Promise<number[]>} issues that appear stalled (expired lease, non-terminal)
81
+ */
82
+ export async function scanStalls(issues, io, now) {
83
+ const stalled = [];
84
+ for (const i of issues) {
85
+ const s = await io.readState(i);
86
+ if (s && !s.terminal && s.lease && s.lease.until < now) stalled.push(i);
87
+ }
88
+ return stalled;
89
+ }
90
+
91
+ /**
92
+ * @param {string[]} argv
93
+ * @param {{io?: {gh: Function}, runIssue?: Function}} [deps]
94
+ * Injectable for tests — defaults to real `gh`/`git` (makeIo()) and the real
95
+ * `runIssue` engine driver.
96
+ */
97
+ export async function runFromCli(argv, deps = {}) {
98
+ const issue = parseInt(argv[0], 10);
99
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
100
+ const lane = flag(argv, "--lane");
101
+ if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
102
+ const repo = flag(argv, "--repo");
103
+ const io = deps.io ?? makeIo();
104
+ await assertRepoMatchesCwd(io, repo);
105
+ const runIssueFn = deps.runIssue ?? runIssue;
106
+ const agentId = `cli_${process.pid}`;
107
+ const res = await runIssueFn({ issue, dir: runDir(), agentId, lane, io,
108
+ runner: (await import("./runner.mjs")).runCommand, now: () => Date.now() });
109
+ console.log(`issue #${issue} → ${res.terminalReason}`);
110
+ return res;
111
+ }
112
+
113
+ /**
114
+ * `forgedock resume-stalled [--dry-run] [--lane <lane>] [--repo <owner/repo>]`
115
+ *
116
+ * Enumerates all open issues carrying non-terminal workflow labels, reads each
117
+ * issue's FORGE:STATE block via the projector, identifies those with an expired
118
+ * lease (stalled), and re-dispatches them through the existing run-issue path.
119
+ *
120
+ * Flags:
121
+ * --dry-run Print the stalled list and exit 0 without dispatching anything.
122
+ * --lane Lane to pass to run-issue (required — no default to prevent accidental production targeting).
123
+ * --repo GitHub repo (owner/repo). Must match the repo `gh` resolves by default
124
+ * (the cwd git remote) — cross-repo dispatch is refused (forge#1593),
125
+ * since state reads/writes are cwd-scoped and would otherwise silently
126
+ * target the wrong repo. Omit --repo to operate on the cwd-resolved repo.
127
+ *
128
+ * Per-issue dispatch failures are caught and isolated — one issue's engine error
129
+ * (e.g. NO_API_KEY/NO_SDK or any other uncaught phase error from runIssue) does
130
+ * not abort dispatch of the remaining stalled issues in the batch. Failures are
131
+ * recorded in the returned `failed` array; the caller decides how to surface them.
132
+ *
133
+ * @param {string[]} argv
134
+ * @param {{io?: {gh: Function}, dispatch?: (argv: string[]) => Promise<any>}} [deps]
135
+ * Injectable for tests — defaults to real `gh`/`git` (makeIo()) and the real
136
+ * `runFromCli` dispatcher.
137
+ */
138
+ export async function resumeStalledFromCli(argv, deps = {}) {
139
+ const dryRun = argv.includes("--dry-run");
140
+ const lane = flag(argv, "--lane");
141
+ if (!lane) throw new Error("--lane is required for resume-stalled: e.g. --lane main or --lane staging.");
142
+ const repo = flag(argv, "--repo");
143
+
144
+ const io = deps.io ?? makeIo();
145
+ await assertRepoMatchesCwd(io, repo);
146
+ const dispatch = deps.dispatch ?? runFromCli;
147
+ const projector = makeProjector(io);
148
+ const now = Date.now();
149
+
150
+ // Collect candidate issue numbers from all non-terminal workflow labels.
151
+ const ACTIVE_LABELS = [
152
+ "workflow:investigating",
153
+ "workflow:ready-to-build",
154
+ "workflow:building",
155
+ "workflow:in-review",
156
+ ];
157
+
158
+ const repoFlag = repo ? ["--repo", repo] : [];
159
+ const issueSet = new Set();
160
+
161
+ for (const label of ACTIVE_LABELS) {
162
+ try {
163
+ const out = await io.gh([
164
+ "issue", "list",
165
+ ...repoFlag,
166
+ "--state", "open",
167
+ "--label", label,
168
+ "--limit", "100",
169
+ "--json", "number",
170
+ ]);
171
+ const items = JSON.parse(out);
172
+ for (const { number } of items) issueSet.add(number);
173
+ } catch {
174
+ // gh may return non-zero when no issues match — treat as empty.
175
+ }
176
+ }
177
+
178
+ if (issueSet.size === 0) {
179
+ console.log("resume-stalled: no in-flight issues found.");
180
+ return { stalled: [], dispatched: [], failed: [] };
181
+ }
182
+
183
+ const candidates = [...issueSet];
184
+ const stalled = await scanStalls(candidates, projector, now);
185
+
186
+ if (stalled.length === 0) {
187
+ console.log("resume-stalled: all in-flight issues have active leases — nothing stalled.");
188
+ return { stalled: [], dispatched: [], failed: [] };
189
+ }
190
+
191
+ console.log(`resume-stalled: ${stalled.length} stalled issue(s) found: ${stalled.map((n) => `#${n}`).join(", ")}`);
192
+
193
+ if (dryRun) {
194
+ console.log("resume-stalled: --dry-run — not dispatching.");
195
+ return { stalled, dispatched: [], failed: [] };
196
+ }
197
+
198
+ const dispatched = [];
199
+ const failed = [];
200
+ for (const issue of stalled) {
201
+ console.log(`resume-stalled: dispatching #${issue} …`);
202
+ try {
203
+ await dispatch([String(issue), "--lane", lane, ...(repo ? ["--repo", repo] : [])]);
204
+ dispatched.push(issue);
205
+ } catch (err) {
206
+ const message = err?.message ?? String(err);
207
+ console.error(`resume-stalled: #${issue} failed: ${message}`);
208
+ failed.push({ issue, error: message });
209
+ }
210
+ }
211
+
212
+ if (failed.length > 0) {
213
+ console.log(
214
+ `resume-stalled: ${dispatched.length} dispatched, ${failed.length} failed: ` +
215
+ `${failed.map((f) => `#${f.issue}`).join(", ")}`,
216
+ );
217
+ }
218
+
219
+ return { stalled, dispatched, failed };
220
+ }
221
+
222
+ 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
+ }