forgedock 1.29.1 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -4,6 +4,9 @@
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { reviewSkeleton } from "./conformance.mjs";
7
+ import { seqNumber } from "./sequence.mjs";
8
+ import { capText, FIX_CAPS, FIX_PROMPT_MAX } from "./fixround.mjs";
9
+ import { SPLIT_MIN_FILES, SPLIT_MIN_LINES } from "./decompose.mjs";
7
10
 
8
11
  export const approxTokens = (s) => Math.ceil(String(s).length / 4);
9
12
  export const LEAN_DIR = ".forge-lean";
@@ -15,8 +18,11 @@ export const CONTRACT_FILE = `${LEAN_DIR}/contract.json`;
15
18
 
16
19
  /** Change-scoped checks with shared incremental caches (#4218): the builder runs the scoped commands this CLI prints. */
17
20
  export const CHECKS_CLI = fileURLToPath(new URL("./checks.mjs", import.meta.url));
21
+ /** #4364: `forgedock check`, the engine's validate and pre-PR checks run in-session before the result is written. */
22
+ export const FORGEDOCK_CLI = fileURLToPath(new URL("../forgedock.mjs", import.meta.url));
18
23
 
19
- const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific test files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare against the base with git show or git diff origin/<base> instead. Never install browsers. Keep all scratch files under $TMPDIR. When $FORGE_HOST_PARALLELISM is set, cap verification workers at it: turbo --concurrency, vitest/jest --maxWorkers, playwright --workers, node --test --test-concurrency (eslint via turbo). For any typecheck, lint or test you run, first run \`node ${CHECKS_CLI} --base origin/<base>\` after your edits and run the scoped commands it prints instead of the repo's full tsc/eslint/test scripts (it widens to the full command itself when impact is unknown: lockfile, tsconfig, CI config); once they pass, run it again with --publish to share the incremental caches. node_modules/.venv may be hardlinked read-only from a shared cache: after changing a lockfile, or when an install fails with EACCES, delete that dir first (rm -rf is safe), then install; never chmod or edit files inside it.`;
24
+ const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare with git show or git diff origin/<base>. Never install browsers. Keep scratch files under $TMPDIR. Cap verification workers at $FORGE_HOST_PARALLELISM when set: turbo --concurrency, vitest/jest --maxWorkers, playwright --workers, node --test --test-concurrency (eslint via turbo). For any typecheck, lint or test you run, first run \`node ${CHECKS_CLI} --base origin/<base>\` after your edits and run the scoped commands it prints instead of the full tsc/eslint/test scripts (it widens itself when impact is unknown: lockfile, tsconfig, CI config); once they pass, rerun it with --publish to share the incremental caches. Lint changed packages only, never dependents (turbo ...[base], nx affected). node_modules/.venv may be hardlinked read-only from a shared cache: after changing a lockfile, or when an install fails with EACCES, delete that dir first (rm -rf is safe), then install; never chmod or edit files inside it.
25
+ Quiet output (tool results are re-read every later turn): run tests quietly (-q or a dot/summary reporter); on failure read \`tail -40\` plus the failing test ids (grep FAILED / not ok / FAIL), never the whole log. Send output over 8 KB to a $TMPDIR file; tail or grep it. Issue independent read-only calls (reads, greps, git log/blame, gh) in the same turn or one shell call. Read each file's ranges once (sed -n a,bp or Read offset/limit, never a whole large file) and keep notes; don't re-read it.`;
20
26
 
21
27
  /** Verify-by-CI (#4167): in ci/hybrid mode the engine runs the repo's CI on GitHub runners; the host runs only light checks. */
22
28
  const LIGHT = "run only formatters and linters on the files you changed";
@@ -42,6 +48,60 @@ export function precheckFailurePrompt(failures, mode = "local") {
42
48
  return `The repo's declared check commands failed in this worktree, before the PR is opened:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). Re-run each failing command, ${mode === "ci" ? `${LIGHT} otherwise, ` : ""}commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
43
49
  }
44
50
 
51
+ /** #4406: the tree after merging the base fails the post-merge check commands (verify.post_merge or the scoped typecheck). */
52
+ export function postMergeFailurePrompt(failures, mode = "local") {
53
+ const list = failures.slice(0, 10).map((f) => `#### \`${f.command}\` (exit ${f.rc})\n${f.output ? `\`\`\`\n${f.output}\n\`\`\`` : "(no output)"}`).join("\n\n");
54
+ return `After merging the base into this branch, the merged tree fails these check commands (sibling changes merged without a text conflict, e.g. duplicate keys or same-named symbols):\n\n${list}\n\nFix the cause keeping both this fix's intent and the base's changes (rename rather than drop either side). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
55
+ }
56
+
57
+ /** #4406: names this change newly exports that another module of the same app or package already declares. */
58
+ export function duplicateExportsPrompt(findings, mode = "local") {
59
+ const rows = findings.slice(0, 30).map((d) => `- ${d.symbol}: ${d.path} (also exported by ${(d.others ?? []).join(", ")})`).join("\n");
60
+ return `This change exports names that another module of the same app or package already exports:\n${rows}\n\nRename the new export or reuse the existing one, and update every reference and any registry, map or test keyed by the name (same-named entries collide or leave one unprobed). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
61
+ }
62
+
63
+ /** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
64
+ export const RESTART_DIFF_MAX_BYTES = 40 * 1024;
65
+
66
+ /**
67
+ * #4390: appended to the restart prompt when the engine stopped a builder session at the context ceiling: the fresh
68
+ * session continues from the frozen contract, the branch (`git diff --stat` + bounded hunks) and the last validate result.
69
+ */
70
+ export function contextRestartPrompt({ stat = "", diff = "", base = "", check = "", ceiling = 0 } = {}) {
71
+ const d = boundDiff(String(diff ?? ""), { stat, base, maxBytes: RESTART_DIFF_MAX_BYTES }).trim();
72
+ const c = String(check ?? "").trim();
73
+ return `\n\nContext restart: the previous session on this lane reached the ${ceiling}-token context ceiling and the engine stopped it. Its history is gone, its work is not: ${CONTRACT_FILE} is still the frozen authority, and the branch holds the committed work (uncommitted edits were saved in a \`wip(forge): checkpoint\` commit). Continue from this state; do not start over, and do not re-read what the summary below already shows.
74
+
75
+ <diff_stat base="${base}">
76
+ ${String(stat ?? "").trim() || "(no changes yet)"}
77
+ </diff_stat>
78
+
79
+ <diff>
80
+ ${d || "(no changes yet)"}
81
+ </diff>
82
+
83
+ Last validate result (${LEAN_DIR}/self-check.json, from \`forgedock check\`): ${c ? `\n${c.length > 4000 ? `${c.slice(0, 4000)}\n[truncated]` : c}` : "none recorded; run the validate commands."}`;
84
+ }
85
+
86
+ /** #4392: the implement turn budget is spent; one bounded fresh session either finishes or stops with a typed digest. */
87
+ export function turnBudgetWrapupPrompt({ size = "?", budget = 0, used = 0, grant = 5, stat = "", diff = "", base = "", check = "" } = {}) {
88
+ const d = boundDiff(String(diff ?? ""), { stat, base, maxBytes: RESTART_DIFF_MAX_BYTES }).trim();
89
+ const c = String(check ?? "").trim();
90
+ return `\n\nTurn budget spent: this lane is size ${size} with an implement budget of ${budget} turns, and ${used} are used. The engine stopped the previous session and saved its work in a \`wip(forge): checkpoint\` commit. You have at most ${grant} turns. Pick one, do not grind:
91
+ - Finish: if the work is complete or nearly so, commit what works, run the validate commands, and write ${RESULT_FILE} with status "fixed" as usual.
92
+ - Stop: otherwise commit nothing new and write ${RESULT_FILE} as {"status":"budget-exceeded","summary":"<one line>","digest":"<what is done, what remains, why it is bigger than planned, and your recommendation: retry, split or re-scope>"}.
93
+
94
+ <diff_stat base="${base}">
95
+ ${String(stat ?? "").trim() || "(no changes yet)"}
96
+ </diff_stat>
97
+
98
+ <diff>
99
+ ${d || "(no changes yet)"}
100
+ </diff>
101
+
102
+ Last validate result (${LEAN_DIR}/self-check.json): ${c ? `\n${c.length > 4000 ? `${c.slice(0, 4000)}\n[truncated]` : c}` : "none recorded."}`;
103
+ }
104
+
45
105
  /** CI could not run (no workflow run, no checks, dispatch refused): the local fallback (#4167). */
46
106
  export function ciUnavailablePrompt() {
47
107
  return `CI verification is unavailable for this branch, so verification falls back to this host. Run every validate command locally (targeted, as the host rules say), fix any failure, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE} with each command's real exit code. Do not push or touch GitHub.`;
@@ -79,21 +139,32 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
79
139
  If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
80
140
 
81
141
  2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
82
- {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>"}],
142
+ {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
83
143
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
84
144
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
85
145
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
86
146
  "decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
87
- Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
88
- A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when there is none. Then stop.
147
+ Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
148
+ A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
149
+ Scope is enforced by the engine: it checks the changed files against the deliverables plus typed amendments itself, so do not write a file-allowlist or denylist validate (git diff --name-only | grep); the engine drops such a validate at freeze.
150
+ A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when none. Then stop.
89
151
 
90
152
  ${BLOCK_RULES}`;
91
153
  }
92
154
 
155
+ /**
156
+ * #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
157
+ * `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
158
+ */
159
+ export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result.
160
+ 2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
161
+ 3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
162
+ 4. Scope: does the diff change only deliverable paths and tests?`;
163
+
93
164
  /** Builder call 2, same session: implement the frozen contract. */
94
165
  export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
95
166
  const remote = mode === "ci" || mode === "hybrid";
96
- return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
167
+ return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, run the repo's formatter on the files you changed, and commit. Do not push or touch GitHub.
97
168
 
98
169
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
99
170
 
@@ -102,8 +173,10 @@ The contract changes only through typed amendments in ${RESULT_FILE}, each with
102
173
  - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
103
174
  - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a contracted requirement that becomes separate work or a post-deploy check. The engine files it as a tracked follow-up.
104
175
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
105
- A new dependency between workspace packages in a package.json that no deliverable names (that package.json, with the package in its change) is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
106
- If the repository has scripts/check-devdocs-freshness.sh, its docs must move with the code: a behaviour change under the paths it checks needs the matching module dossier (devdocs/modules/*.md, per devdocs/index.yaml) updated in the same change. Run it against the base before writing the result; the engine runs it too and resumes you once if it fails.
176
+ A new dependency between workspace packages in a package.json that no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
177
+ If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base before writing the result; the engine runs it too.
178
+
179
+ Before writing ${RESULT_FILE}, after your last edit, run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes; if it cannot pass, report the problem as blocked.
107
180
 
108
181
  When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
109
182
  ### Approach
@@ -112,7 +185,13 @@ When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comment
112
185
  ### Verification (each validate command, its result, and whether the new test fails without the fix)
113
186
  ### Notes (behavior changes, env vars)
114
187
 
115
- Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirements":[{"id":"R1","status":"implemented|partial|missing","evidence":"file:line"}],"validation":[{"id":"V1","command":"","exit":0}],"tests":[{"command":"","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}],"amendments":[]}
188
+ Last step before writing ${RESULT_FILE}: self-review against the reviewer's rubric. Answer each question below yes or no against \`git diff <base>...HEAD\` (base: the lane the contract was frozen against), with evidence for each (path:line, or a command and its result); fix any "no" in this session and commit before you answer again:
189
+ <rubric>
190
+ ${REVIEW_RUBRIC}
191
+ </rubric>
192
+
193
+ Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirements":[{"id":"R1","status":"implemented|partial|missing","evidence":"file:line","instrumented":"<true if out_of_band bench+job committed>"}],"validation":[{"id":"V1","command":"","exit":0}],"tests":[{"command":"","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}],"self_review":[{"question":"1","answer":"yes|no","evidence":""}],"amendments":[]}
194
+ Optional "facts":{"decisions":[],"rejected":[{"approach":"","why":""}],"hotspots":[{"at":"file:line","why":""}],"conventions":[]}; rejected: approaches you dropped, with why.
116
195
  Docker: start containers with docker run --rm; pass --label for each entry of $FORGE_DOCKER_LABELS on every docker run, volume create, network create and build (compose: on services, volumes, networks); tear down via trap/finally.
117
196
  If you started Docker containers or volumes that are still there, list their names in ${RESULT_FILE} as "resources":{"containers":[],"volumes":[]} so the engine removes them when the lane ends.
118
197
  If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
@@ -147,6 +226,39 @@ export function hotspotBuildRule(facts) {
147
226
  return `\n\nDon't grow a file over ${facts.file_lines} lines by more than ${facts.growth_lines} lines. Put new code next to it.`;
148
227
  }
149
228
 
229
+ /** #4322: tests that enumerate the siblings of a module the issue names (bin/lean/enumerating.mjs), for the plan; "" when none. */
230
+ export function enumeratingPlanNote(list) {
231
+ if (!Array.isArray(list) || !list.length) return "";
232
+ return `\n\nEnumerating tests (found by the engine): these tests list every member of a module or directory this issue names, so a new export, table, job, action, namespace or file there must join their list or CI goes red. List each one you will touch under Affected Files in ${INVESTIGATION_FILE}, and add a validate command that runs only those tests.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line} (${e.why})`).join("\n")}`;
233
+ }
234
+
235
+ const NOT_MEMBER = `or, when the symbol is not a member of that list, record {"symbol":"<name>","test":"<test path>","why":"<why it is not a member>"} in ${RESULT_FILE} "not_members"`;
236
+
237
+ /** #4322: the builder rule citing the plan's enumerating tests; "" when none. */
238
+ export function enumeratingBuildRule(list) {
239
+ if (!Array.isArray(list) || !list.length) return "";
240
+ return `\n\nEnumerating tests: add every new exported symbol, table, job, action, namespace or file to each test listed below whose list enumerates its siblings, one test-list edit per new symbol, ${NOT_MEMBER}.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line}`).join("\n")}`;
241
+ }
242
+
243
+ /** #4322: after implement, new symbols that tests enumerating their siblings do not name. */
244
+ export function missingEntriesPrompt(missing, mode = "local") {
245
+ return `These tests enumerate the siblings of symbols or files this change adds, but do not name them (CI will fail on the stale list):\n${missing.slice(0, 30).map((m) => `- ${m.symbol} (${m.module}) -> ${m.test}:${m.line}`).join("\n")}\n\nFor each, add the symbol to that test's list, ${NOT_MEMBER}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
246
+ }
247
+
248
+ const RENUMBER_RULE = "Renumber the lane's files, never the base's: rename each with git mv to its new number, keep the SQL (or other migration source) byte-identical, change a numbered document (e.g. an ADR) only in its own number (its title), regenerate the snapshot and journal with the repo's own migration generator (never hand- or text-merge the journal), keep journal entries strictly ordered, and update every reference to the old name, including the series index (e.g. docs/adr/README.md) so it lists both the base's entry and yours.";
249
+
250
+ /** #4322: lane-added sequence-numbered files whose number the base already uses, at hand-off or after a base-sync. */
251
+ export function sequencePrompt(collisions, lane, mode = "local") {
252
+ const rows = collisions.slice(0, 30).map((c) => `- ${c.lane} -> number ${c.next}${c.base.length ? ` (base has ${c.base.join(", ")})` : " (moves to keep the order)"}`).join("\n");
253
+ return `origin/${lane} already uses sequence numbers this change adds:\n${rows}\n\nFirst bring the branch up to date with \`git merge --no-edit origin/${lane}\` if it is not (already fetched; never rebase or force). ${RENUMBER_RULE} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
254
+ }
255
+
256
+ /** #4322: the base-sync conflict addition when a conflicted file is a numbered migration or a journal; "" otherwise. */
257
+ export function sequenceSyncNote(files) {
258
+ const hit = (files ?? []).some((f) => seqNumber(f) || /(?:^|\/)_journal\.json$/.test(String(f)));
259
+ return hit ? ` A numbered migration or its journal conflicts: do not text-merge it. ${RENUMBER_RULE}` : "";
260
+ }
261
+
150
262
  export function contractCorrectionPrompt(problems) {
151
263
  return `Your ${CONTRACT_FILE} is not valid:\n${problems.slice(0, 20).map((p) => `- ${p}`).join("\n")}\n\nFix the contract (planning only, no code, no commit) and stop. Do not push or touch GitHub.`;
152
264
  }
@@ -169,12 +281,22 @@ export function docsPrompt(output, mode = "local") {
169
281
  return `The repository's docs freshness check (scripts/check-devdocs-freshness.sh) fails on this change:\n${String(output ?? "").slice(0, 4000)}\n\nUpdate the module dossier it names (devdocs/modules/*.md; devdocs/index.yaml maps files to dossiers) to describe the behaviour you changed, in the dossier's existing style and in a few lines. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
170
282
  }
171
283
 
284
+ /**
285
+ * #4363: a fix round in a fresh session (no transcript). It carries the site's task prompt (which holds the capped
286
+ * failure output), the lane diff, the frozen contract and the issue title and summary, each capped; under FIX_PROMPT_MAX.
287
+ */
288
+ export function fixSessionPrompt({ task, diff = "", stat = "", base = "", contract = null, title = "", summary = "" }) {
289
+ const contractText = capText(typeof contract === "string" ? contract : JSON.stringify(contract ?? {}, null, 1), FIX_CAPS.contract);
290
+ const p = `${HOST_RULES}\n\nYou are fixing one failure on an existing branch in this worktree. You have no earlier session: the issue, the frozen contract and the branch's diff against ${base || "the lane base"} are below; read any file you need.\n\n## Issue: ${String(title).slice(0, 300)}\n${capText(summary, FIX_CAPS.summary)}\n\n## Frozen contract (${CONTRACT_FILE})\n\`\`\`json\n${contractText}\n\`\`\`\n\n## Diff (\`git diff ${base || "<base>"}...HEAD\`)\n\`\`\`diff\n${boundDiff(diff, { stat, base, maxBytes: FIX_CAPS.diff })}\n\`\`\`\n\n## The failure to fix\n${capText(task, FIX_CAPS.task)}`;
291
+ return capText(p, FIX_PROMPT_MAX - 100);
292
+ }
293
+
172
294
  /** Human-only block reasons (#4124). Everything else agents resolve: a dependency, a split, prod data monitoring can read. */
173
295
  export const HUMAN_BLOCK_REASONS = Object.freeze(["credentials", "spend", "promotion", "customer", "product-decision"]);
174
296
 
175
297
  const BLOCK_RULES = `"blocked" needs a typed "block" in ${RESULT_FILE}, one of:
176
298
  - {"kind":"dependency","issues":[N]}: an open upstream issue must land first. The engine requeues this issue behind it.
177
- - {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0]}]}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues).
299
+ - {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0],"files":["<path>"]}],"size":{"files":N,"lines":N}}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues; files = the files or dirs each will edit, also accepted as "paths") only when every sub-issue's files are disjoint from the others' or the whole change's size estimate exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines; otherwise keep one lane: an ordered checklist in the contract with a validate per item. In an admitted split, when siblings would edit the same hub (a barrel or index export list, a union type, a shared test fixture, an index doc), pre-stage its slots in the earliest sub-issue and make the others depend on it, or give each sibling its own per-feature file; the engine serialises sub-issues that still share a file.
178
300
  - {"kind":"human","why":"${HUMAN_BLOCK_REASONS.join("|")}"}: only an external action agents cannot take, or a product decision too critical for agents.
179
301
  "An upstream issue must land first", "needs a split" and "needs prod data that read-only monitoring can read" are never "human".`;
180
302
 
@@ -207,7 +329,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
207
329
 
208
330
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
209
331
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
210
- - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with repo-relative path:line evidence (e.g. src/app/route.ts:12, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change.
332
+ - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
211
333
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
212
334
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
213
335
  - regressions: concrete breakages only, each with path:line.
@@ -249,7 +371,7 @@ export function boundDiff(diff, { stat = "", base = "", maxBytes = REVIEW_DIFF_M
249
371
  export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
250
372
  const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
251
373
  const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
252
- return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
374
+ return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
253
375
  }
254
376
 
255
377
  /**
@@ -257,7 +379,7 @@ export function reviewerPrompt({ issue, comments = [], contract, result, files =
257
379
  * for, with the exact problems and the reviewer's previous reply; no diff is re-sent (the worktree still holds the change).
258
380
  */
259
381
  export function reviewerRepairPrompt({ missing = [], problems = [], rows = null }) {
260
- return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is repo-relative path:line.`;
382
+ return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is a backtick-quoted repo-relative path:line copied verbatim (e.g. \`apps/web/app/(console)/layout.tsx:12\`); for an evidence path reported as not existing, give the full repo-relative path of that file from the changed files or the repository.`;
261
383
  }
262
384
 
263
385
  /** Pre-filter (#4123): one read-only, single-turn check of an issue a cheap signal flagged as possibly already fixed. */
@@ -417,3 +417,78 @@ describe("#4316 one shared recovery evidence text", () => {
417
417
  }
418
418
  });
419
419
  });
420
+
421
+ describe("#4318: the batch-exit relabel survives a throw and a signal; relabel results; full non-final reason", () => {
422
+ const LONG = `ci.red: line one ${"x".repeat(400)}\nsecond line END-OF-REASON`;
423
+ const NH = (code, detail) => ({ terminalReason: "needs-human", code, detail });
424
+ // issue 1 ends ci.red; attempt 1/2 is queued, and `onRecovering` runs when the runner reports it
425
+ const queued = (opts) => {
426
+ const relabels = [], ran = [];
427
+ const p = orchestrate({ issues: [1], parallel: 1, recovery: { maxAttempts: 2 }, readBody: async () => "",
428
+ relabelNeedsHuman: opts.relabel ?? (async (x) => { relabels.push(x); }),
429
+ runOne: async (n, _l, ctx = {}) => { ran.push(ctx.recover ?? null); await new Promise((r) => setImmediate(r)); return NH("ci.red", LONG); },
430
+ ...opts.extra(relabels) });
431
+ return { p, relabels, ran };
432
+ };
433
+
434
+ it("an exception out of the loop relabels the queued recovery once, then propagates", async () => {
435
+ let boom = false;
436
+ const { p, relabels, ran } = queued({ extra: () => ({
437
+ now: () => { if (boom) throw new Error("clock broke"); return 1000; },
438
+ onUpdate: (r) => { if (r.outcome === "recovering") boom = true; } }) });
439
+ await assert.rejects(p, /clock broke/);
440
+ assert.deepEqual(ran, [null], "attempt 1 never launched");
441
+ assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
442
+ assert.ok(relabels[0].evidence.includes(LONG), "full persisted reason");
443
+ assert.match(relabels[0].evidence, /the batch stopped \(batch failed: clock broke\)/);
444
+ });
445
+
446
+ it("onExit hands the caller the relabel: a signal relabels with its cause, once; the normal exit does not repeat it", async () => {
447
+ let exitFn = null;
448
+ const { p, relabels } = queued({ extra: () => ({
449
+ onExit: (fn) => { exitFn = fn; },
450
+ onUpdate: async (r) => { if (r.outcome === "recovering" && exitFn) { const f = exitFn; exitFn = null; await f("interrupted by SIGINT"); await f("again"); } } }) });
451
+ await p;
452
+ assert.equal(relabels.length, 1, "idempotent");
453
+ assert.match(relabels[0].evidence, /the batch stopped \(interrupted by SIGINT\)/);
454
+ });
455
+
456
+ it("a non-final attempt's relabel carries the full multi-line reason, not 300 chars", async () => {
457
+ const relabels = [];
458
+ await orchestrate({ issues: [1], parallel: 1, recovery: { maxAttempts: 2 }, readBody: async () => "", relabelNeedsHuman: async (x) => { relabels.push(x); },
459
+ runOne: async (_n, _l, ctx = {}) => (ctx.recover ? NH("builder.blocked", LONG) : NH("ci.red", "red")) });
460
+ assert.equal(relabels.length, 1);
461
+ assert.ok(relabels[0].evidence.includes(LONG));
462
+ assert.ok(relabels[0].evidence.includes("END-OF-REASON"));
463
+ });
464
+
465
+ describe("relabel results on the batch-exit path", () => {
466
+ const lost = (relabel) => {
467
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
468
+ recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", result_code: "ci.red", prev_attempt: 1 } },
469
+ breaker: { at: 1, detail: "circuit breaker: tripped" } };
470
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
471
+ const calls = [];
472
+ const go = () => orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
473
+ relabelNeedsHuman: async (x) => { calls.push(x); return relabel(); }, runOne: async () => ({ terminalReason: "merged" }) });
474
+ return { go, calls, state: () => state };
475
+ };
476
+ it("a failed label add (false / \"failed\" / a throw) is not recorded: the next resume retries it", async () => {
477
+ for (const relabel of [() => false, () => "failed", () => { throw new Error("gh down"); }]) {
478
+ const b = lost(relabel);
479
+ await b.go();
480
+ assert.equal(b.state().relabelled, undefined);
481
+ await b.go();
482
+ assert.equal(b.calls.length, 2, "retried on resume");
483
+ }
484
+ });
485
+ it("\"skipped\" (closed or leased issue) and true are recorded: no retry", async () => {
486
+ for (const relabel of [() => "skipped", () => true]) {
487
+ const b = lost(relabel);
488
+ await b.go(); await b.go();
489
+ assert.deepEqual(b.state().relabelled, [1]);
490
+ assert.equal(b.calls.length, 1);
491
+ }
492
+ });
493
+ });
494
+ });
@@ -125,7 +125,7 @@ const CONTRACT_BLOCK_RE = /<summary>contract\.json \(sha256 ([0-9a-f]{12})(?:, i
125
125
  export function renderContract(c, hash, { compact = false, scope = null } = {}) {
126
126
  const json = `<details><summary>contract.json (sha256 ${hash}${scope ? `, issue ${scope}` : ""})</summary>\n\n\`\`\`json\n${contractJson(c)}\n\`\`\`\n</details>`;
127
127
  if (compact) return `## Builder Contract (frozen \`${hash}\`)\n\n${c.requirements.length} requirement(s), ${(c.deliverables ?? []).length} deliverable(s); the tables are omitted because the contract is large.\n\n${json}`;
128
- const disp = (r) => `${r.disposition}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
128
+ const disp = (r) => `${r.disposition}${r.verify === "out_of_band" ? `, verified out of band${r.evidence ? ` (${escl(r.evidence)})` : ""}` : ""}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
129
129
  const req = c.requirements.map((r) => `| ${esc(r.id)} | ${esc(r.quote)} | ${esc(disp(r))} |`).join("\n");
130
130
  const del = (c.deliverables ?? []).map((d) => `| ${esc(d.id)} | \`${esc(d.path)}\` | ${esc(d.change)} | ${esc((d.requirements ?? []).join(", "))} |`).join("\n");
131
131
  const dec = Array.isArray(c.decisions) && c.decisions.length // forge#4165: decision provenance
@@ -148,7 +148,8 @@ export function renderAmendments(applied, filed = {}) {
148
148
  export function renderReview(rv, contract) {
149
149
  const rows = contract.requirements.map((r) => {
150
150
  const x = (rv.requirements ?? []).find((y) => y.id === r.id) ?? {};
151
- return `| ${esc(r.id)} | ${esc(x.status)}${r.tracked ? ` (tracked ${r.tracked})` : ""} | ${esc(x.evidence)} | ${esc(x.validated_by)} |`;
151
+ const oob = r.verify === "out_of_band" ? ` (out of band${r.evidence ? `: ${r.evidence}` : ""})` : "";
152
+ return `| ${esc(r.id)} | ${oob ? esc(`${x.status ?? ""}${oob}`) : esc(x.status)}${r.tracked ? ` (tracked ${r.tracked})` : ""} | ${esc(x.evidence)} | ${esc(x.validated_by)} |`;
152
153
  }).join("\n");
153
154
  const cov = rv.issue_coverage ?? [];
154
155
  return `## Conformance Review\n**Verdict:** CONFORMS (computed by the engine from the rows below)\n\n| Requirement | Status | Evidence | Validated by |\n| --- | --- | --- | --- |\n${rows}\n\n**Issue coverage:** ${cov.filter((c) => c.status === "covered").length}/${cov.length} issue requirement(s) covered by the contract\n**Scope:** ${(rv.scope ?? []).map((s) => `\`${escl(s.file)}\` ← ${escl(s.authorized_by)}`).join(", ") || NR}\n**Preserve:** ${(rv.preserve ?? []).map((p) => `${escl(p.id)} ${escl(p.status)}`).join(", ") || "none"}\n**Regressions:** none\n\n**Notes:** ${escl(rv.summary)}`;
@@ -171,7 +172,10 @@ export function renderPrBody({ issue, investigation, contract, implementation, r
171
172
  : planFallback(result, gitFiles).split("### Deliverables\n")[1].split("\n\n### ")[0];
172
173
  const verify = section(implementation, "Verification") || verification(result);
173
174
  const remaining = tracked.length ? `\n\n## Remaining work tracked\n${tracked.map((t) => `- ${t}`).join("\n")}` : "";
174
- return `## Root cause\n${root}\n\n## Deliverables\n${deliver}\n\n## Verification\n${verify}${remaining}\n\nCloses #${issue}`;
175
+ // #4404: requirements measured outside the lane; the change delivers their instrumentation, the named job the result
176
+ const oob = (contract?.requirements ?? []).filter((r) => r?.verify === "out_of_band");
177
+ const outOfBand = oob.length ? `\n\n## Verified out of band\n${oob.map((r) => `- ${escl(r.id)}: ${escl(r.quote)} (job ${r.evidence ? `\`${escl(r.evidence).replace(/`/g, "'")}\`` : "not named"})`).join("\n")}` : "";
178
+ return `## Root cause\n${root}\n\n## Deliverables\n${deliver}\n\n## Verification\n${verify}${remaining}${outOfBand}\n\nCloses #${issue}`;
175
179
  }
176
180
 
177
181
  // ---- trajectory ----
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4400: the resume digest. A resumed or recovery lane reuses the branch, but the engine-posted records of the last run
5
+ * never reach the builder (they are excluded from the quotable issue text, #4297), so it re-derived state it had already
6
+ * posted. This compiles a bounded digest from structured records only: the branch's changed files, the newest trusted
7
+ * FORGE:FACTS baselines (validate command + exit), the RUN_START FORGE:STATE end reason/code/failure classes, the first
8
+ * line of the newest trusted FORGE:NEEDS_HUMAN or FORGE:FIX_CI, and the frozen contract's deliver requirements.
9
+ * Pure: no I/O, never throws. It goes into builder prompts only, never into the text contract quotes are checked against.
10
+ */
11
+ import { noComments } from "./render.mjs";
12
+ import { parseFactsComment } from "./facts.mjs";
13
+ import { isTrustedIssueComment as trusted } from "./prefilter.mjs";
14
+
15
+ export const RESUME_DIGEST_BYTES = 2048;
16
+ const LINE_MAX = 200;
17
+ const LIST_MAX = 12;
18
+ const CODE_RE = /^[a-z][a-z0-9_.-]{0,63}$/;
19
+
20
+ /** One line, comments stripped and `<` escaped, so builder text can never open a comment or a FORGE marker. */
21
+ const one = (s, max = LINE_MAX) => {
22
+ const t = noComments(String(s ?? ""), " ").replace(/\s+/g, " ").trim().replace(/</g, "&lt;");
23
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
24
+ };
25
+ const code = (v) => (typeof v === "string" && CODE_RE.test(v) ? v : null);
26
+ const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
27
+ /** First line of a record that is not the marker or a heading. */
28
+ const recordLine = (body) => one(noComments(String(body ?? "")).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
29
+
30
+ /**
31
+ * {text, files, validates, reason}: the digest text ("" when nothing is known) and its counts for the RESUME_DIGEST event. `state` is the FORGE:STATE read at RUN_START (before this run overwrites
32
+ * it), `comments` the issue comments (only trusted ones are read), `contract` the frozen contract or null, `files` the
33
+ * branch's changed files, `lane` the base branch. The text never exceeds `maxBytes` (UTF-8): whole lines are dropped from
34
+ * the end, so the end reason and validate results (first) survive longest.
35
+ */
36
+ export function buildResumeDigest({ state = null, comments = [], contract = null, files = [], lane = "", maxBytes = RESUME_DIGEST_BYTES } = {}) {
37
+ try {
38
+ const tc = (Array.isArray(comments) ? comments : []).filter(trusted);
39
+ const facts = [...tc].reverse().map((c) => parseFactsComment(c?.body)).find(Boolean) ?? null;
40
+ const last = [...tc].reverse().find((c) => ["NEEDS_HUMAN", "FIX_CI"].includes(markerOf(c?.body)));
41
+ const validate = Array.isArray(contract?.validate) ? contract.validate : [];
42
+ const idOf = (cmd) => validate.find((v) => one(v?.command) === cmd)?.id ?? null;
43
+
44
+ const sections = [];
45
+ const reason = code(state?.terminalReason), tcode = code(state?.terminalCode);
46
+ const fails = Array.isArray(state?.failureClasses) ? [...new Set(state.failureClasses.map(code).filter(Boolean))].sort().slice(0, 5) : [];
47
+ const why = last ? `FORGE:${markerOf(last.body)}: ${recordLine(last.body)}` : "";
48
+ if (reason || why) sections.push(["How the last run ended:", ...(reason ? [`- ${reason}${tcode && tcode !== "unclassified" ? ` (${tcode})` : ""}${fails.length ? `; failures ${fails.join(", ")}` : ""}`] : []), ...(why ? [`- ${why}`] : [])]);
49
+ const base = facts?.baselines ?? [];
50
+ if (base.length) sections.push(["Validate results of the last run (command: exit):", ...base.map((b) => { const id = idOf(b.command); return `- ${id ? `${one(id, 20)} ` : ""}\`${b.command.replace(/`/g, "'")}\`: exit ${b.exit}${b.exit === 0 ? "" : " (failing)"}`; })]);
51
+ const reqs = (Array.isArray(contract?.requirements) ? contract.requirements : []).filter((r) => r?.disposition === "deliver");
52
+ if (reqs.length) sections.push(["Requirements to deliver (frozen contract); verify each against the branch and finish what is missing:", ...reqs.map((r) => `- ${one(r.id, 20)}: ${one(r.quote, 160)}`)]);
53
+ const changed = (Array.isArray(files) ? files : []).map((f) => one(f)).filter(Boolean);
54
+ if (changed.length) sections.push([`Files changed on the branch (vs ${one(lane) || "base"}):`, ...changed.slice(0, LIST_MAX).map((f) => `- ${f}`), ...(changed.length > LIST_MAX ? [`- … ${changed.length - LIST_MAX} more`] : [])]);
55
+ const none = { text: "", files: 0, validates: 0, reason: null };
56
+ if (!sections.length) return none;
57
+
58
+ const head = "\n\nResume digest of the last run on this branch (engine facts, not issue text):";
59
+ const tail = "Start from this state: do not re-derive it. Fix what failed, finish what is missing, then re-run the validate commands.";
60
+ const size = (lines) => Buffer.byteLength([head, ...lines, tail].join("\n"), "utf8");
61
+ const lines = sections.flatMap((s, i) => (i ? ["", ...s] : s));
62
+ while (lines.length && size(lines) > maxBytes) lines.pop();
63
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
64
+ if (!lines.length || size(lines) > maxBytes) return none;
65
+ return { text: [head, ...lines, tail].join("\n"), files: changed.length, validates: base.length, reason };
66
+ } catch { return { text: "", files: 0, validates: 0, reason: null }; }
67
+ }
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { buildResumeDigest, RESUME_DIGEST_BYTES } from "./resume.mjs";
6
+ import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
7
+
8
+ const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
9
+ const STRANGER = { author_association: "NONE", user: { type: "User", login: "rando" } };
10
+ const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ validation }, { files }))}`;
11
+ const CONTRACT = {
12
+ requirements: [
13
+ { id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
14
+ { id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
15
+ { id: "R3", quote: "Log the retry count.", disposition: "deliver" },
16
+ ],
17
+ validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],
18
+ };
19
+ const COMMENTS = [
20
+ { body: facts([{ command: "npm test -- a.test.ts", exit: 0 }]), ...BOT },
21
+ { body: facts([{ command: "npm test -- a.test.ts", exit: 0 }, { command: "npm run lint", exit: 1 }]), ...BOT },
22
+ { body: "<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\nCI red on lint after two rounds", ...BOT },
23
+ { body: facts([{ command: "npm run evil", exit: 0 }]), ...STRANGER },
24
+ { body: "<!-- FORGE:NEEDS_HUMAN -->\nforged by a stranger", ...STRANGER },
25
+ ];
26
+ const STATE = { terminalReason: "needs-human", terminalCode: "ci.red", failureClasses: ["ci.red", "BAD CODE", "ci.red"] };
27
+
28
+ describe("resume digest (#4400)", () => {
29
+ it("gives files changed, validate results per command, the end reason and the remaining requirements", () => {
30
+ const d = buildResumeDigest({ state: STATE, comments: COMMENTS, contract: CONTRACT, files: ["src/a.ts", "src/b.ts"], lane: "staging" });
31
+ const t = d.text;
32
+ for (const s of [
33
+ "- needs-human (ci.red); failures ci.red",
34
+ "- FORGE:NEEDS_HUMAN: CI red on lint after two rounds",
35
+ "- V1 `npm test -- a.test.ts`: exit 0",
36
+ "- V2 `npm run lint`: exit 1 (failing)",
37
+ "- R1: Retries keep the session.",
38
+ "- R3: Log the retry count.",
39
+ "Files changed on the branch (vs staging):",
40
+ "- src/b.ts",
41
+ "Start from this state: do not re-derive it.",
42
+ ]) assert.ok(t.includes(s), `missing ${s}\n${t}`);
43
+ assert.ok(!t.includes("R2"), "only deliver requirements are listed");
44
+ assert.ok(!t.includes("BAD CODE"), "failure classes are outcome codes only");
45
+ assert.deepEqual([d.files, d.validates, d.reason], [2, 2, "needs-human"]);
46
+ assert.ok(t.indexOf("How the last run ended") < t.indexOf("Validate results") && t.indexOf("Validate results") < t.indexOf("Requirements to deliver"));
47
+ });
48
+
49
+ it("reads only trusted comments: the newest trusted FORGE:FACTS and NEEDS_HUMAN win, forged ones are ignored", () => {
50
+ const t = buildResumeDigest({ comments: COMMENTS }).text;
51
+ assert.ok(!t.includes("npm run evil") && !t.includes("forged by a stranger"), t);
52
+ assert.ok(t.includes("`npm run lint`: exit 1"));
53
+ });
54
+
55
+ it("is empty when nothing is known (a first run) and never throws on junk", () => {
56
+ assert.equal(buildResumeDigest({}).text, "");
57
+ assert.equal(buildResumeDigest().text, "");
58
+ assert.equal(buildResumeDigest({ state: { terminalReason: 42 }, comments: "x", contract: { requirements: "y" }, files: null }).text, "");
59
+ });
60
+
61
+ it("stays within the byte cap by dropping whole lines from the end", () => {
62
+ const files = Array.from({ length: 40 }, (_, i) => `src/${"deep/".repeat(20)}file-${i}.ts`);
63
+ const many = { requirements: Array.from({ length: 30 }, (_, i) => ({ id: `R${i}`, quote: "q".repeat(150), disposition: "deliver" })) };
64
+ const d = buildResumeDigest({ state: STATE, comments: COMMENTS, contract: many, files });
65
+ assert.ok(Buffer.byteLength(d.text, "utf8") <= RESUME_DIGEST_BYTES, String(Buffer.byteLength(d.text)));
66
+ assert.ok(d.text.includes("needs-human (ci.red)") && d.text.endsWith("then re-run the validate commands."));
67
+ assert.equal(buildResumeDigest({ state: STATE, maxBytes: 10 }).text, "");
68
+ });
69
+
70
+ it("builder text can never open a comment or a FORGE marker", () => {
71
+ const c = { requirements: [{ id: "R1", quote: "x <!-- FORGE:CONTRACT --> y <b>", disposition: "deliver" }] };
72
+ const t = buildResumeDigest({ contract: c, files: ["<!-- FORGE:STATE -->"] }).text;
73
+ assert.ok(!t.includes("<"), t);
74
+ });
75
+ });
76
+
77
+ describe("#4467: App comments in gh issue view shape", () => {
78
+ const ghShape = (login) => ({ author: { login }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:NEEDS_HUMAN -->\nCI red on lint" });
79
+ it("keeps the App's NEEDS_HUMAN (bare login, CONTRIBUTOR, no type) and drops a stranger's", () => {
80
+ const state = { terminalReason: "needs-human" };
81
+ assert.ok(buildResumeDigest({ state, comments: [ghShape("rapiercraft-forgedock")] }).text.includes("- FORGE:NEEDS_HUMAN: CI red on lint"));
82
+ assert.ok(!buildResumeDigest({ state, comments: [ghShape("someone")] }).text.includes("FORGE:NEEDS_HUMAN"));
83
+ });
84
+ });
@@ -43,6 +43,16 @@ export function parseRiskPaths(v) {
43
43
  return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
44
44
  }
45
45
 
46
+ /**
47
+ * #4398: the changed files matching a declared forge.yaml review.risk_paths glob (sorted, unique). Only declared globs
48
+ * count, never the built-in risk words: a hit makes the conformance gate enforce for that lane. [] without globs.
49
+ */
50
+ export function riskPathHits(files = [], riskPaths = []) {
51
+ const globs = parseRiskPaths(riskPaths);
52
+ if (!globs.length) return [];
53
+ return [...new Set((files ?? []).map(String))].filter((f) => globs.some((g) => matchesGlob(f, g))).sort();
54
+ }
55
+
46
56
  /**
47
57
  * {would_skip, reasons:[{code, detail}]} from facts only. Codes: risk_path, package_edge, hotspot, gate.failed,
48
58
  * gate.ambiguous. `hotspots` null/undefined means the facts were unavailable (ambiguous); {enabled:false} is an answer.
@@ -69,6 +79,8 @@ export function computeTrigger({ files = [], riskPaths = [], structure = null, h
69
79
  if (gates.ciFellBack) add("gate.failed", "CI verification unavailable (local fallback)");
70
80
  const tracked = (contract?.requirements ?? []).filter((r) => r?.disposition === "defer" || r?.disposition === "post-deploy").map((r) => r.id);
71
81
  if (tracked.length) add("gate.ambiguous", `contract defers ${tracked.sort().join(", ")} (only the reviewer judges deferrals)`);
82
+ const oob = (contract?.requirements ?? []).filter((r) => r?.verify === "out_of_band").map((r) => r.id);
83
+ if (oob.length) add("gate.ambiguous", `contract verifies ${oob.sort().join(", ")} out of band (only the reviewer judges instrumentation)`);
72
84
  reasons.sort((a, b) => a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
73
85
  return { would_skip: reasons.length === 0, reasons };
74
86
  }
@@ -7,12 +7,21 @@ import { join } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
- import { computeTrigger, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
10
+ import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
11
11
 
12
12
  const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
13
13
  const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
14
14
  const codes = (t) => t.reasons.map((r) => r.code);
15
15
 
16
+ describe("riskPathHits (#4398)", () => {
17
+ it("only declared review.risk_paths globs count, sorted and unique; built-in risk words never do", () => {
18
+ assert.deepEqual(riskPathHits(["src/auth/login.ts", "src/billing/b.ts", "src/billing/a.ts", "src/billing/a.ts"], ["src/billing/**"]), ["src/billing/a.ts", "src/billing/b.ts"]);
19
+ assert.deepEqual(riskPathHits(["src/auth/login.ts"], []), [], "no declared globs: nothing enforces");
20
+ assert.deepEqual(riskPathHits(["db/x.sql", "lib/a.js"], "db/**, *.sql"), ["db/x.sql"]);
21
+ assert.deepEqual(riskPathHits(undefined, ["**"]), []);
22
+ });
23
+ });
24
+
16
25
  describe("computeTrigger (#4273)", () => {
17
26
  it("a clean diff with passing gates would skip, with no reasons", () => {
18
27
  assert.deepEqual(clean(), { would_skip: true, reasons: [] });
@@ -61,6 +70,13 @@ describe("computeTrigger (#4273)", () => {
61
70
  assert.deepEqual(a, b);
62
71
  assert.deepEqual(codes(a), ["gate.ambiguous", "gate.failed", "risk_path", "risk_path"]);
63
72
  });
73
+ it("#4404 an out-of-band requirement forces the reviewer (only it judges instrumentation)", () => {
74
+ const t = clean({ contract: { requirements: [{ id: "R2", disposition: "deliver", verify: "out_of_band" }, { id: "R1", disposition: "deliver" }] } });
75
+ assert.equal(t.would_skip, false);
76
+ assert.deepEqual(t.reasons, [{ code: "gate.ambiguous", detail: "contract verifies R2 out of band (only the reviewer judges instrumentation)" }]);
77
+ assert.equal(clean({ contract: { requirements: [{ id: "R1", disposition: "deliver", verify: "in_lane" }] } }).would_skip, true);
78
+ });
79
+
64
80
  it("parseRiskPaths takes a comma string or a list", () => {
65
81
  assert.deepEqual(parseRiskPaths(" a/** , ,*.sql"), ["a/**", "*.sql"]);
66
82
  assert.deepEqual(parseRiskPaths(["x", " ", "y "]), ["x", "y"]);