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
package/bin/demo.mjs CHANGED
@@ -61,6 +61,9 @@ export const DEFAULT_DEMO_DIRNAME = "forgedock-demo";
61
61
  * --repo <slug> | --repo=<slug> Override the demo repo (owner/name).
62
62
  * --open Launch Claude Code in the demo dir when found.
63
63
  * --no-open Explicitly disable launching (the default).
64
+ * --run Run the seeded issue through /work-on automatically
65
+ * (requires ANTHROPIC_API_KEY; falls back to print-only
66
+ * instructions when the key is absent).
64
67
  * --help | -h Show usage.
65
68
  *
66
69
  * The first non-flag argument is treated as the target directory (positional
@@ -68,8 +71,8 @@ export const DEFAULT_DEMO_DIRNAME = "forgedock-demo";
68
71
  *
69
72
  * @param {string[]} args
70
73
  * @returns {{positional: string|undefined, dir: string|undefined,
71
- * repo: string|undefined, open: boolean, help: boolean,
72
- * error: string|null}}
74
+ * repo: string|undefined, open: boolean, run: boolean,
75
+ * help: boolean, error: string|null}}
73
76
  */
74
77
  export function parseDemoArgs(args = []) {
75
78
  const out = {
@@ -77,6 +80,7 @@ export function parseDemoArgs(args = []) {
77
80
  dir: undefined,
78
81
  repo: undefined,
79
82
  open: false,
83
+ run: false,
80
84
  help: false,
81
85
  error: null,
82
86
  };
@@ -88,6 +92,8 @@ export function parseDemoArgs(args = []) {
88
92
  out.open = true;
89
93
  } else if (a === "--no-open") {
90
94
  out.open = false;
95
+ } else if (a === "--run") {
96
+ out.run = true;
91
97
  } else if (a === "--dir") {
92
98
  out.dir = args[++i];
93
99
  if (out.dir === undefined) out.error = "--dir requires a path argument";
@@ -145,7 +151,7 @@ function isEmptyDir(dir, fsx) {
145
151
 
146
152
  export function demoUsage() {
147
153
  return [
148
- "Usage: forgedock demo [dir] [--dir <path>] [--repo <owner/name>] [--open]",
154
+ "Usage: forgedock demo [dir] [--dir <path>] [--repo <owner/name>] [--open] [--run]",
149
155
  "",
150
156
  "Stand up a runnable ForgeDock demo at a predictable location (default",
151
157
  "~/forgedock-demo) and print the exact next steps. No configuration needed.",
@@ -155,6 +161,9 @@ export function demoUsage() {
155
161
  " --repo <slug> Demo repo to clone (default: RapierCraftStudios/forgedock-demo).",
156
162
  " --open Launch Claude Code in the demo directory if available.",
157
163
  " --no-open Do not launch Claude Code (default).",
164
+ " --run Automatically run the seeded issue through /work-on to a",
165
+ " merged PR (requires ANTHROPIC_API_KEY; falls back to",
166
+ " print-only instructions when the key is absent).",
158
167
  " -h, --help Show this help.",
159
168
  ].join("\n");
160
169
  }
@@ -186,10 +195,10 @@ export function demoForgeYaml(repoSlug) {
186
195
 
187
196
  /**
188
197
  * Render the success / next-steps card.
189
- * @param {{target: string, source: string, claudeAvailable: boolean}} ctx
198
+ * @param {{target: string, source: string, claudeAvailable: boolean, apiKeyAvailable?: boolean}} ctx
190
199
  * @returns {string}
191
200
  */
192
- export function renderNextSteps({ target, source, claudeAvailable }) {
201
+ export function renderNextSteps({ target, source, claudeAvailable, apiKeyAvailable = false }) {
193
202
  const sourceNote = {
194
203
  cloned: "Cloned the live demo repo.",
195
204
  pulled: "Demo already present — pulled the latest.",
@@ -214,6 +223,15 @@ export function renderNextSteps({ target, source, claudeAvailable }) {
214
223
  ? " claude # open Claude Code here (or re-run with --open)"
215
224
  : " # then open Claude Code in this directory",
216
225
  ];
226
+
227
+ if (!apiKeyAvailable) {
228
+ lines.push(
229
+ "",
230
+ "Tip: set ANTHROPIC_API_KEY and re-run with --run to execute the pipeline",
231
+ "automatically and watch a PR merge without opening Claude Code.",
232
+ );
233
+ }
234
+
217
235
  return lines.join("\n");
218
236
  }
219
237
 
@@ -261,11 +279,12 @@ const defaultLogger = {
261
279
  * Stand up the demo.
262
280
  *
263
281
  * @param {object} opts
264
- * @param {string} opts.forgeHome - Package root (for bundled scaffold).
282
+ * @param {string} opts.forgeHome - Package root (for bundled scaffold and commands/).
265
283
  * @param {string[]} [opts.args] - CLI args after `demo`.
266
284
  * @param {string} [opts.home] - Home dir (default os.homedir()).
267
285
  * @param {string} [opts.cwd] - Working dir (default process.cwd()).
268
286
  * @param {string} [opts.demoRepo] - Override demo repo slug.
287
+ * @param {string} [opts.apiKey] - Anthropic API key (default ANTHROPIC_API_KEY env).
269
288
  * @param {Function} [opts.exec] - (cmd, args, {cwd}) => {status,...}.
270
289
  * @param {object} [opts.fsx] - Filesystem facade.
271
290
  * @param {Function} [opts.commandExists] - (bin) => boolean.
@@ -279,6 +298,7 @@ export async function runDemo(opts = {}) {
279
298
  args = [],
280
299
  home = homedir(),
281
300
  cwd = process.cwd(),
301
+ apiKey = process.env.ANTHROPIC_API_KEY,
282
302
  exec = defaultExec,
283
303
  fsx = defaultFsx,
284
304
  commandExists = defaultCommandExists,
@@ -371,7 +391,72 @@ export async function runDemo(opts = {}) {
371
391
  }
372
392
 
373
393
  const claudeAvailable = commandExists("claude");
374
- logger.log(renderNextSteps({ target, source, claudeAvailable }));
394
+ const apiKeyAvailable = Boolean(apiKey);
395
+
396
+ // --run: drive the seeded issue through /work-on automatically.
397
+ // Requires ANTHROPIC_API_KEY; falls back to print-only instructions gracefully.
398
+ if (parsed.run) {
399
+ if (!apiKeyAvailable) {
400
+ logger.log(
401
+ "\nANTHROPIC_API_KEY is not set — cannot run the pipeline automatically.\n" +
402
+ "Export your key and re-run with --run, or open Claude Code manually:\n",
403
+ );
404
+ logger.log(renderNextSteps({ target, source, claudeAvailable, apiKeyAvailable: false }));
405
+ return { status: "ok", target, source };
406
+ }
407
+
408
+ const commandsDir = forgeHome ? join(forgeHome, "commands") : null;
409
+ if (!commandsDir || !fsx.existsSync(commandsDir)) {
410
+ logger.error(
411
+ "Could not locate the ForgeDock commands/ directory. " +
412
+ "Falling back to manual instructions.",
413
+ );
414
+ logger.log(renderNextSteps({ target, source, claudeAvailable, apiKeyAvailable }));
415
+ return { status: "ok", target, source };
416
+ }
417
+
418
+ logger.log(
419
+ "\nRunning /work-on 1 in the demo repo — watch the full pipeline execute…\n",
420
+ );
421
+
422
+ let runCommand;
423
+ try {
424
+ ({ runCommand } = await import("./runner.mjs"));
425
+ } catch {
426
+ logger.error(
427
+ "Could not load bin/runner.mjs. Falling back to manual instructions.",
428
+ );
429
+ logger.log(renderNextSteps({ target, source, claudeAvailable, apiKeyAvailable }));
430
+ return { status: "ok", target, source };
431
+ }
432
+
433
+ try {
434
+ const result = await runCommand({
435
+ commandsDir,
436
+ commandName: "work-on",
437
+ args: ["1"],
438
+ cwd: target,
439
+ apiKey,
440
+ logger: { log: logger.log, error: logger.error },
441
+ });
442
+ logger.log(`\nDemo pipeline finished with status: ${result.status}`);
443
+ return { status: result.status === "ok" ? "ok" : result.status, target, source };
444
+ } catch (err) {
445
+ if (err.code === "NO_SDK") {
446
+ logger.error(
447
+ "\n@anthropic-ai/sdk is not installed. Install it with:\n" +
448
+ " npm install @anthropic-ai/sdk\n" +
449
+ "Then re-run with --run, or open Claude Code manually:\n",
450
+ );
451
+ } else {
452
+ logger.error(`\nPipeline error: ${err.message}\nFalling back to manual instructions.\n`);
453
+ }
454
+ logger.log(renderNextSteps({ target, source, claudeAvailable, apiKeyAvailable }));
455
+ return { status: "ok", target, source };
456
+ }
457
+ }
458
+
459
+ logger.log(renderNextSteps({ target, source, claudeAvailable, apiKeyAvailable }));
375
460
 
376
461
  if (parsed.open) {
377
462
  if (claudeAvailable) {
@@ -0,0 +1,168 @@
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 reconcile(state, io) {
54
+ // Idempotent resume: FORGE:CONTEXT already present → skip the LLM re-run.
55
+ const m = await issueMarkers(state.issue, io);
56
+ return has(m, "FORGE:CONTEXT") ? { satisfied: true } : { satisfied: false };
57
+ },
58
+ async detectOutcome(state, io) {
59
+ const m = await issueMarkers(state.issue, io);
60
+ // Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
61
+ if (has(m, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
62
+ return { status: "committed", outputs: { skipped: true, which: "context" } };
63
+ },
64
+ },
65
+ {
66
+ id: "architect",
67
+ command: "work-on/build/architect",
68
+ entryCondition: (s) => s.committed.includes("context"),
69
+ async reconcile(state, io) {
70
+ // Idempotent resume: FORGE:ARCHITECT already present → skip the LLM re-run.
71
+ const m = await issueMarkers(state.issue, io);
72
+ return has(m, "FORGE:ARCHITECT") ? { satisfied: true } : { satisfied: false };
73
+ },
74
+ async detectOutcome(state, io) {
75
+ const m = await issueMarkers(state.issue, io);
76
+ return has(m, "FORGE:ARCHITECT")
77
+ ? { status: "committed", outputs: {} }
78
+ : { status: "failed", detail: "no FORGE:ARCHITECT" };
79
+ },
80
+ },
81
+ {
82
+ id: "build",
83
+ command: "work-on/build",
84
+ entryCondition: (s) => s.committed.includes("architect"),
85
+ async reconcile(state, io) {
86
+ // Idempotent resume: branch already ahead of base → treat as done, skip the LLM.
87
+ if (state.branch && (await commitsAhead(state, io)) > 0) {
88
+ const m = await issueMarkers(state.issue, io);
89
+ if (has(m, "FORGE:BUILDER:COMPLETE")) return { satisfied: true, outputs: { branch: state.branch } };
90
+ }
91
+ return { satisfied: false };
92
+ },
93
+ async detectOutcome(state, io) {
94
+ const m = await issueMarkers(state.issue, io);
95
+ const complete = has(m, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
96
+ const ahead = state.branch ? await commitsAhead(state, io) : 0; // … AND real commits
97
+ if (complete && ahead > 0) return { status: "committed", outputs: { branch: state.branch } };
98
+ return { status: "failed", detail: `builder complete=${complete} commitsAhead=${ahead}` };
99
+ },
100
+ },
101
+ {
102
+ id: "review",
103
+ command: "work-on/review",
104
+ entryCondition: (s) => s.committed.includes("build"),
105
+ async reconcile(state, io) {
106
+ const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
107
+ return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
108
+ },
109
+ async detectOutcome(state, io) {
110
+ const pr = await prStatusFor(state, io);
111
+ if (!pr) return { status: "failed", detail: "no PR created" };
112
+ if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
113
+ if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
114
+ return { status: "failed", detail: "PR open, not merged" };
115
+ },
116
+ },
117
+ {
118
+ id: "close",
119
+ command: "work-on/close",
120
+ entryCondition: (s) => s.committed.includes("review"),
121
+ async reconcile(state, io) {
122
+ // Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
123
+ const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
124
+ let j;
125
+ try { j = JSON.parse(out || "{}"); } catch { return { satisfied: false }; }
126
+ const labels = (j.labels || []).map((l) => l.name || l);
127
+ return (j.state === "CLOSED" || labels.includes("workflow:merged"))
128
+ ? { satisfied: true }
129
+ : { satisfied: false };
130
+ },
131
+ async detectOutcome(state, io) {
132
+ const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
133
+ let j;
134
+ try { j = JSON.parse(out || "{}"); } catch { return { status: "failed", detail: "malformed gh response" }; }
135
+ const labels = (j.labels || []).map((l) => l.name || l);
136
+ if (j.state === "CLOSED" || labels.includes("workflow:merged"))
137
+ return { status: "committed", terminalReason: "merged", outputs: {} };
138
+ return { status: "failed", detail: "issue not closed" };
139
+ },
140
+ isTerminalAfter: () => true,
141
+ },
142
+ ];
143
+
144
+ async function openPrFor(state, io) {
145
+ if (!state.branch) return null;
146
+ const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
147
+ try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
148
+ }
149
+ async function prStatusFor(state, io) {
150
+ const n = await openPrFor(state, io);
151
+ if (!n) return null;
152
+ const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
153
+ let j;
154
+ try { j = JSON.parse(out || "{}"); } catch { return null; }
155
+ const labels = (j.labels || []).map((l) => l.name || l);
156
+ return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
157
+ needsHuman: labels.includes("needs-human") };
158
+ }
159
+
160
+ /** The engine's transition function: first uncommitted phase whose gate holds. */
161
+ export function pickPhase(state) {
162
+ if (state.terminal) return null;
163
+ for (const p of PHASES) {
164
+ if (state.committed.includes(p.id)) continue;
165
+ if (p.entryCondition(state)) return p;
166
+ }
167
+ return null;
168
+ }
@@ -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,32 @@
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
+ const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
8
+
9
+ /** @param {import("./phases.mjs").RunState} index */
10
+ export function serializeState(index) {
11
+ // Escape both HTML comment-close forms: the standard "-->" and the "--!>"
12
+ // variant that browsers/HTML parsers also honour. JSON.stringify does not
13
+ // escape either, so a payload value containing them could otherwise close
14
+ // the <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
15
+ const payload = JSON.stringify(index).replace(/--(!?)>/g, "--$1&gt;");
16
+ return `${OPEN}\n${payload}\n${CLOSE}`;
17
+ }
18
+
19
+ /** @returns {import("./phases.mjs").RunState|null} */
20
+ export function parseState(issueBody) {
21
+ const m = BLOCK_RE.exec(issueBody || "");
22
+ if (!m) return null;
23
+ // Inverse of serializeState: restore both "--&gt;" and "--!&gt;".
24
+ try { return JSON.parse(m[1].trim().replace(/--(!?)&gt;/g, "--$1>")); } catch { return null; }
25
+ }
26
+
27
+ /** Replace the FORGE:STATE block in place, or append one if absent. */
28
+ export function upsertStateBlock(body, index) {
29
+ const block = serializeState(index);
30
+ if (BLOCK_RE.test(body || "")) return body.replace(BLOCK_RE, () => block);
31
+ return `${body || ""}\n\n${block}`.trimStart();
32
+ }