forgedock 1.26.0 → 1.27.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 (42) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -0
  2. package/bin/engine/phases.mjs +38 -2
  3. package/bin/engine/telemetry-seam.mjs +6 -0
  4. package/bin/engine/telemetry-seam.test.mjs +4 -0
  5. package/bin/engine-cli.mjs +78 -20
  6. package/bin/engine.mjs +5 -1
  7. package/bin/forgedock.mjs +94 -30
  8. package/bin/init-detect.mjs +68 -3
  9. package/bin/init-enrich.mjs +3 -0
  10. package/bin/init-repo.mjs +213 -0
  11. package/bin/journey.mjs +278 -43
  12. package/bin/lean/batch.mjs +2 -1
  13. package/bin/lean/batch.test.mjs +49 -1
  14. package/bin/lean/conformance.mjs +76 -10
  15. package/bin/lean/conformance.test.mjs +32 -3
  16. package/bin/lean/effects.mjs +96 -11
  17. package/bin/lean/lean.mjs +326 -33
  18. package/bin/lean/lean.test.mjs +618 -10
  19. package/bin/lean/ledger.mjs +67 -0
  20. package/bin/lean/ledger.test.mjs +65 -0
  21. package/bin/lean/orchestrate.mjs +9 -0
  22. package/bin/lean/prompts.mjs +42 -8
  23. package/bin/lean/render.mjs +1 -1
  24. package/bin/lean/verify.mjs +113 -0
  25. package/bin/lean/verify.test.mjs +95 -0
  26. package/commands/adopt.md +3 -0
  27. package/commands/audit.md +3 -8
  28. package/commands/forgedock-init.md +118 -6
  29. package/commands/review-pr.md +2 -2
  30. package/docs/CONFIG.md +52 -3
  31. package/docs/OUTCOME-CODES.md +3 -0
  32. package/package.json +1 -1
  33. package/scripts/bootstrap-validate.mjs +287 -0
  34. package/scripts/check-devdocs-freshness.sh +120 -0
  35. package/scripts/check-devdocs-freshness.test.sh +120 -0
  36. package/scripts/ci-local/run.mjs +13 -2
  37. package/scripts/ci-local.test.sh +13 -1
  38. package/scripts/northstar-docs.test.sh +68 -0
  39. package/scripts/review-prepare.sh +11 -9
  40. package/scripts/review-prepare.test.sh +14 -0
  41. package/scripts/structure-delta.sh +150 -0
  42. package/scripts/structure-delta.test.sh +135 -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 });
@@ -109,7 +109,8 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
109
109
  rmSync(rf, { force: true });
110
110
  const out = openSync(batch.logFile(issue), "a");
111
111
  try {
112
- const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env, detached: true, stdio: ["ignore", out, out] });
112
+ // FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
113
+ 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
114
  child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
114
115
  child.unref?.();
115
116
  return { pid: child.pid };
@@ -7,7 +7,7 @@ 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
13
  const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
@@ -87,6 +87,22 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
87
87
  assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
88
88
  });
89
89
 
90
+ it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
91
+ const { now, sleep } = fakeClock();
92
+ const updates = [];
93
+ let calls = 0;
94
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
95
+ onUpdate: (r) => updates.push(r),
96
+ runOne: async () => (++calls <= 3
97
+ ? { terminalReason: "deferred", code: "gh.unavailable", retryInSeconds: 60, detail: "gh repo view failed" }
98
+ : { terminalReason: "merged", pr: 5 }) });
99
+ assert.equal(calls, 4);
100
+ assert.equal(rows[0].outcome, "merged");
101
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
102
+ assert.equal(updates.filter((u) => /requeued in 60s: gh.unavailable/.test(u.detail ?? "")).length, 3);
103
+ assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
104
+ });
105
+
90
106
  it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
91
107
  const { now, sleep, c } = fakeClock();
92
108
  const t0 = c.t, starts = {};
@@ -139,6 +155,25 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
139
155
  assert.deepEqual([again, row.outcome], [[9, 9], "engine-error"]);
140
156
  });
141
157
 
158
+ it("#4153 a resume re-derives a pending issue's lane from its current milestone", async () => {
159
+ const saved = { state: null };
160
+ const store = { load: () => saved.state, save: (s) => { saved.state = JSON.parse(JSON.stringify(s)); } };
161
+ const milestoneLane = { 1: "staging", 2: "staging" };
162
+ const resolveLane = async (n) => ({ ok: true, lane: milestoneLane[n] });
163
+ const runs = [];
164
+ let rows = await orchestrate({ issues: [1, 2], parallel: 1, breaker: { errors: 1, window_min: 30 }, store, resolveLane, readBody: async () => "",
165
+ runOne: async (n, lane) => { runs.push([n, lane]); throw new Error("x"); } });
166
+ assert.deepEqual(rows.map((r) => r.outcome), ["held", "held"]);
167
+ assert.equal(saved.state.lanes["2"].lane, "staging");
168
+ milestoneLane[1] = milestoneLane[2] = "milestone/m1"; // the operator re-milestones both issues
169
+ saved.state = { ...saved.state, breaker: null, errorTimes: [] }; // what --resume does
170
+ rows = await orchestrate({ issues: [1, 2], parallel: 1, store, resolveLane, readBody: async () => "",
171
+ runOne: async (n, lane) => { runs.push([n, lane]); return { terminalReason: "merged" }; } });
172
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
173
+ assert.deepEqual(runs.slice(1).sort((a, b) => a[0] - b[0]), [[1, "milestone/m1"], [2, "milestone/m1"]]);
174
+ assert.equal(saved.state.lanes["2"].lane, "milestone/m1");
175
+ });
176
+
142
177
  it("concurrency follows the live K", async () => {
143
178
  let k = 1, active = 0, maxActive = 0;
144
179
  const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6, 7], parallel: 1, pollMs: 5, getParallel: () => k, readBody: async () => "",
@@ -182,3 +217,16 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
182
217
  assert.ok(!existsSync(`${rf}.tmp-${process.pid}`));
183
218
  });
184
219
  });
220
+
221
+ describe("lane launch env (#4179)", () => {
222
+ it("launchLane passes FORGEDOCK_BATCH_ID (the ledger dir) in the lane env and keeps the rest of the env and argv", () => {
223
+ const batch = openBatch(tmpDir("batch-"), "b20261019-120000-1");
224
+ const spawned = [];
225
+ const { pid } = launchLane({ batch, issue: 7, args: ["--repo", "o/r"], script: "/x/forgedock.mjs", bin: "node", env: { PATH: "/bin", KEEP: "1" },
226
+ spawnFn: (bin, argv, opts) => { spawned.push({ bin, argv, opts }); return { pid: 99, unref() {}, on() {} }; } });
227
+ assert.equal(pid, 99);
228
+ assert.equal(spawned[0].opts.env.FORGEDOCK_BATCH_ID, batch.id);
229
+ assert.equal(spawned[0].opts.env.KEEP, "1");
230
+ assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
231
+ });
232
+ });