forgedock 1.27.0 → 1.29.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 (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +601 -42
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. package/templates/bootstrap/technical-spike.md +75 -0
@@ -1,6 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /** Core work-on prompts: a plan call that writes the contract, an implement call in the same session, and the conformance reviewer. */
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { reviewSkeleton } from "./conformance.mjs";
4
7
 
5
8
  export const approxTokens = (s) => Math.ceil(String(s).length / 4);
6
9
  export const LEAN_DIR = ".forge-lean";
@@ -10,7 +13,10 @@ export const PLAN_FILE = `${LEAN_DIR}/plan.md`;
10
13
  export const IMPLEMENTATION_FILE = `${LEAN_DIR}/implementation.md`;
11
14
  export const CONTRACT_FILE = `${LEAN_DIR}/contract.json`;
12
15
 
13
- 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.`;
16
+ /** Change-scoped checks with shared incremental caches (#4218): the builder runs the scoped commands this CLI prints. */
17
+ export const CHECKS_CLI = fileURLToPath(new URL("./checks.mjs", import.meta.url));
18
+
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.`;
14
20
 
15
21
  /** Verify-by-CI (#4167): in ci/hybrid mode the engine runs the repo's CI on GitHub runners; the host runs only light checks. */
16
22
  const LIGHT = "run only formatters and linters on the files you changed";
@@ -30,16 +36,31 @@ export function ciFailurePrompt(failures, mode = "local") {
30
36
  return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
31
37
  }
32
38
 
39
+ /** The repo's declared check commands (forge.yaml learned.test_commands) failed before the PR (#4240): fixed in-session. */
40
+ export function precheckFailurePrompt(failures, mode = "local") {
41
+ 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");
42
+ 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
+ }
44
+
33
45
  /** CI could not run (no workflow run, no checks, dispatch refused): the local fallback (#4167). */
34
46
  export function ciUnavailablePrompt() {
35
47
  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.`;
36
48
  }
37
49
 
50
+ /** #4163: the extra host-rules line when the engine pinned the repo's toolchain on PATH; "" otherwise (prompt unchanged). */
51
+ const toolchainRule = (t) => (t ? `\nThe engine pinned this repo's toolchain on PATH (${t}). Use it as is: do not install global shims or other versions and do not edit PATH; if the pinned tool itself fails, report it as a toolchain/environment problem instead of working around it.` : "");
52
+
53
+ /** #4272: the extra line citing the repo-facts cache by path (never its content) when it exists; "" otherwise (prompt unchanged). */
54
+ const factsRule = (f) => (f ? `\nRepo facts (toolchain pins, slow suites, layer rules, hotspots) are cached in ${f}: read it before choosing checks or placing code.` : "");
55
+
56
+ /** #4221: the extra host-rules line when the lane worktree is sparse; "" otherwise (prompt unchanged). */
57
+ const sparseRule = (s) => (s?.excluded ? `\nThis worktree is a sparse checkout: ${s.excluded} tracked file(s) of ${s.maxKb} KB or more are not on disk (\`git ls-files -t | grep '^S'\` lists them). They are not deleted: never commit their removal. If you need one, run \`git sparse-checkout add /<path>\`.` : "");
58
+
38
59
  /** Builder call 1: investigate and write the contract. No code edits. */
39
- export function builderPlanPrompt(n) {
60
+ export function builderPlanPrompt(n, { toolchain = null, sparse = null, facts = null } = {}) {
40
61
  return `Resolve issue #${n} (read it with gh issue view ${n} --comments). This first step is PLANNING ONLY: do not edit code, do not commit.
41
62
 
42
- ${HOST_RULES}
63
+ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
43
64
 
44
65
  1. Write ${INVESTIGATION_FILE} as markdown with exactly these headings, concise, file:line wherever you cite code (no HTML comments):
45
66
  **Verdict**: CONFIRMED|ALREADY-FIXED|INVALID|BLOCKED
@@ -61,19 +82,20 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
61
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>"}],
62
83
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
63
84
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
64
- "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change>"}]}
85
+ "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
+ "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"}]}
65
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.
66
- 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. Then stop.
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.
67
89
 
68
90
  ${BLOCK_RULES}`;
69
91
  }
70
92
 
71
93
  /** Builder call 2, same session: implement the frozen contract. */
72
- export function builderImplementPrompt(mode = "local") {
94
+ export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
73
95
  const remote = mode === "ci" || mode === "hybrid";
74
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.
75
97
 
76
- ${HOST_RULES}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
98
+ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
77
99
 
78
100
  The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
79
101
  - {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
@@ -81,6 +103,7 @@ The contract changes only through typed amendments in ${RESULT_FILE}, each with
81
103
  - {"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.
82
104
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
83
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.
84
107
 
85
108
  When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
86
109
  ### Approach
@@ -95,13 +118,45 @@ If you started Docker containers or volumes that are still there, list their nam
95
118
  If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
96
119
  }
97
120
 
121
+ /**
122
+ * forge#4275: the compiled context pack for the plan prompt (context_pack: on); "" for an empty pack. Labelled as hints:
123
+ * the issue outranks the code, which outranks the pack, and contract quotes never come from it.
124
+ */
125
+ export function contextPackNote(pack) {
126
+ if (!pack?.text) return "";
127
+ return `\n\nContext pack (hints, not proof; compiled by the engine from GitHub and git). The issue outranks the code, which outranks this pack: verify anything you rely on. Contract requirements quote only the issue and its trusted comments, never this pack. Open a link (gh issue view N / gh pr view N) yourself when you need more.\n${pack.text}`;
128
+ }
129
+
130
+ /** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
131
+ export function reproPlanNote(on) {
132
+ if (!on) return "";
133
+ return `\n\nThis issue is a bug. When it is testable, ${CONTRACT_FILE} must also carry "repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"}: a test that fails on the base and passes after the fix. The engine proves it with scripts/acceptance-red-green.sh (head's changed test files are overlaid on the base) and fails the run if it is not red on base and green on head. When it is not testable (specs, docs, workflows, environment), write "repro_test":"none","repro_test_reason":"<one line why>" instead.`;
134
+ }
135
+
136
+ /** forge#4195: hotspot facts for the plan prompt: one sentence plus one line per over-threshold file; "" when none. */
137
+ export function hotspotPlanNote(facts) {
138
+ if (!facts || facts.enabled === false || !Array.isArray(facts.files)) return "";
139
+ const hot = facts.files.filter((f) => f?.over && !f.excluded);
140
+ if (!hot.length) return "";
141
+ return `\n\nHotspots (scripts/hotspots.sh): an Affected File below is over ${facts.file_lines} lines, so the contract must put new logic in a new or neighbouring module and give the hotspot only wiring (an import and a call); growing it by more than ${facts.growth_lines} lines needs "hotspot-exempt: <reason>" in that deliverable's change.\n${hot.map((f) => `- ${f.path}: ${f.lines} lines, ${f.commits_90d} commits/90d`).join("\n")}`;
142
+ }
143
+
144
+ /** forge#4195: the builder's growth rule with the effective thresholds; "" when the check is off. */
145
+ export function hotspotBuildRule(facts) {
146
+ if (!facts || facts.enabled === false || !(facts.file_lines > 0) || !(facts.growth_lines > 0)) return "";
147
+ 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
+ }
149
+
98
150
  export function contractCorrectionPrompt(problems) {
99
151
  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.`;
100
152
  }
101
153
 
102
154
  /** The one bounded correction round, in the builder's own session. */
103
- export function conformanceResumePrompt(failures, mode = "local") {
104
- return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
155
+ export function conformanceResumePrompt(failures, mode = "local", lane = "") {
156
+ const baseMoved = lane && failures.some((f) => f?.kind === "validate_failed")
157
+ ? `\n\nIf a validate command fails only because origin/${lane} moved on after this branch started (the change itself is fine), bring the branch up to date with \`git merge --no-edit origin/${lane}\` (already fetched; never rebase or force), resolve any conflicts, then re-run every validate command and record the results.`
158
+ : "";
159
+ return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}${baseMoved}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
105
160
  }
106
161
 
107
162
  /** forge#4159: the one resume for package.json edges between workspace packages the contract does not name. */
@@ -109,6 +164,11 @@ export function structurePrompt(edges, mode = "local") {
109
164
  return `This change adds dependencies between workspace packages that the frozen contract does not name:\n${edges.slice(0, 30).map((e) => `- ${e.from} -> ${e.to} (${e.kind}, ${e.manifest})`).join("\n")}\n\nFor each one, add it to the contract with a reason, or remove it: either record {"kind":"add-deliverable","path":"<that package.json>","why":"<names the package and why this dependency is right>"} in ${RESULT_FILE}, or drop the dependency from the package.json and the code that needs it. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
110
165
  }
111
166
 
167
+ /** The repo's devdocs freshness rule failed on the committed change: update the named dossier(s) and commit. */
168
+ export function docsPrompt(output, mode = "local") {
169
+ 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
+ }
171
+
112
172
  /** Human-only block reasons (#4124). Everything else agents resolve: a dependency, a split, prod data monitoring can read. */
113
173
  export const HUMAN_BLOCK_REASONS = Object.freeze(["credentials", "spend", "promotion", "customer", "product-decision"]);
114
174
 
@@ -122,15 +182,35 @@ export function blockCorrectionPrompt(problem) {
122
182
  return `Your ${RESULT_FILE} says "blocked", but ${String(problem).slice(0, 1000)}. That is not a block that needs a human.\n\n${BLOCK_RULES}\n\nEither do the work (fix, commit, update the stage notes and ${RESULT_FILE}) or write a valid typed "block". Do not push or touch GitHub.`;
123
183
  }
124
184
 
185
+ /** refactor:split grouping call (#4211): no tools, no files; the only input is the compact symbol index. */
186
+ export const GROUPING_SYSTEM = "You plan how to split one oversized source module into a package of smaller modules. You see only a symbol index (names, kinds, sizes, references); you have no tools and never see code. Reply with exactly one JSON object and nothing else.";
187
+
188
+ /** The grouping prompt: fixed rules plus JSON of bin/lean/refactor.mjs compactIndex(). Its size depends on the index only, never on file length. */
189
+ export function groupingPrompt(index) {
190
+ return `Group the top-level symbols of one module into cohesive new modules of a package. A codemod moves them verbatim and leaves the old module re-exporting every moved name.
191
+
192
+ Index: "symbols" are top-level symbols {name, kind, lines, public}; "edges" are "from->to" references between them.
193
+
194
+ Rules:
195
+ - Module names are snake_case Python identifiers, unique, not equal to any symbol name.
196
+ - Each symbol appears at most once. Symbols you leave out stay in the old module.
197
+ - A moved symbol must not reference a symbol that stays: when you move a symbol, move everything it references (follow the edges).
198
+ - Modules must not reference each other in a cycle; keep heavily cross-referenced symbols in one module.
199
+ - Prefer a few modules of similar size grouped by what the symbols do.
200
+
201
+ Reply with only {"<module>": ["<symbol>", ...], ...}.
202
+
203
+ ${JSON.stringify(index)}`;
204
+ }
205
+
125
206
  export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of one change. You did not write it. Read-only: use Read, Grep and Glob in the worktree.
126
207
 
127
- 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, and the diff. The engine computes the verdict from your rows; a reply without rows is rejected, so fill EVERY row:
128
- - 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.
129
- - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with path:line evidence 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.
130
- - scope: one row per changed file: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes.
131
- - preserve: one row per preserve id: intact|violated|unverified, with path:line.
208
+ 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
+ - 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.
211
+ - 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
+ - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
132
213
  - regressions: concrete breakages only, each with path:line.
133
- Start from <row_skeleton> when given: it already lists every row the engine requires. Keep every id and file, fill each status and evidence, and add issue_coverage rows for issue requirements the contract does not quote. Scope rows are needed only for the files listed there; the engine authorizes the rest.
134
214
 
135
215
  Output ONLY a JSON object in a json code fence:
136
216
  {"issue_coverage":[{"quote":"","contract_ids":["R1"],"status":"covered|narrowed|missing"}],"requirements":[{"id":"R1","status":"","evidence":"path:line","validated_by":"V1","deferral_ok":true,"note":""}],"scope":[{"file":"","authorized_by":"D1|test|none","note":""}],"preserve":[{"id":"P1","status":"","evidence":"path:line"}],"regressions":[{"at":"path:line","failure":""}],"verdict":"CONFORMS|NONCONFORMING","summary":"one line"}
@@ -166,9 +246,18 @@ export function boundDiff(diff, { stat = "", base = "", maxBytes = REVIEW_DIFF_M
166
246
  + (list.length ? `\n<omitted_files>\n${list.join("\n")}\n</omitted_files>` : "");
167
247
  }
168
248
 
169
- export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", skeleton = null, retry = null }) {
249
+ export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
170
250
  const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
171
- 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<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}${skeleton ? `\n\n<row_skeleton>\n${cap(JSON.stringify(skeleton), 12000)}\n</row_skeleton>` : ""}${retry?.problems?.length ? `\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these and return the complete corrected JSON:\n${retry.problems.slice(0, 60).map((x) => `- ${x}`).join("\n")}\n</previous_reply_problems>${retry.previous ? `\n\n<previous_reply>\n${cap(retry.previous, 8000)}\n</previous_reply>` : ""}` : ""}`;
251
+ 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>` : ""}`;
253
+ }
254
+
255
+ /**
256
+ * #4160: the targeted re-ask after an incomplete or inconsistent reply. Only the missing or invalid ids are asked
257
+ * for, with the exact problems and the reviewer's previous reply; no diff is re-sent (the worktree still holds the change).
258
+ */
259
+ 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.`;
172
261
  }
173
262
 
174
263
  /** Pre-filter (#4123): one read-only, single-turn check of an issue a cheap signal flagged as possibly already fixed. */
@@ -0,0 +1,230 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4267: in-batch recovery of mechanical needs-human (orchestrate, run-issue/orchestrate CLI, /orchestrate spec).
4
+ // The runCore side (--recover label/decision/prompt, issueEdited) is in lean.test.mjs next to its harness.
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync } from "node:fs";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
10
+ import { RECOVER_HOW } from "./lean.mjs";
11
+ import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
12
+ import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
13
+ // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
14
+ process.env.FORGEDOCK_SCHED_DIR = tmpDir("recovery-sched-");
15
+
16
+ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
17
+ // runOne: `script[n]` is a list of results, one per launch of issue n (the last repeats); ctx is recorded.
18
+ const batch = (script, bodies = {}, extra = {}) => {
19
+ const calls = [], decisions = [];
20
+ const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: 1 },
21
+ readBody: async (n) => bodies[n] ?? "",
22
+ runOne: async (n, _lane, ctx = {}) => {
23
+ calls.push({ n, recover: ctx.recover ?? null });
24
+ await new Promise((r) => setImmediate(r));
25
+ const list = script[n] ?? [{ terminalReason: "merged" }];
26
+ const k = calls.filter((c) => c.n === n).length - 1;
27
+ return list[Math.min(k, list.length - 1)];
28
+ },
29
+ decide: (d) => { decisions.push(d); }, ...extra });
30
+ return p.then((rows) => ({ rows, calls, decisions }));
31
+ };
32
+ const NH = (code, detail = `ended ${code}`, more = {}) => ({ terminalReason: "needs-human", code, detail, ...more });
33
+ const byIssue = (rows) => Object.fromEntries(rows.map((r) => [r.issue, r]));
34
+
35
+ it("ci.red: one recovery lane on the PR; green re-queues the issue and its dependent in the same batch, no operator action", async () => {
36
+ const { rows, calls, decisions } = await batch({ 1: [NH("ci.red", "Required check(s) failed on PR #101: test"), { terminalReason: "merged", pr: 101 }], 2: [{ terminalReason: "merged", pr: 102 }] }, { 2: "**Depends on**: #1" });
37
+ const r = byIssue(rows);
38
+ assert.equal(r[1].outcome, "merged"); assert.equal(r[1].recovered, "ci.red");
39
+ assert.equal(r[2].outcome, "merged", "the dependent was not skipped");
40
+ assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1 }]);
41
+ assert.ok(calls.findIndex((c) => c.n === 2) > calls.findLastIndex((c) => c.n === 1), "the dependent ran after the recovered merge");
42
+ const d = decisions.find((x) => x.issue === 1);
43
+ assert.match(d.what, /recovery lane for ci\.red/); assert.match(d.why, /Required check/); assert.ok(d.alternative);
44
+ assert.deepEqual(formatOutcomeLists(rows), ["recovered: #1 (ci.red)"]);
45
+ });
46
+
47
+ it("a recovery that stays red is final with its evidence; dependents stay skipped; no attempt beyond max_attempts", async () => {
48
+ const { rows, calls } = await batch({ 1: [NH("ci.red"), NH("ci.red", "CI verification failed again", { recovery: true })], 2: [] }, { 2: "**Depends on**: #1" });
49
+ const r = byIssue(rows);
50
+ assert.equal(calls.filter((c) => c.n === 1).length, 2, "the first run plus exactly one recovery");
51
+ assert.equal(r[1].outcome, "needs-human");
52
+ assert.deepEqual(r[1].recovery, { code: "ci.red", attempts: 1, outcome: "needs-human", result_code: "ci.red", detail: "CI verification failed again" });
53
+ assert.equal(r[2].outcome, "skipped"); assert.ok(!calls.some((c) => c.n === 2));
54
+ const lines = formatOutcomeLists(rows);
55
+ assert.equal(lines.length, 1); assert.match(lines[0], /^unrecovered: #1 \(ci\.red: recovery ci\.red ended needs-human: CI verification failed again\)$/);
56
+ });
57
+
58
+ it("human codes (product-decision, spend) are never recovered, still skip dependents and are the only escalations, asked once while other lanes keep running", async () => {
59
+ const { rows, calls, decisions } = await batch({
60
+ 1: [NH("builder.blocked", "Builder is blocked (product-decision): pick a pricing model")],
61
+ 2: [],
62
+ 3: [NH("builder.blocked", "Builder is blocked (spend): needs a paid plan")],
63
+ 4: [NH("conformance.omission", "R2 not covered")],
64
+ 5: [{ terminalReason: "merged" }] }, { 2: "**Depends on**: #1" });
65
+ const r = byIssue(rows);
66
+ assert.equal(calls.filter((c) => c.n === 1).length, 1); assert.equal(calls.filter((c) => c.n === 3).length, 1);
67
+ assert.equal(calls.filter((c) => c.n === 4).length, 1, "terminal mechanical codes are not recovered either");
68
+ assert.ok(!decisions.some((d) => [1, 3].includes(d.issue)));
69
+ assert.equal(r[2].outcome, "skipped"); assert.equal(r[5].outcome, "merged");
70
+ const lines = formatOutcomeLists(rows);
71
+ assert.equal(lines.filter((l) => l.startsWith("escalations")).length, 1, "one escalation list");
72
+ const esc = lines.find((l) => l.startsWith("escalations"));
73
+ assert.match(esc, /#1 \(builder\.blocked\): Builder is blocked \(product-decision\)/);
74
+ assert.match(esc, /#3 \(builder\.blocked\): Builder is blocked \(spend\)/);
75
+ assert.doesNotMatch(esc, /#4/, "the needs-human list holds only human codes");
76
+ assert.ok(lines.some((l) => /^unrecovered: #4 \(conformance\.omission: R2 not covered\)$/.test(l)));
77
+ });
78
+
79
+ it("base.conflict: a recovery base-sync with a clean result re-queues the issue; the report lists recovered issues separately", async () => {
80
+ const { rows, calls } = await batch({ 1: [NH("ci.red"), { terminalReason: "merged" }], 2: [NH("base.conflict", "PR #102 still conflicts with staging after 2 base-syncs"), { terminalReason: "merged" }] });
81
+ assert.deepEqual(calls.filter((c) => c.n === 2).map((c) => c.recover?.code ?? null), [null, "base.conflict"]);
82
+ assert.equal(byIssue(rows)[2].recovered, "base.conflict");
83
+ assert.deepEqual(formatOutcomeLists(rows), ["recovered: #1 (ci.red) #2 (base.conflict)"]);
84
+ });
85
+
86
+ it("ci-pending recovers only when its PR conflicts (as base.conflict); a slow-CI ci-pending is left as is", async () => {
87
+ const seen = [];
88
+ const conflicting = async (n, pr) => { seen.push([n, pr]); return n === 1; };
89
+ const { rows, calls } = await batch({ 1: [{ terminalReason: "ci-pending", pr: 101 }, { terminalReason: "merged", pr: 101 }], 2: [{ terminalReason: "ci-pending", pr: 102 }] }, {}, { conflicting });
90
+ const r = byIssue(rows);
91
+ assert.equal(r[1].recovered, "base.conflict");
92
+ assert.equal(r[2].outcome, "ci-pending"); assert.equal(calls.filter((c) => c.n === 2).length, 1);
93
+ assert.deepEqual(seen.sort(), [[1, 101], [2, 102]]);
94
+ });
95
+
96
+ it("contract.invalid recovers only when the issue was edited mid-run", async () => {
97
+ const { rows, calls } = await batch({ 1: [NH("contract.invalid", "contract invalid", { issueEdited: true }), { terminalReason: "merged" }], 2: [NH("contract.invalid")] });
98
+ assert.equal(byIssue(rows)[1].recovered, "contract.invalid");
99
+ assert.equal(byIssue(rows)[2].outcome, "needs-human"); assert.equal(calls.filter((c) => c.n === 2).length, 1);
100
+ });
101
+
102
+ it("relabelNeedsHuman (SPEC-7/8): only a recovery that ends final engine-error/deferred relabels; invalid/merged never; a requeued deferred waits", async () => {
103
+ const relabels = [];
104
+ const rl = { relabelNeedsHuman: async (x) => { relabels.push(x); }, pollMs: 5 };
105
+ // engine-error after the recovery lane's own relaunch: final -> exactly one relabel with the recovery evidence
106
+ const a = await batch({ 1: [NH("ci.red"), { terminalReason: "engine-error", detail: "boom" }] }, {}, rl);
107
+ assert.equal(a.rows[0].outcome, "engine-error"); assert.equal(relabels.length, 1);
108
+ assert.equal(relabels[0].issue, 1); assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `engine-error`/);
109
+ // invalid / merged / decomposed: never
110
+ relabels.length = 0;
111
+ await batch({ 1: [NH("ci.red"), { terminalReason: "invalid", detail: "not a bug" }], 2: [NH("ci.red"), { terminalReason: "merged" }],
112
+ 3: [NH("ci.red"), { terminalReason: "decomposed", subissues: [] }] }, {}, rl);
113
+ assert.deepEqual(relabels, []);
114
+ // a deferred recovery that is requeued is not relabelled until it is final (capped)
115
+ const defer = { terminalReason: "deferred", code: "gh.unavailable", retryInSeconds: 0.01, detail: "gh down" };
116
+ const seen = [];
117
+ const d = await batch({ 1: [NH("ci.red"), defer] }, {}, { ...rl, maxDeferrals: 2, onUpdate: (r) => seen.push([r.outcome, relabels.length, String(r.detail)]) });
118
+ assert.equal(d.rows[0].outcome, "deferred");
119
+ const rq = seen.filter(([o, , det]) => o === "deferred" && /^requeued in/.test(det));
120
+ assert.ok(rq.length >= 1 && rq.every(([, n]) => n === 0), "a requeued deferral is not relabelled");
121
+ assert.equal(relabels.filter((x) => x.issue === 1).length, 1, "once, only when final");
122
+ // without a recovery pass nothing is relabelled
123
+ relabels.length = 0;
124
+ await batch({ 1: [{ terminalReason: "engine-error" }] }, {}, rl);
125
+ assert.deepEqual(relabels, []);
126
+ });
127
+
128
+ it("without the recovery option the runner behaves as before: needs-human is final and dependents skip", async () => {
129
+ const calls = [];
130
+ const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "**Depends on**: #1" : ""),
131
+ runOne: async (n) => { calls.push(n); return NH("ci.red"); } });
132
+ assert.deepEqual(calls, [1]); assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "skipped"]);
133
+ });
134
+
135
+ it("the attempt count survives a resume: a persisted recovery lane is launched with ctx.recover and is not retried again", async () => {
136
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [1], results: {}, running: {}, recoveries: { 1: 1 }, recovering: { 1: { code: "ci.red", attempt: 1 } } };
137
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
138
+ const ctxs = [];
139
+ const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 1 }, readBody: async () => "",
140
+ runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return NH("ci.red"); } });
141
+ assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1 }]);
142
+ assert.equal(rows[0].outcome, "needs-human"); assert.equal(rows[0].recovery.code, "ci.red");
143
+ assert.deepEqual(state.recovering, {});
144
+ });
145
+
146
+ it("other runner decisions are recorded too (engine-error relaunch, dependency requeue, sub-issues), and a throwing recorder never blocks the run", async () => {
147
+ const { decisions } = await batch({ 1: [{ terminalReason: "engine-error", detail: "boom" }, { terminalReason: "merged" }], 2: [{ terminalReason: "decomposed", subissues: [8] }] });
148
+ assert.ok(decisions.some((d) => d.issue === 1 && /relaunch/.test(d.what) && d.why === "boom" && d.alternative));
149
+ assert.ok(decisions.some((d) => d.issue === 2 && /#8/.test(d.what)));
150
+ const rows = await orchestrate({ issues: [1], recovery: { maxAttempts: 1 }, readBody: async () => "", decide: () => { throw new Error("gh down"); },
151
+ runOne: async (n, l, ctx) => (ctx.recover ? { terminalReason: "merged" } : NH("ci.red")) });
152
+ assert.equal(rows[0].recovered, "ci.red");
153
+ });
154
+
155
+
156
+ it("run-issue --recover reaches runCore; a batch relaunches a ci.red lane with recover and reports it", async () => {
157
+ const got = [];
158
+ const l = console.log; console.log = () => {};
159
+ try {
160
+ await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "base.conflict", "--recover-attempt", "1"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
161
+ leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
162
+ } finally { console.log = l; }
163
+ assert.deepEqual(got, [{ code: "base.conflict", attempt: 1 }]);
164
+ // The batch: a ci.red lane is relaunched in-process with recover, the decision is recorded and the report lists it.
165
+ const leanIo = {
166
+ gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
167
+ git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
168
+ };
169
+ const runs = [], decisions = [], logs = [];
170
+ console.log = (x) => logs.push(String(x));
171
+ let res;
172
+ try {
173
+ res = await orchestrateFromCli(["5", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("core-rec-"), batchRoot: tmpDir("core-rec-batch-"), allowTmpfs: true, emitTelemetry: async () => {},
174
+ io: { gh: async () => "o/r\n" }, leanIo, sched: null, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: (d) => decisions.push(d),
175
+ leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return o.recover ? { terminalReason: "merged", pr: 55 } : { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
176
+ } finally { console.log = l; }
177
+ assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1 }]);
178
+ assert.equal(res.ok, true);
179
+ assert.ok(decisions.some((d) => d.issue === 5 && /recovery lane for ci\.red/.test(d.what)));
180
+ assert.ok(logs.includes("recovered: #5 (ci.red)"), logs.join("\n"));
181
+ });
182
+
183
+ it("/orchestrate spec step 3 lists the recoverable codes, asks no confirmation on them, and escalates once at the end", async () => {
184
+ const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
185
+ const step3 = spec.slice(spec.indexOf("## 3. Launch lanes"), spec.indexOf("## 4. Report"));
186
+ for (const c of ["`ci.red`", "`base.conflict`", "`ci-pending` on a conflicting", "`contract.invalid` caused by an issue edited mid-run"]) assert.ok(step3.includes(c), c);
187
+ assert.match(step3, /\*\*Do not ask for confirmation on them\.\*\*/);
188
+ assert.match(step3, /FORGE:DECISION/);
189
+ assert.match(step3, /one question list[\s\S]*once, at the end of the batch/);
190
+ assert.match(spec, /recovered: #A \(ci\.red\) #B \(base\.conflict\)/);
191
+ });
192
+ });
193
+
194
+ describe("#4298 staging-review follow-ups: recovery", () => {
195
+ it("RECOVER_HOW has a prompt for exactly the RECOVERABLE_CODES", () => {
196
+ assert.deepEqual(Object.keys(RECOVER_HOW).sort(), [...RECOVERABLE_CODES].sort());
197
+ for (const c of RECOVERABLE_CODES) assert.ok(String(RECOVER_HOW[c]).length > 20, c);
198
+ });
199
+
200
+ it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt}", async () => {
201
+ let t = 0; const seen = [];
202
+ const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 0.5 }, { terminalReason: "merged", pr: 9, cost_usd: 0.25 }],
203
+ 2: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 2 }] };
204
+ const k = { 1: 0, 2: 0 };
205
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, recovery: { maxAttempts: 1 }, readBody: async () => "", now: () => t,
206
+ runOne: async (n, _l, ctx = {}) => { seen.push(ctx.recover ?? null); t += 60000 * (n === 1 ? 3 : 1); return script[n][k[n]++]; } });
207
+ const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
208
+ assert.equal(r[1].outcome, "merged"); assert.equal(r[1].cost_usd, 0.75); assert.equal(r[1].minutes, 6);
209
+ assert.equal(r[2].outcome, "needs-human"); assert.equal(r[2].cost_usd, 3); assert.equal(r[2].minutes, 2);
210
+ assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1 }, { code: "ci.red", attempt: 1 }]);
211
+ });
212
+
213
+ it("formatOutcomeLists caps a detail at OUTCOME_LINE_MAX", () => {
214
+ const [l] = formatOutcomeLists([{ issue: 3, outcome: "needs-human", code: "conformance.omission", detail: "x".repeat(500) }]);
215
+ assert.ok(l.includes("x".repeat(OUTCOME_LINE_MAX)) && !l.includes("x".repeat(OUTCOME_LINE_MAX + 1)));
216
+ });
217
+
218
+ it("run-issue rejects --recover on the legacy --path full", async () => {
219
+ await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "full", "--recover", "ci.red"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" } }),
220
+ /--recover is not supported with --path full/);
221
+ });
222
+
223
+ it("/orchestrate spec describes terminal non-human needs-human rows, --recover scope and the schedd resume scope", () => {
224
+ const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
225
+ assert.match(spec, /\*\*terminal code\*\* that is neither recoverable nor human/);
226
+ assert.match(spec, /`unrecovered: #N/);
227
+ assert.match(spec, /core path only:\s+`--path full` rejects `--recover`/);
228
+ assert.match(spec, /resumes every daemon-hosted batch that has no report yet/);
229
+ });
230
+ });