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/engine.mjs CHANGED
@@ -13,6 +13,7 @@ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postC
13
13
  import { reconcileState } from "./engine/reconcile.mjs";
14
14
  import { makeProjector } from "./engine/projector.mjs";
15
15
  import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
16
+ import { resolveClaudeConfigDir } from "./journey.mjs";
16
17
  import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText, isTransientSpawnError } from "./engine/outcome-codes.mjs";
17
18
 
18
19
  // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
@@ -82,7 +83,10 @@ export function ciRateLimitSleepMs(rl, nowMs, rand = Math.random) {
82
83
  */
83
84
  export function backendIdentity({ backend, env = process.env } = {}) {
84
85
  const sha = (s) => createHash("sha256").update(String(s)).digest("hex");
85
- const cfg = env.CLAUDE_CONFIG_DIR || `${homedir()}/.claude`;
86
+ // Shared resolver (forge#4173). The fallback keeps its legacy
87
+ // `${homedir()}/.claude` spelling so identities recorded before the shared
88
+ // resolver existed still match on Windows (path.join would use "\\").
89
+ const cfg = env.CLAUDE_CONFIG_DIR ? resolveClaudeConfigDir(env) : `${homedir()}/.claude`;
86
90
  const key = env.ANTHROPIC_API_KEY
87
91
  ? scryptSync(String(env.ANTHROPIC_API_KEY), "forgedock-backend-identity", 16).toString("hex") : "";
88
92
  return sha(JSON.stringify([backend ?? "auto", cfg, key])).slice(0, 16);
@@ -941,7 +945,7 @@ export async function runIssue(opts) {
941
945
  // "needs-human" — this is the engine/tool breaking, not a genuine
942
946
  // human-judgment block (see #2244/#2261). Any other thrown error is
943
947
  // a true unexpected crash and keeps propagating unchanged.
944
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS" || isDiskQuotaError(e)) {
948
+ if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_BACKEND_UNAVAILABLE" || e.code === "CLI_MAX_TURNS" || isDiskQuotaError(e)) {
945
949
  // forge#2241: when the runner attached a session-limit reset time
946
950
  // (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
947
951
  // for a genuine session-limit CLI_BACKEND_FAILED — never
@@ -1051,6 +1055,8 @@ export async function runIssue(opts) {
1051
1055
  usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
1052
1056
  // forge#3589: model + turn count per phase (null when the backend does not report them).
1053
1057
  model: outcome.model ?? null, turns: outcome.turns ?? null,
1058
+ // forge#4257: the execution backend that ran the phase (null when unreported).
1059
+ backend: outcome.backend ?? null,
1054
1060
  ...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
1055
1061
  });
1056
1062
  state = deriveState(readLog(dir, issue));
@@ -1278,6 +1284,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1278
1284
  event: "PHASE_ATTEMPT_USAGE", phase: phase.id, attempt,
1279
1285
  usage: usage ?? null, duration_ms: Math.round(performance.now() - t0),
1280
1286
  cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
1287
+ backend: extra?.backend ?? null,
1281
1288
  };
1282
1289
  appendEvent(dir, issue, rec);
1283
1290
  try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
@@ -1344,7 +1351,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1344
1351
  attempt--;
1345
1352
  continue;
1346
1353
  }
1347
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
1354
+ if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_BACKEND_UNAVAILABLE") throw e;
1348
1355
  // forge#2377: no `usage` field here — the runner threw, so no result
1349
1356
  // (and therefore no usage data) was ever produced for this attempt.
1350
1357
  // Do not fabricate a value; omitting the field (rather than a stale
@@ -1363,10 +1370,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
1363
1370
  // forge#3589: model/turns recorded alongside usage (additive; null when absent).
1364
1371
  const lastModel = result?.cliModel ?? result?.model ?? null;
1365
1372
  const lastTurns = result?.turns ?? null;
1373
+ const lastBackend = typeof result?.backend === "string" ? result.backend : null; // forge#4257
1366
1374
  addUsage(lastUsage);
1367
- await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
1375
+ await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns, backend: lastBackend });
1368
1376
  const outcome = await phase.detectOutcome(state, io, result);
1369
- if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns };
1377
+ if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns, backend: lastBackend };
1370
1378
  appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
1371
1379
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
1372
1380
  // state-derived fixed point — re-running the phase's runner is
package/bin/forgedock.mjs CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  writeInstallReceipt,
35
35
  persistHome,
36
36
  PIPELINE_SCRIPTS,
37
+ resolveClaudeConfigDir,
38
+ setupAfterRead,
37
39
  } from "./journey.mjs";
38
40
  import {
39
41
  deriveWorkflowLabels,
@@ -176,7 +178,14 @@ const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
176
178
  // existing invocations, but it is now a no-op — global is the only supported
177
179
  // install location.
178
180
 
179
- const GLOBAL_CLAUDE_DIR = join(HOME, ".claude");
181
+ //
182
+ // The directory itself honours CLAUDE_CONFIG_DIR (forge#4173): Claude Code
183
+ // reads commands/settings from there when it is set, so install, doctor,
184
+ // status and uninstall all resolve it through the one shared helper the
185
+ // journey installer uses — never a hard-coded ~/.claude.
186
+ const GLOBAL_CLAUDE_DIR = resolveClaudeConfigDir(process.env, HOME);
187
+ /** Display form of GLOBAL_CLAUDE_DIR for user-facing messages. */
188
+ const CLAUDE_DIR_LABEL = GLOBAL_CLAUDE_DIR === join(HOME, ".claude") ? "~/.claude" : GLOBAL_CLAUDE_DIR;
180
189
 
181
190
  /**
182
191
  * Resolve the install target directories for the current invocation.
@@ -242,7 +251,7 @@ const restArgs = cmdIdx === -1 ? [] : rawArgs.slice(cmdIdx + 1).filter((a) => a
242
251
  * Path to the user-level Claude Code settings file.
243
252
  * This is where the SessionStart hook entry is written/removed.
244
253
  */
245
- const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
254
+ const CLAUDE_SETTINGS_PATH = join(GLOBAL_CLAUDE_DIR, "settings.json");
246
255
 
247
256
  /**
248
257
  * Check whether a hook command string refers to the ForgeDock SessionStart hook.
@@ -1004,6 +1013,11 @@ async function statusScreen(c) {
1004
1013
  * otherwise falls back to the Anthropic API when ANTHROPIC_API_KEY is set,
1005
1014
  * otherwise skips enrichment.
1006
1015
  *
1016
+ * Repository setup (forge#4173): after detection, interactive runs with no
1017
+ * GitHub remote, no commits, or no `staging` branch are offered repo
1018
+ * creation, an initial push of `main`, and `staging` creation — see
1019
+ * journey.mjs setupRepo(). Non-TTY runs are unchanged.
1020
+ *
1007
1021
  * Env:
1008
1022
  * FORGEDOCK_INIT_BACKEND Override ("cli"|"api"|"none"|"auto"; default
1009
1023
  * "auto") — pins the enrichment backend instead of
@@ -1042,7 +1056,7 @@ async function initFlow(c) {
1042
1056
  // Staging semantics preserved (#1148): detection → minimal template with
1043
1057
  // only the three required sections. No review screen — this is the
1044
1058
  // power-user escape hatch for a ~20-line forge.yaml.
1045
- const { draft, description } = await read(c);
1059
+ const { draft, description } = await setupAfterRead(c, await read(c));
1046
1060
  // Hard-fail on unresolved identity placeholders when overwriting an
1047
1061
  // existing config (forge#1850 spec item 4) — never destroy a working
1048
1062
  // forge.yaml in favor of one that can't address a repo.
@@ -1071,7 +1085,7 @@ async function initFlow(c) {
1071
1085
 
1072
1086
  if (flags.includes("--manual")) {
1073
1087
  // Manual escape hatch: plain prompts, detection values as defaults.
1074
- const { draft, description } = await read(c);
1088
+ const { draft, description } = await setupAfterRead(c, await read(c));
1075
1089
  const v = {
1076
1090
  owner: await input("GitHub owner", draft.project.owner.value),
1077
1091
  repo: await input("Repository", draft.project.repo.value),
@@ -1097,7 +1111,7 @@ async function initFlow(c) {
1097
1111
  await maybeOfferDemo(c);
1098
1112
  return 0;
1099
1113
  }
1100
- const { draft, description } = await read(c);
1114
+ const { draft, description } = await setupAfterRead(c, await read(c));
1101
1115
  const reviewed = await review(c, draft, description);
1102
1116
  celebrate(c, reviewed);
1103
1117
  if (!reviewed.aborted) await maybeOfferDemo(c);
@@ -1110,7 +1124,7 @@ async function uninstall() {
1110
1124
  console.log("");
1111
1125
 
1112
1126
  const { targetDir, scriptsTargetDir, manifestPath } = detectInstallPaths();
1113
- console.log(` Mode: global (~/.claude)`);
1127
+ console.log(` Mode: global (${CLAUDE_DIR_LABEL})`);
1114
1128
  console.log(` ${dim(getLogoTagline("uninstall"))}`);
1115
1129
  console.log("");
1116
1130
 
@@ -1244,19 +1258,19 @@ async function uninstall() {
1244
1258
 
1245
1259
  // Remove the SessionStart hook from ~/.claude/settings.json via the
1246
1260
  // hardened settings-hook module (never writes through malformed JSON).
1247
- const settingsJsonPath = join(HOME, ".claude", "settings.json");
1261
+ const settingsJsonPath = CLAUDE_SETTINGS_PATH;
1248
1262
  const { status: hookRemoveResult } = removeSessionStartHook(settingsJsonPath);
1249
1263
  if (hookRemoveResult === "removed") {
1250
1264
  console.log(
1251
- ` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
1265
+ ` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`,
1252
1266
  );
1253
1267
  } else if (hookRemoveResult === "absent") {
1254
1268
  console.log(
1255
- ` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
1269
+ ` ✔ SessionStart hook already absent from ${CLAUDE_DIR_LABEL}/settings.json`,
1256
1270
  );
1257
1271
  } else {
1258
1272
  console.log(
1259
- ` ${YELLOW}⚠${RESET} Could not update ~/.claude/settings.json — remove the hook manually.`,
1273
+ ` ${YELLOW}⚠${RESET} Could not update ${CLAUDE_DIR_LABEL}/settings.json — remove the hook manually.`,
1260
1274
  );
1261
1275
  }
1262
1276
 
@@ -1264,19 +1278,19 @@ async function uninstall() {
1264
1278
  // engine adapter, and SubagentStop annotation verifier.
1265
1279
  const { status: preToolUseRemoveResult } = removePreToolUseHook(settingsJsonPath);
1266
1280
  if (preToolUseRemoveResult === "removed") {
1267
- console.log(` ${GREEN}✔${RESET} Removed PreToolUse enforcement hook from ${CYAN}~/.claude/settings.json${RESET}`);
1281
+ console.log(` ${GREEN}✔${RESET} Removed PreToolUse enforcement hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`);
1268
1282
  } else if (preToolUseRemoveResult !== "absent") {
1269
- console.log(` ${YELLOW}⚠${RESET} Could not remove PreToolUse hook — check ${CYAN}~/.claude/settings.json${RESET} manually.`);
1283
+ console.log(` ${YELLOW}⚠${RESET} Could not remove PreToolUse hook — check ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET} manually.`);
1270
1284
  }
1271
1285
  const { status: subagentStopRemoveResult } = removeSubagentStopHook(settingsJsonPath);
1272
1286
  if (subagentStopRemoveResult === "removed") {
1273
- console.log(` ${GREEN}✔${RESET} Removed SubagentStop hook from ${CYAN}~/.claude/settings.json${RESET}`);
1287
+ console.log(` ${GREEN}✔${RESET} Removed SubagentStop hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`);
1274
1288
  }
1275
1289
  const { status: subagentStopEnforceRemoveResult } = removeSubagentStopEnforceHook(settingsJsonPath);
1276
1290
  if (subagentStopEnforceRemoveResult === "removed") {
1277
- console.log(` ${GREEN}✔${RESET} Removed SubagentStop enforcement hook from ${CYAN}~/.claude/settings.json${RESET}`);
1291
+ console.log(` ${GREEN}✔${RESET} Removed SubagentStop enforcement hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`);
1278
1292
  } else if (subagentStopEnforceRemoveResult !== "absent") {
1279
- console.log(` ${YELLOW}⚠${RESET} Could not remove SubagentStop enforcement hook — check ${CYAN}~/.claude/settings.json${RESET} manually.`);
1293
+ console.log(` ${YELLOW}⚠${RESET} Could not remove SubagentStop enforcement hook — check ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET} manually.`);
1280
1294
  }
1281
1295
  console.log("");
1282
1296
 
@@ -2111,6 +2125,44 @@ function resolveLabelsRepo(subArgs) {
2111
2125
  return null;
2112
2126
  }
2113
2127
 
2128
+ /** Fixed colors for well-known domain labels (forge#4174); anything else gets DOMAIN_LABEL_DEFAULT_COLOR. */
2129
+ const DOMAIN_LABEL_COLORS = {
2130
+ frontend: "1D76DB",
2131
+ backend: "0E8A16",
2132
+ infra: "5319E7",
2133
+ design: "D4C5F9",
2134
+ "legal-gated": "B60205",
2135
+ };
2136
+ const DOMAIN_LABEL_DEFAULT_COLOR = "EDEDED";
2137
+
2138
+ /**
2139
+ * Parse a `--domain a,b,c` value into domain-label entries (forge#4174).
2140
+ * Names are trimmed and deduped (first occurrence wins). An empty name, a name
2141
+ * over 50 characters (GitHub's label-name limit), or a missing value is an
2142
+ * error — the caller exits before any gh call.
2143
+ *
2144
+ * @param {string|undefined} raw - the value after --domain
2145
+ * @returns {{ labels?: Array<{name: string, color: string, description: string}>, error?: string }}
2146
+ */
2147
+ function buildDomainLabels(raw) {
2148
+ if (raw === undefined || raw.startsWith("--")) return { error: "--domain needs a comma-separated list, e.g. --domain frontend,backend" };
2149
+ const seen = new Set();
2150
+ const labels = [];
2151
+ for (const part of raw.split(",")) {
2152
+ const name = part.trim();
2153
+ if (!name) return { error: `--domain contains an empty label name: "${raw}"` };
2154
+ if (name.length > 50) return { error: `--domain label name exceeds 50 characters: "${name}"` };
2155
+ if (seen.has(name)) continue;
2156
+ seen.add(name);
2157
+ labels.push({
2158
+ name,
2159
+ color: DOMAIN_LABEL_COLORS[name] ?? DOMAIN_LABEL_DEFAULT_COLOR,
2160
+ description: `Domain: ${name}. Managed by ForgeDock.`,
2161
+ });
2162
+ }
2163
+ return { labels };
2164
+ }
2165
+
2114
2166
  /**
2115
2167
  * Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
2116
2168
  *
@@ -2122,10 +2174,15 @@ function resolveLabelsRepo(subArgs) {
2122
2174
  * --force makes the call idempotent: it creates the label if absent, or updates
2123
2175
  * its color and description if it already exists. Safe to re-run at any time.
2124
2176
  *
2177
+ * Optional per-repo domain labels (forge#4174) are appended after the
2178
+ * manifest — see buildDomainLabels(). They are never part of bin/labels.json,
2179
+ * so doctor's required-label check ignores them.
2180
+ *
2125
2181
  * @param {string} repo - "owner/repo" string
2182
+ * @param {Array<{name: string, color: string, description: string}>} [domainLabels]
2126
2183
  * @returns {{ created: number, failed: string[] }}
2127
2184
  */
2128
- async function labelsSetup(repo) {
2185
+ async function labelsSetup(repo, domainLabels = []) {
2129
2186
  const manifestPath = join(__dirname, "labels.json");
2130
2187
 
2131
2188
  if (!existsSync(manifestPath)) {
@@ -2141,6 +2198,8 @@ async function labelsSetup(repo) {
2141
2198
  console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
2142
2199
  process.exit(1);
2143
2200
  }
2201
+ const manifestNames = new Set(labels.map((l) => l.name));
2202
+ labels = labels.concat(domainLabels.filter((l) => !manifestNames.has(l.name)));
2144
2203
 
2145
2204
  console.log("");
2146
2205
  console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
@@ -2210,7 +2269,7 @@ async function labelsSetup(repo) {
2210
2269
  * 2. gh CLI installed and authenticated
2211
2270
  * 3. git configured (user.name + user.email)
2212
2271
  * 4. forge.yaml exists and has required keys
2213
- * 5. SessionStart hook registered in ~/.claude/settings.json
2272
+ * 5. SessionStart hook registered in ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR)
2214
2273
  * 6. CLAUDE.md legacy managed block (cwd) — informational only; the
2215
2274
  * journey install never injects one, session context comes from the
2216
2275
  * SessionStart hook
@@ -2247,7 +2306,7 @@ async function doctor(fix = false) {
2247
2306
  console.log(
2248
2307
  isGitCloneInstall
2249
2308
  ? ` Mode: git clone at ${CYAN}${FORGE_HOME}${RESET}`
2250
- : ` Mode: global (~/.claude)`,
2309
+ : ` Mode: global (${CLAUDE_DIR_LABEL})`,
2251
2310
  );
2252
2311
  console.log(` Source: ${formatSourceIdentity(sourceBranch)}`);
2253
2312
  console.log("");
@@ -2301,7 +2360,7 @@ async function doctor(fix = false) {
2301
2360
  console.log(` ${CYAN}↻${RESET} ${BOLD}${label}${RESET}${suffix}`);
2302
2361
  }
2303
2362
 
2304
- const settingsPath = join(HOME, ".claude", "settings.json");
2363
+ const settingsPath = CLAUDE_SETTINGS_PATH;
2305
2364
 
2306
2365
  /**
2307
2366
  * Detect whether the registered SessionStart hook entry (if any) points at
@@ -2671,17 +2730,17 @@ async function doctor(fix = false) {
2671
2730
  }
2672
2731
 
2673
2732
  if (hookReg.error) {
2674
- fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${hookReg.error}. Run: npx forgedock install`);
2733
+ fail("SessionStart hook", `Cannot read ${CLAUDE_DIR_LABEL}/settings.json: ${hookReg.error}. Run: npx forgedock install`);
2675
2734
  } else if (hookReg.ok) {
2676
2735
  if (fix && hookRegWasBroken) {
2677
- fixed("SessionStart hook", "registered in ~/.claude/settings.json (repaired)");
2736
+ fixed("SessionStart hook", `registered in ${CLAUDE_DIR_LABEL}/settings.json (repaired)`);
2678
2737
  } else {
2679
- pass("SessionStart hook", "registered in ~/.claude/settings.json");
2738
+ pass("SessionStart hook", `registered in ${CLAUDE_DIR_LABEL}/settings.json`);
2680
2739
  }
2681
2740
  } else {
2682
2741
  fail(
2683
2742
  "SessionStart hook",
2684
- "Run: npx forgedock doctor --fix (or: npx forgedock install) (writes hook entry to ~/.claude/settings.json)",
2743
+ `Run: npx forgedock doctor --fix (or: npx forgedock install) (writes hook entry to ${CLAUDE_DIR_LABEL}/settings.json)`,
2685
2744
  );
2686
2745
  }
2687
2746
  }
@@ -3007,7 +3066,7 @@ async function doctor(fix = false) {
3007
3066
  // server presence is orthogonal to the core ForgeDock install — but missing
3008
3067
  // it causes /qa-sweep to silently fail mid-sweep.
3009
3068
  {
3010
- const mcpServersPath = join(HOME, ".claude", "mcp_servers.json");
3069
+ const mcpServersPath = join(GLOBAL_CLAUDE_DIR, "mcp_servers.json");
3011
3070
  let playwrightFound = false;
3012
3071
  let fileReadable = false;
3013
3072
  let hasMcpServers = false;
@@ -3040,12 +3099,12 @@ async function doctor(fix = false) {
3040
3099
  if (!fileReadable) {
3041
3100
  warn(
3042
3101
  "Playwright MCP",
3043
- "No MCP servers file found (~/.claude/mcp_servers.json). Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest",
3102
+ `No MCP servers file found (${CLAUDE_DIR_LABEL}/mcp_servers.json). Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest`,
3044
3103
  );
3045
3104
  } else if (!hasMcpServers) {
3046
3105
  warn(
3047
3106
  "Playwright MCP",
3048
- "MCP servers file found (~/.claude/mcp_servers.json) but it has no 'mcpServers' key. Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest",
3107
+ `MCP servers file found (${CLAUDE_DIR_LABEL}/mcp_servers.json) but it has no 'mcpServers' key. Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest`,
3049
3108
  );
3050
3109
  } else if (playwrightFound) {
3051
3110
  pass("Playwright MCP", "registered in Claude Code MCP servers");
@@ -3172,6 +3231,26 @@ async function doctor(fix = false) {
3172
3231
  );
3173
3232
  }
3174
3233
 
3234
+ // ── Check 13: Resource hygiene (#4183) ──────────────────────────────────────
3235
+ // Owned leftovers (stale lane ledgers, forgedock-labelled docker, lean-*/agent-* worktrees, lane branches,
3236
+ // ~/.forge/tmp scratch) with counts and sizes; unowned stale ones are only reported. --fix runs the reaper with the
3237
+ // forge.yaml hygiene TTLs. Never a failure: a leftover slows the host, it does not break the install.
3238
+ {
3239
+ try {
3240
+ const gc = await import("./gc.mjs");
3241
+ const probes = gc.makeGcProbes({ cwd: process.cwd() });
3242
+ const h = gc.readHygiene(process.cwd());
3243
+ const plan = await gc.planGc({ probes, ttlMs: gc.hygieneTtls(h), deleteMergedBranches: h.delete_merged_branches });
3244
+ const applied = fix ? await gc.applyGc(plan, probes) : null;
3245
+ if (applied && applied.reclaimed.length > 0) fixesApplied++;
3246
+ const r = gc.hygieneCheck(plan, { applied });
3247
+ if (r.status === "pass") pass("Resource hygiene", r.detail);
3248
+ else warn("Resource hygiene", r.detail);
3249
+ } catch (e) {
3250
+ warn("Resource hygiene", `could not inspect ForgeDock leftovers: ${String(e?.message ?? e).slice(0, 200)}`);
3251
+ }
3252
+ }
3253
+
3175
3254
  // ── Summary ────────────────────────────────────────────────────────────────
3176
3255
  console.log("");
3177
3256
  if (fix && fixesApplied > 0) {
@@ -3207,20 +3286,24 @@ function help() {
3207
3286
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
3208
3287
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
3209
3288
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3289
+ ["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
3210
3290
  ["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3211
3291
  ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
3212
- ["npx forgedock orchestrate --resume <id> | --set-parallel <id> K", "Continue a batch after the dispatcher died (live lanes re-adopted), or change its concurrency live"],
3292
+ ["npx forgedock orchestrate --resume <id> | --set-parallel <id> K", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), or change its concurrency live"],
3213
3293
  ["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
3214
- ["npx forgedock gc", "Prune stale per-run scratch dirs under ~/.forge/tmp/runs (older than 6h, owner dead)"],
3294
+ ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3295
+ ["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
3296
+ ["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and batches"],
3297
+ ["npx forgedock gc [--apply] [--older-than 6h] [--docker] [--worktrees] [--branches] [--tmp] [--processes] [--sandboxes]", "Report (dry-run default) and reclaim ForgeDock-owned leftovers: stale lane ledgers, forgedock-labelled containers/volumes, lean-*/agent-* worktrees, abandoned lane branches, ~/.forge/tmp scratch, orphaned lane processes, orphaned lane sandboxes; unowned resources and unpushed work are only reported"],
3215
3298
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3216
3299
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
3217
- ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
3300
+ ["npx forgedock labels [setup] [--repo owner/repo] [--domain a,b,c]", "Bootstrap ForgeDock-managed labels on a GitHub repo, plus optional per-repo domain labels (idempotent)"],
3218
3301
  ["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
3219
3302
  ["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
3220
3303
  ["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
3221
3304
  ["npx forgedock stats [--since <ver|date>] [--repo owner/repo] [--json] [--refresh]", "Version-over-version cost/time/flow/review/gate report from GitHub telemetry (no model)"],
3222
3305
  ["npx forgedock doctor", "Check installation health"],
3223
- ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
3306
+ ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block) and reclaim ForgeDock-owned leftovers (forge.yaml hygiene TTLs)"],
3224
3307
  ["npx forgedock update", "Pull latest & reinstall"],
3225
3308
  ["npx forgedock uninstall", "Remove commands"],
3226
3309
  ["npx forgedock opencode install", "Install token-efficient native OpenCode commands"],
@@ -3785,10 +3868,10 @@ async function watch() {
3785
3868
  // The journey-routed commands render their own branded marks (hero/compact);
3786
3869
  // splash() would double-brand them. Engine-surface commands, help, and
3787
3870
  // unknown-command output keep the logo.
3788
- const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3871
+ const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3789
3872
  const KNOWN_COMMANDS = new Set([
3790
3873
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3791
- "run", "backend-check", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
3874
+ "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "help", "--help", "-h",
3792
3875
  "version", "--version", "-v",
3793
3876
  ]);
3794
3877
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3945,6 +4028,18 @@ switch (command) {
3945
4028
  case "run":
3946
4029
  await run();
3947
4030
  break;
4031
+ case "bootstrap": {
4032
+ // #4177: headless /bootstrap; --plan runs scripts/bootstrap-create.mjs directly (no LLM), a re-run resumes.
4033
+ const { runBootstrap } = await import("./bootstrap.mjs");
4034
+ try {
4035
+ const { runCommand } = await import("./runner.mjs");
4036
+ exitCode = await runBootstrap(restArgs, { runCommand, commandsDir: COMMANDS_DIR });
4037
+ } catch (err) {
4038
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4039
+ exitCode = 1;
4040
+ }
4041
+ break;
4042
+ }
3948
4043
  case "backend-check": {
3949
4044
  // Exit codes: 0 = ready, 1 = no usable backend, 2 = bad usage (unknown option).
3950
4045
  // Only --quiet is accepted; anything else is a usage error so callers can tell a
@@ -3987,10 +4082,9 @@ switch (command) {
3987
4082
  break;
3988
4083
  }
3989
4084
  case "gc": {
3990
- // Best-effort prune of core-run scratch dirs (older than 6h, owner pid dead). Never touches dirs we did not create.
3991
- const { makeRunTmp } = await import("./lean/effects.mjs");
3992
- const removed = await makeRunTmp().prune();
3993
- process.stdout.write(`gc: removed ${removed.length} stale run tmp dir(s)\n`);
4085
+ // Reaper (#4180): dry-run report by default; --apply reclaims only ForgeDock-owned resources (ledger, label, lane naming, own tmp).
4086
+ const { runGc } = await import("./gc.mjs");
4087
+ exitCode = await runGc(restArgs);
3994
4088
  break;
3995
4089
  }
3996
4090
  case "orchestrate": {
@@ -4004,6 +4098,37 @@ switch (command) {
4004
4098
  }
4005
4099
  break;
4006
4100
  }
4101
+ case "limits": {
4102
+ const { limitsFromCli } = await import("./engine-cli.mjs");
4103
+ try {
4104
+ await limitsFromCli(restArgs);
4105
+ } catch (err) {
4106
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4107
+ exitCode = 1;
4108
+ }
4109
+ break;
4110
+ }
4111
+ case "schedd": {
4112
+ // #4192: the host scheduler daemon; runs until idle or SIGTERM. Lanes are detached and survive it.
4113
+ const { scheddFromCli } = await import("./engine-cli.mjs");
4114
+ try {
4115
+ await scheddFromCli(restArgs);
4116
+ } catch (err) {
4117
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4118
+ exitCode = 1;
4119
+ }
4120
+ break;
4121
+ }
4122
+ case "sched": {
4123
+ const { schedFromCli } = await import("./engine-cli.mjs");
4124
+ try {
4125
+ await schedFromCli(restArgs);
4126
+ } catch (err) {
4127
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4128
+ exitCode = 1;
4129
+ }
4130
+ break;
4131
+ }
4007
4132
  case "triage": {
4008
4133
  const { triageFromCli } = await import("./engine-cli.mjs");
4009
4134
  try {
@@ -4048,19 +4173,24 @@ switch (command) {
4048
4173
  const subcommand = positional[1];
4049
4174
  if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
4050
4175
  const repo = resolveLabelsRepo(restArgs);
4051
- if (!repo) {
4176
+ const domainIdx = restArgs.indexOf("--domain");
4177
+ const domain = domainIdx === -1 ? { labels: [] } : buildDomainLabels(restArgs[domainIdx + 1]);
4178
+ if (domain.error) {
4179
+ console.log(`${RED}${domain.error}${RESET}`);
4180
+ exitCode = 1;
4181
+ } else if (!repo) {
4052
4182
  console.log(
4053
4183
  `${RED}No repository specified.${RESET}\n` +
4054
4184
  ` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
4055
4185
  );
4056
4186
  exitCode = 1;
4057
4187
  } else {
4058
- await labelsSetup(repo);
4188
+ await labelsSetup(repo, domain.labels);
4059
4189
  }
4060
4190
  } else {
4061
4191
  console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
4062
4192
  console.log(
4063
- `Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
4193
+ `Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo] [--domain a,b,c]${RESET}`,
4064
4194
  );
4065
4195
  exitCode = 1;
4066
4196
  }