forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  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/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
package/bin/journey.mjs CHANGED
@@ -2,11 +2,12 @@
2
2
  * bin/journey.mjs — The five-act onboarding journey for ForgeDock.
3
3
  *
4
4
  * Acts (added across Tasks 4–8):
5
- * preflight() → forge() → read() → review() → celebrate()
5
+ * preflight() → forge() → read() → setupRepo() → review() → celebrate()
6
+ * (setupRepo, forge#4173: interactive repo creation / main push / staging)
6
7
  *
7
8
  * This file owns forge.yaml generation (single source of truth, driven from
8
9
  * detection values) and orchestrates the existing modules:
9
- * init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
10
+ * init-detect.mjs, init-enrich-api.mjs, init-repo.mjs, tui.annotatedReviewScreen, registry.mjs
10
11
  */
11
12
 
12
13
  import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
@@ -16,6 +17,24 @@ import { join, basename } from "path";
16
17
  // forge.yaml generation (Task 4)
17
18
  // ---------------------------------------------------------------------------
18
19
 
20
+ /**
21
+ * Resolve the Claude Code configuration directory (forge#4173): a non-empty
22
+ * CLAUDE_CONFIG_DIR wins (Claude Code reads commands, settings.json and MCP
23
+ * config from there), otherwise `<home>/.claude`. Shared by the installer
24
+ * here and by doctor/status/uninstall in forgedock.mjs and the engine, so
25
+ * every path agrees on one directory (no #1589 split-brain).
26
+ *
27
+ * @param {Record<string, string|undefined>} [env]
28
+ * @param {string} [home]
29
+ * @returns {string}
30
+ */
31
+ export function resolveClaudeConfigDir(env = process.env, home = env.HOME || env.USERPROFILE || os.homedir()) {
32
+ return env.CLAUDE_CONFIG_DIR || join(home, ".claude");
33
+ }
34
+
35
+ /** Claude config dir for a journey ctx. */
36
+ const claudeDirOf = (ctx) => resolveClaudeConfigDir(ctx.env ?? process.env, ctx.home);
37
+
19
38
  const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n");
20
39
 
21
40
  /** Append the TODO flag for a low-confidence field. */
@@ -508,47 +527,88 @@ export function validateForgeYamlShape(cwd) {
508
527
  }
509
528
 
510
529
  // ---------------------------------------------------------------------------
511
- // Description detection (moved from forgedock.mjs init(), Task 4)
530
+ // Description detection (forge#4173)
512
531
  // ---------------------------------------------------------------------------
532
+ //
533
+ // The description comes from explicit project metadata only. README/CLAUDE.md
534
+ // prose is never used: its first paragraph is routinely a badge line, a
535
+ // tagline, or setup text, and was written into forge.yaml as if it were the
536
+ // project description (field-test friction in #4155).
537
+
538
+ const MAX_DESCRIPTION = 200;
513
539
 
514
- function firstParagraph(content) {
515
- const lines = content.split("\n");
516
- let started = false;
517
- const out = [];
518
- for (const line of lines) {
519
- if (!started && line.match(/^#/)) continue;
520
- if (!started && line.trim() === "") continue;
521
- if (!started && line.match(/^[!<\[`|]/)) continue;
522
- if (!started && line.match(/^---/)) continue;
523
- if (!started) started = true;
524
- if (line.trim() === "") break;
525
- out.push(line.trim());
526
- }
527
- if (out.length === 0) return "";
528
- return out
529
- .join(" ")
530
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
531
- .replace(/\*\*([^*]+)\*\*/g, "$1")
532
- .replace(/`([^`]+)`/g, "$1")
533
- .slice(0, 200)
534
- .trim();
540
+ function clean(value) {
541
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_DESCRIPTION).trim();
535
542
  }
536
543
 
537
544
  /**
538
- * Detect a project description from README.md, falling back to CLAUDE.md.
539
- * @returns {{ value: string, source: 'README.md'|'CLAUDE.md'|'' }}
545
+ * Read `description = "..."` from the first matching TOML table.
546
+ * Minimal line-based reader: handles basic and literal single-line strings.
547
+ * @param {string} content
548
+ * @param {string[]} tables - e.g. ["project", "tool.poetry"]
549
+ * @returns {string}
540
550
  */
541
- export function detectDescription(cwd) {
542
- for (const file of ["README.md", "CLAUDE.md"]) {
551
+ function tomlTableDescription(content, tables) {
552
+ let current = "";
553
+ const found = new Map();
554
+ for (const raw of content.split("\n")) {
555
+ const line = raw.trim();
556
+ const header = line.match(/^\[([^\[\]]+)\]\s*(#.*)?$/);
557
+ if (header) {
558
+ current = header[1].trim();
559
+ continue;
560
+ }
561
+ if (!tables.includes(current) || found.has(current)) continue;
562
+ const m = line.match(/^description\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/);
563
+ if (m) found.set(current, m[1] !== undefined ? m[1].replace(/\\(["\\])/g, "$1") : m[2]);
564
+ }
565
+ for (const t of tables) if (found.get(t)) return found.get(t);
566
+ return "";
567
+ }
568
+
569
+ const MANIFEST_SOURCES = [
570
+ {
571
+ file: "package.json",
572
+ read: (content) => {
573
+ const pkg = JSON.parse(content);
574
+ return typeof pkg.description === "string" ? pkg.description : "";
575
+ },
576
+ },
577
+ { file: "pyproject.toml", read: (content) => tomlTableDescription(content, ["project", "tool.poetry"]) },
578
+ { file: "Cargo.toml", read: (content) => tomlTableDescription(content, ["package"]) },
579
+ ];
580
+
581
+ /**
582
+ * Detect a project description from project metadata: package.json,
583
+ * pyproject.toml, Cargo.toml, then (best effort) the GitHub repository
584
+ * description. Never reads README.md or CLAUDE.md.
585
+ *
586
+ * @param {string} cwd
587
+ * @param {object} [opts]
588
+ * @param {Function} [opts.exec] - exec(cmd, args) → stdout. When given with
589
+ * opts.repo, used for `gh repo view <repo> --json description`.
590
+ * @param {string} [opts.repo] - "owner/name" of a detected GitHub remote.
591
+ * @returns {{ value: string, source: string }}
592
+ */
593
+ export function detectDescription(cwd, { exec, repo } = {}) {
594
+ for (const { file, read } of MANIFEST_SOURCES) {
543
595
  try {
544
596
  const p = join(cwd, file);
545
597
  if (!existsSync(p)) continue;
546
- const value = firstParagraph(readFileSync(p, "utf-8").slice(0, 2048));
598
+ const value = clean(read(readFileSync(p, "utf-8")));
547
599
  if (value) return { value, source: file };
548
600
  } catch {
549
601
  // best-effort only
550
602
  }
551
603
  }
604
+ if (exec && repo) {
605
+ try {
606
+ const value = clean(exec("gh", ["repo", "view", repo, "--json", "description", "--jq", ".description"]));
607
+ if (value) return { value, source: "GitHub repository" };
608
+ } catch {
609
+ // gh missing, unauthenticated, or repo not on GitHub — no description
610
+ }
611
+ }
552
612
  return { value: "", source: "" };
553
613
  }
554
614
 
@@ -685,10 +745,10 @@ export async function preflight(ctx) {
685
745
  {
686
746
  label: "Claude Code",
687
747
  run: async () => {
688
- const claudeDir = join(ctx.home, ".claude");
748
+ const claudeDir = claudeDirOf(ctx);
689
749
  return existsSync(claudeDir)
690
- ? { ok: true, detail: "~/.claude found" }
691
- : { ok: false, detail: "~/.claude not found", fix: ["Install Claude Code: https://claude.com/claude-code"] };
750
+ ? { ok: true, detail: `${claudeDir} found` }
751
+ : { ok: false, detail: `${claudeDir} not found`, fix: ["Install Claude Code: https://claude.com/claude-code"] };
692
752
  },
693
753
  },
694
754
  {
@@ -1701,7 +1761,7 @@ export async function persistHome(ctx) {
1701
1761
  */
1702
1762
  async function linkPipelineScripts(ctx) {
1703
1763
  const scriptsSourceDir = join(ctx.forgeHome, "scripts");
1704
- const scriptsTargetDir = join(ctx.home, ".claude", "scripts");
1764
+ const scriptsTargetDir = join(claudeDirOf(ctx), "scripts");
1705
1765
  const wantSymlink = ctx.linkStrategy !== "copy";
1706
1766
 
1707
1767
  await mkdir(scriptsTargetDir, { recursive: true });
@@ -2157,7 +2217,7 @@ function renderForgeReceipt(ctx, results) {
2157
2217
  const scriptsDetail = scriptsChanged > 0
2158
2218
  ? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
2159
2219
  : `(unchanged ${scriptsResult.skipped})`;
2160
- w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
2220
+ w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ${join(claudeDirOf(ctx), "scripts")} ${dimLine(ctx, scriptsDetail)}\n`);
2161
2221
  }
2162
2222
  if (manifestSaveFailed) {
2163
2223
  w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
@@ -2253,8 +2313,8 @@ function renderForgeReceipt(ctx, results) {
2253
2313
  export async function forge(ctx) {
2254
2314
  const { stdout: w } = ctx;
2255
2315
  const commandsDir = join(ctx.forgeHome, "commands");
2256
- const targetDir = join(ctx.home, ".claude", "commands");
2257
- const manifestPath = join(ctx.home, ".claude", "forgedock", "copied-commands.json");
2316
+ const targetDir = join(claudeDirOf(ctx), "commands");
2317
+ const manifestPath = join(claudeDirOf(ctx), "forgedock", "copied-commands.json");
2258
2318
  const wantSymlink = ctx.linkStrategy !== "copy";
2259
2319
 
2260
2320
  w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
@@ -2382,7 +2442,7 @@ export async function forge(ctx) {
2382
2442
  const scriptsResult = await linkPipelineScripts(ctx);
2383
2443
 
2384
2444
  const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
2385
- const settingsPath = join(ctx.home, ".claude", "settings.json");
2445
+ const settingsPath = join(claudeDirOf(ctx), "settings.json");
2386
2446
  const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
2387
2447
 
2388
2448
  // Install enforcement hooks (#1250): PreToolUse (branch/label validation)
@@ -2426,7 +2486,11 @@ export async function forge(ctx) {
2426
2486
  import { detectConfig } from "./init-detect.mjs";
2427
2487
  import { enrich, resolveEnrichBackend } from "./init-enrich.mjs";
2428
2488
  import { isClaudeCliAvailable } from "./runner.mjs";
2429
- import { annotatedReviewScreen, box, confirm, getLogoTagline } from "./tui.mjs";
2489
+ import { annotatedReviewScreen, box, confirm, getLogoTagline, input, select } from "./tui.mjs";
2490
+ import {
2491
+ defaultExec as repoExec, listOwners, listAppInstallations, ownerCoverageWarning,
2492
+ createRepo, ensureInitialCommit, pushMain, createStaging,
2493
+ } from "./init-repo.mjs";
2430
2494
 
2431
2495
  const badgeOf = (field) => field.confidence;
2432
2496
 
@@ -2440,7 +2504,7 @@ export async function read(ctx) {
2440
2504
  w.write("\n " + ember("Reading your repository", ctx.mode) + "\n\n");
2441
2505
 
2442
2506
  let draft = await detectConfig(ctx.cwd);
2443
- const description = detectDescription(ctx.cwd);
2507
+ const description = detectDescription(ctx.cwd, descriptionOpts(ctx, draft));
2444
2508
 
2445
2509
  const rows = [
2446
2510
  { label: "owner/repo", run: async () => ({ ok: true, detail: `${draft.project.owner.value}/${draft.project.repo.value}`, badge: badgeOf(draft.project.owner) }) },
@@ -2466,7 +2530,9 @@ export async function read(ctx) {
2466
2530
  try {
2467
2531
  w.write(" " + dimLine(ctx, "✦ enriching with AI…") + "\n");
2468
2532
  const enriched = await ctx.enrichFn(draft, { backend: enrichBackend, cwd: ctx.cwd, env: ctx.env });
2469
- if (enriched && typeof enriched === "object") draft = enriched;
2533
+ // meta holds detection facts (remote/commit/staging state) that drive
2534
+ // setupRepo() — never let model output rewrite or drop them (#4173).
2535
+ if (enriched && typeof enriched === "object") draft = { ...enriched, meta: draft.meta };
2470
2536
  } catch {
2471
2537
  w.write(" " + dimLine(ctx, "✦ AI enrichment unavailable — continuing with detection only") + "\n");
2472
2538
  }
@@ -2477,6 +2543,163 @@ export async function read(ctx) {
2477
2543
  return { draft, description };
2478
2544
  }
2479
2545
 
2546
+ /** gh lookup options for detectDescription — only with a detected GitHub remote. */
2547
+ function descriptionOpts(ctx, draft) {
2548
+ return draft?.meta?.remoteDetected
2549
+ ? { exec: ctx.exec, repo: `${draft.project.owner.value}/${draft.project.repo.value}` }
2550
+ : {};
2551
+ }
2552
+
2553
+ // ---------------------------------------------------------------------------
2554
+ // Act III.5 — Repository setup (forge#4173)
2555
+ // ---------------------------------------------------------------------------
2556
+
2557
+ /**
2558
+ * Bind the bin/init-repo.mjs helpers to one exec. Default uses
2559
+ * init-repo's own exec (longer timeout than ctx.exec — pushes and
2560
+ * `gh repo create` can take a while).
2561
+ * @param {Function} [exec]
2562
+ */
2563
+ export function bindRepoOps(exec = repoExec) {
2564
+ return {
2565
+ listOwners: () => listOwners(exec),
2566
+ listAppInstallations: (owners) => listAppInstallations(exec, owners),
2567
+ createRepo: (opts) => createRepo(exec, opts),
2568
+ ensureInitialCommit: (root) => ensureInitialCommit(exec, root),
2569
+ pushMain: (root, branch) => pushMain(exec, root, branch),
2570
+ createStaging: (root, from) => createStaging(exec, root, from),
2571
+ };
2572
+ }
2573
+
2574
+ const COVERAGE_BADGE = {
2575
+ installed: "ForgeDock App installed",
2576
+ "not-installed": "App NOT installed",
2577
+ unknown: "App status unknown",
2578
+ };
2579
+
2580
+ /**
2581
+ * Act III.5: when the repo has no GitHub remote, no commits, or no
2582
+ * `staging` branch, offer to fix that instead of writing placeholders and
2583
+ * TODOs into forge.yaml:
2584
+ * - create the GitHub repo (private by default) under an owner picked from
2585
+ * a list that shows where the ForgeDock GitHub App is installed, warning
2586
+ * when the chosen owner is not covered;
2587
+ * - make an empty initial commit when HEAD is unborn, push `main`;
2588
+ * - create `staging` from the default branch (fast-lane convention).
2589
+ * Then re-runs detection so the draft carries the real values.
2590
+ *
2591
+ * Interactive only: non-TTY and --fast runs return the draft untouched and
2592
+ * make no git/GitHub calls. Declining any step stops there and keeps the
2593
+ * existing placeholder/TODO behaviour. Failures are reported, never thrown.
2594
+ *
2595
+ * Injectable: ctx.confirmFn, ctx.selectFn, ctx.inputFn, ctx.repoOps,
2596
+ * ctx.isTTY (defaults to process.stdin.isTTY).
2597
+ *
2598
+ * @returns {Promise<{ draft: object, changed: boolean, created?: string, error?: string }>}
2599
+ */
2600
+ export async function setupRepo(ctx, draft) {
2601
+ const { stdout: w } = ctx;
2602
+ const meta = draft?.meta ?? {};
2603
+ const isTTY = ctx.isTTY ?? process.stdin.isTTY === true;
2604
+ const needsRepo = meta.remoteDetected === false;
2605
+ const needsCommit = meta.hasCommits === false;
2606
+ const needsStaging = meta.stagingMissing === true;
2607
+ if (!isTTY || ctx.argv.includes("--fast") || !(needsRepo || needsCommit || needsStaging)) {
2608
+ return { draft, changed: false };
2609
+ }
2610
+
2611
+ const ops = ctx.repoOps ?? bindRepoOps();
2612
+ const confirmFn = ctx.confirmFn ?? confirm;
2613
+ const selectFn = ctx.selectFn ?? select;
2614
+ const inputFn = ctx.inputFn ?? input;
2615
+ const root = draft.paths.root.value;
2616
+ const branch = meta.unbornBranch || draft.branches.default.value || "main";
2617
+ const note = (s) => w.write(" " + dimLine(ctx, s) + "\n");
2618
+ const warnLine = (s) => w.write(` ${ctx.mode === "none" ? "⚠" : "\x1b[38;2;255;179;71m⚠\x1b[0m"} ${s}\n`);
2619
+
2620
+ w.write("\n " + ember("Setting up your repository", ctx.mode) + "\n\n");
2621
+
2622
+ let created;
2623
+ let changed = false;
2624
+ try {
2625
+ if (needsRepo) {
2626
+ const yes = await confirmFn("No GitHub repository is connected. Create one now?", true);
2627
+ if (!yes) {
2628
+ note("Skipped — forge.yaml will keep placeholder owner/repo values.");
2629
+ return { draft, changed: false };
2630
+ }
2631
+ const owners = ops.listOwners();
2632
+ if (owners.length === 0) {
2633
+ note("Could not list GitHub accounts — run `gh auth login`, then `npx forgedock init` again.");
2634
+ return { draft, changed: false };
2635
+ }
2636
+ const coverage = ops.listAppInstallations(owners);
2637
+ const choices = owners.map((o) => ({
2638
+ label: `${o.login} (${o.type === "Organization" ? "org" : "personal"} · ${COVERAGE_BADGE[coverage.get(o.login) ?? "unknown"]})`,
2639
+ value: o.login,
2640
+ }));
2641
+ const firstCovered = owners.findIndex((o) => coverage.get(o.login) === "installed");
2642
+ let owner;
2643
+ for (;;) {
2644
+ owner = await selectFn("Repository owner", choices, { initialIndex: Math.max(firstCovered, 0) });
2645
+ if (!owner) {
2646
+ note("Skipped — no owner chosen.");
2647
+ return { draft, changed: false };
2648
+ }
2649
+ const warning = ownerCoverageWarning(owner, coverage.get(owner));
2650
+ if (!warning) break;
2651
+ warnLine(warning);
2652
+ if (await confirmFn(`Create the repository under '${owner}' anyway?`, false)) break;
2653
+ }
2654
+ const name = await inputFn("Repository name", basename(root));
2655
+ const isPrivate = await confirmFn("Make the repository private?", true);
2656
+ const repo = ops.createRepo({ owner, name, private: isPrivate, root, branch });
2657
+ created = repo.fullName;
2658
+ changed = true;
2659
+ w.write(` ✔ Created ${repo.visibility} repository ${repo.fullName}\n`);
2660
+ }
2661
+
2662
+ if (needsCommit) {
2663
+ const yes = await confirmFn(`The repository has no commits. Create an empty initial commit on '${branch}' so it can be pushed?`, true);
2664
+ if (!yes) {
2665
+ note(`Skipped — push '${branch}' and create 'staging' yourself once you have a commit.`);
2666
+ return await finish();
2667
+ }
2668
+ ops.ensureInitialCommit(root);
2669
+ changed = true;
2670
+ }
2671
+
2672
+ if (needsRepo || needsCommit) {
2673
+ ops.pushMain(root, branch);
2674
+ changed = true;
2675
+ w.write(` ✔ Pushed ${branch}\n`);
2676
+ }
2677
+
2678
+ if (needsStaging || needsRepo) {
2679
+ const yes = await confirmFn(`Create a 'staging' branch from '${branch}'? (ForgeDock fast-lane PRs merge into staging)`, true);
2680
+ if (yes) {
2681
+ const res = ops.createStaging(root, branch);
2682
+ changed = true;
2683
+ w.write(` ✔ ${res.created ? "Created" : "Found"} staging\n`);
2684
+ } else {
2685
+ note(`Skipped — branches.staging will default to '${branch}'.`);
2686
+ }
2687
+ }
2688
+ } catch (err) {
2689
+ const msg = String(err?.stderr || err?.message || err).trim().split("\n")[0];
2690
+ warnLine(`Repository setup failed: ${msg}`);
2691
+ note("Continuing with the detected values — fix this and re-run `npx forgedock init`.");
2692
+ return await finish(msg);
2693
+ }
2694
+ return await finish();
2695
+
2696
+ async function finish(error) {
2697
+ if (!changed) return { draft, changed: false, ...(error ? { error } : {}) };
2698
+ const redetected = await detectConfig(ctx.cwd);
2699
+ return { draft: redetected, changed: true, ...(created ? { created } : {}), ...(error ? { error } : {}) };
2700
+ }
2701
+ }
2702
+
2480
2703
  // ---------------------------------------------------------------------------
2481
2704
  // Act IV — The Review (Task 8)
2482
2705
  // ---------------------------------------------------------------------------
@@ -2516,7 +2739,7 @@ export async function review(ctx, draft, description) {
2516
2739
  }
2517
2740
 
2518
2741
  const extraFields = description.value
2519
- ? { description: { value: description.value, confidence: "medium", source: description.source, why: `First paragraph of ${description.source}` } }
2742
+ ? { description: { value: description.value, confidence: "medium", source: description.source, why: `Description from ${description.source}` } }
2520
2743
  : {};
2521
2744
 
2522
2745
  const accepted = await annotatedReviewScreen(draft, {
@@ -2574,7 +2797,7 @@ export function celebrate(ctx, summary) {
2574
2797
  w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
2575
2798
  }
2576
2799
  if (summary.scriptsResult && summary.scriptsResult.total > 0) {
2577
- w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, "~/.claude/scripts — classify-lane, transition-label, validate-pr-target")}\n`);
2800
+ w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, `${join(claudeDirOf(ctx), "scripts")} — classify-lane, transition-label, validate-pr-target`)}\n`);
2578
2801
  }
2579
2802
  w.write("\n");
2580
2803
  if (summary.isMinimal) {
@@ -2840,6 +3063,18 @@ export async function maybeOfferDemo(ctx) {
2840
3063
  * Acts I→V. Returns the process exit code.
2841
3064
  * SIGINT: restore cursor, summarize partial state, exit 130.
2842
3065
  */
3066
+ /**
3067
+ * Run setupRepo() on read()'s result; when it changed the repository,
3068
+ * re-detect the description against the new state.
3069
+ * @returns {Promise<{ draft: object, description: { value: string, source: string } }>}
3070
+ */
3071
+ export async function setupAfterRead(ctx, { draft, description }) {
3072
+ const setup = await setupRepo(ctx, draft);
3073
+ if (!setup.changed) return { draft, description };
3074
+ const fresh = description.value ? description : detectDescription(ctx.cwd, descriptionOpts(ctx, setup.draft));
3075
+ return { draft: setup.draft, description: fresh };
3076
+ }
3077
+
2843
3078
  export async function runJourney(ctx) {
2844
3079
  const onSigint = () => {
2845
3080
  ctx.stdout.write("\x1b[0m\x1b[?25h\n Interrupted. Partial state: commands may be installed; config not written.\n Finish anytime with: npx forgedock init\n");
@@ -2860,7 +3095,7 @@ export async function runJourney(ctx) {
2860
3095
  ctx.forgeHome = persistResult.forgeHome;
2861
3096
  }
2862
3097
  const forged = await forge(ctx);
2863
- const { draft, description } = await read(ctx);
3098
+ const { draft, description } = await setupAfterRead(ctx, await read(ctx));
2864
3099
  const reviewed = await review(ctx, draft, description);
2865
3100
  const connected = await connect(ctx);
2866
3101
  celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus, scriptsResult: forged.scriptsResult });
@@ -4,9 +4,17 @@
4
4
  * Host-resource admission for the core work-on (#4105): refuse to start a run when the host is out of headroom, so a
5
5
  * batch of lanes does not push the machine into OOM or a full /tmp. Every probe is injected through `host`.
6
6
  */
7
- export const ADMISSION_DEFAULTS = Object.freeze({ enabled: true, min_free_mem_mb: 2048, min_free_tmp_mb: 1024, max_load_per_core: 4 });
7
+ // #4169: load average lags by minutes and is phase-blind, so it is off unless configured; heavy steps are gated by
8
+ // the host heavy-step semaphore (heavy.mjs) instead. An explicit `max_load_per_core` is still honoured.
9
+ export const ADMISSION_DEFAULTS = Object.freeze({ enabled: true, min_free_mem_mb: 2048, min_free_tmp_mb: 1024, max_load_per_core: null });
8
10
  export const ADMISSION_RETRY_SECONDS = 60;
9
11
 
12
+ /** Workers one verification step may use (#4182): max(1, floor(cores / lanes)); bad inputs count as 1. Heavy shims pass live heavy holders (#4169). */
13
+ export function hostParallelism({ cores, lanes } = {}) {
14
+ const c = Number.isFinite(cores) && cores > 0 ? cores : 1, l = Number.isFinite(lanes) && lanes > 0 ? lanes : 1;
15
+ return Math.max(1, Math.floor(c / l));
16
+ }
17
+
10
18
  const gb = (mb) => { const g = mb / 1024; return `${Number.isInteger(g) ? g : g.toFixed(1)}G`; };
11
19
 
12
20
  /**
@@ -26,7 +34,29 @@ export async function checkAdmission({ cfg = {}, host }) {
26
34
  const free = await safe(host.freeMb, p);
27
35
  if (free !== null && free < c.min_free_tmp_mb) reasons.push(`tmp ${p} ${gb(free)} < ${gb(c.min_free_tmp_mb)}`);
28
36
  }
29
- const load = await safe(host.loadAvg1), cores = await safe(host.cores);
30
- if (load !== null && cores && load / cores > c.max_load_per_core) reasons.push(`load ${(load / cores).toFixed(1)}/core > ${c.max_load_per_core}`);
37
+ const maxLoad = typeof c.max_load_per_core === "number" && Number.isFinite(c.max_load_per_core) ? c.max_load_per_core : null;
38
+ const load = maxLoad === null ? null : await safe(host.loadAvg1), cores = maxLoad === null ? null : await safe(host.cores);
39
+ if (load !== null && cores && load / cores > maxLoad) reasons.push(`load ${(load / cores).toFixed(1)}/core > ${maxLoad}`);
31
40
  return { ok: reasons.length === 0, reasons, detail: reasons.length ? `host busy: ${reasons.join(", ")}; retry in ${ADMISSION_RETRY_SECONDS}s` : "" };
32
41
  }
42
+
43
+ /**
44
+ * Near the admission ceiling (#4183): within `ratio` of any checkAdmission threshold (load/core >= ratio * max, or free
45
+ * mem/tmp <= min / ratio). Orchestrate runs owned-resource GC before an admission when this is true. A failed or
46
+ * non-finite probe never counts as near.
47
+ */
48
+ export async function nearCeiling({ cfg = {}, host, ratio = 0.8 } = {}) {
49
+ const c = { ...ADMISSION_DEFAULTS, ...Object.fromEntries(Object.entries(cfg ?? {}).filter(([, v]) => v !== undefined && v !== null)) };
50
+ if (c.enabled === false || !host) return false;
51
+ const r = Number.isFinite(ratio) && ratio > 0 && ratio <= 1 ? ratio : 0.8;
52
+ const safe = async (fn, ...a) => { try { const v = await fn?.(...a); return Number.isFinite(v) ? v : null; } catch { return null; } };
53
+ const maxLoad = typeof c.max_load_per_core === "number" && Number.isFinite(c.max_load_per_core) ? c.max_load_per_core : null; // off by default (#4169)
54
+ const load = maxLoad === null ? null : await safe(host.loadAvg1), cores = maxLoad === null ? null : await safe(host.cores);
55
+ if (load !== null && cores && load / cores >= r * maxLoad) return true;
56
+ const mem = await safe(host.memAvailableMb);
57
+ if (mem !== null && mem <= c.min_free_mem_mb / r) return true;
58
+ let paths = [];
59
+ try { paths = (await host.tmpPaths?.()) ?? []; } catch { paths = []; }
60
+ for (const p of paths) { const free = await safe(host.freeMb, p); if (free !== null && free <= c.min_free_tmp_mb / r) return true; }
61
+ return false;
62
+ }
@@ -4,12 +4,14 @@
4
4
  * Durable batch state and detached lanes for `forgedock orchestrate` (#4121).
5
5
  *
6
6
  * A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
7
- * scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K) and
8
- * `lanes/<issue>.{json,log}` (each lane's result and output). Every write is tmp + fsync + rename, and the root must be
7
+ * scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K),
8
+ * `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
9
+ * when the scheduler loop returns; `forgedock orchestrate --resume` on a finished batch replays it, #4192). Every write is tmp + fsync + rename, and the root must be
9
10
  * on disk, never tmpfs. A lane is its own session (`setsid` through `detached`), so killing the dispatcher leaves it
10
- * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path.
11
+ * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path. These
12
+ * detached lanes are the `local` execution (#4253); `execution.mjs` is the sandbox-provider seam for remote lanes.
11
13
  */
12
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
14
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
13
15
  import { spawn } from "node:child_process";
14
16
  import { homedir } from "node:os";
15
17
  import { dirname, join, resolve, sep } from "node:path";
@@ -63,6 +65,13 @@ export function newBatchId(now = Date.now(), pid = process.pid) {
63
65
  return `b${new Date(now).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${pid}`;
64
66
  }
65
67
 
68
+ /** Ids of the batches under `root` (directories with a `plan.json`). */
69
+ export function listBatches(root) {
70
+ let names = [];
71
+ try { names = readdirSync(root); } catch { return []; }
72
+ return names.filter((n) => ID_RE.test(n) && existsSync(join(root, n, "plan.json"))).sort();
73
+ }
74
+
66
75
  /** Handle on one batch directory. */
67
76
  export function openBatch(root, id) {
68
77
  if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
@@ -84,6 +93,15 @@ export function openBatch(root, id) {
84
93
  if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
85
94
  writeAtomic(f("parallel"), `${k}\n`);
86
95
  },
96
+ /** The lane updates in `log.jsonl`, in order (unparsable lines skipped). */
97
+ readLog: () => {
98
+ let text = "";
99
+ try { text = readFileSync(f("log.jsonl"), "utf8"); } catch { return []; }
100
+ return text.split("\n").flatMap((l) => { try { return l ? [JSON.parse(l)] : []; } catch { return []; } });
101
+ },
102
+ readReport: () => readJson(f("report.json")),
103
+ writeReport: (report) => writeAtomic(f("report.json"), JSON.stringify(report) + "\n"),
104
+ clearReport: () => rmSync(f("report.json"), { force: true }),
87
105
  resultFile: (n) => f(join("lanes", `${n}.json`)),
88
106
  logFile: (n) => f(join("lanes", `${n}.log`)),
89
107
  };
@@ -109,7 +127,8 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
109
127
  rmSync(rf, { force: true });
110
128
  const out = openSync(batch.logFile(issue), "a");
111
129
  try {
112
- const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env, detached: true, stdio: ["ignore", out, out] });
130
+ // FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
131
+ const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, stdio: ["ignore", out, out] });
113
132
  child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
114
133
  child.unref?.();
115
134
  return { pid: child.pid };
@@ -7,9 +7,11 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
10
- import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic } from "./batch.mjs";
10
+ import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
11
11
  import { orchestrateFromCli, runFromCli, readOrchestration } from "../engine-cli.mjs";
12
12
 
13
+ // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
14
+ process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
13
15
  const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
14
16
  const until = async (cond, ms = 15000) => {
15
17
  const end = Date.now() + ms;
@@ -87,6 +89,22 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
87
89
  assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
88
90
  });
89
91
 
92
+ it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
93
+ const { now, sleep } = fakeClock();
94
+ const updates = [];
95
+ let calls = 0;
96
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
97
+ onUpdate: (r) => updates.push(r),
98
+ runOne: async () => (++calls <= 3
99
+ ? { terminalReason: "deferred", code: "gh.unavailable", retryInSeconds: 60, detail: "gh repo view failed" }
100
+ : { terminalReason: "merged", pr: 5 }) });
101
+ assert.equal(calls, 4);
102
+ assert.equal(rows[0].outcome, "merged");
103
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
104
+ assert.equal(updates.filter((u) => /requeued in 60s: gh.unavailable/.test(u.detail ?? "")).length, 3);
105
+ assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
106
+ });
107
+
90
108
  it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
91
109
  const { now, sleep, c } = fakeClock();
92
110
  const t0 = c.t, starts = {};
@@ -139,6 +157,25 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
139
157
  assert.deepEqual([again, row.outcome], [[9, 9], "engine-error"]);
140
158
  });
141
159
 
160
+ it("#4153 a resume re-derives a pending issue's lane from its current milestone", async () => {
161
+ const saved = { state: null };
162
+ const store = { load: () => saved.state, save: (s) => { saved.state = JSON.parse(JSON.stringify(s)); } };
163
+ const milestoneLane = { 1: "staging", 2: "staging" };
164
+ const resolveLane = async (n) => ({ ok: true, lane: milestoneLane[n] });
165
+ const runs = [];
166
+ let rows = await orchestrate({ issues: [1, 2], parallel: 1, breaker: { errors: 1, window_min: 30 }, store, resolveLane, readBody: async () => "",
167
+ runOne: async (n, lane) => { runs.push([n, lane]); throw new Error("x"); } });
168
+ assert.deepEqual(rows.map((r) => r.outcome), ["held", "held"]);
169
+ assert.equal(saved.state.lanes["2"].lane, "staging");
170
+ milestoneLane[1] = milestoneLane[2] = "milestone/m1"; // the operator re-milestones both issues
171
+ saved.state = { ...saved.state, breaker: null, errorTimes: [] }; // what --resume does
172
+ rows = await orchestrate({ issues: [1, 2], parallel: 1, store, resolveLane, readBody: async () => "",
173
+ runOne: async (n, lane) => { runs.push([n, lane]); return { terminalReason: "merged" }; } });
174
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
175
+ assert.deepEqual(runs.slice(1).sort((a, b) => a[0] - b[0]), [[1, "milestone/m1"], [2, "milestone/m1"]]);
176
+ assert.equal(saved.state.lanes["2"].lane, "milestone/m1");
177
+ });
178
+
142
179
  it("concurrency follows the live K", async () => {
143
180
  let k = 1, active = 0, maxActive = 0;
144
181
  const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6, 7], parallel: 1, pollMs: 5, getParallel: () => k, readBody: async () => "",
@@ -166,9 +203,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
166
203
  assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
167
204
  assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
168
205
  assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
169
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3 });
170
- writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n");
171
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2 });
206
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null });
207
+ writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
208
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4 });
172
209
  });
173
210
 
174
211
  it("run-issue --result-file writes the lane result, also when the run throws", async () => {
@@ -182,3 +219,16 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
182
219
  assert.ok(!existsSync(`${rf}.tmp-${process.pid}`));
183
220
  });
184
221
  });
222
+
223
+ describe("lane launch env (#4179)", () => {
224
+ it("launchLane passes FORGEDOCK_BATCH_ID (the ledger dir) in the lane env and keeps the rest of the env and argv", () => {
225
+ const batch = openBatch(tmpDir("batch-"), "b20261019-120000-1");
226
+ const spawned = [];
227
+ const { pid } = launchLane({ batch, issue: 7, args: ["--repo", "o/r"], script: "/x/forgedock.mjs", bin: "node", env: { PATH: "/bin", KEEP: "1" },
228
+ spawnFn: (bin, argv, opts) => { spawned.push({ bin, argv, opts }); return { pid: 99, unref() {}, on() {} }; } });
229
+ assert.equal(pid, 99);
230
+ assert.equal(spawned[0].opts.env.FORGEDOCK_BATCH_ID, batch.id);
231
+ assert.equal(spawned[0].opts.env.KEEP, "1");
232
+ assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
233
+ });
234
+ });