forgedock 1.1.7 → 1.1.9

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 (97) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine/invariants.mjs +361 -0
  4. package/bin/engine-cli.mjs +137 -11
  5. package/bin/env-detect.mjs +249 -0
  6. package/bin/forge-utils.mjs +364 -4
  7. package/bin/forgedock.mjs +1064 -135
  8. package/bin/hooks/pre-tool-use.mjs +182 -0
  9. package/bin/hooks/session-start.mjs +68 -3
  10. package/bin/init-detect.mjs +101 -12
  11. package/bin/journey.mjs +917 -16
  12. package/bin/labels.json +10 -0
  13. package/bin/recall.mjs +557 -0
  14. package/bin/registry.mjs +91 -3
  15. package/bin/runner.mjs +38 -2
  16. package/bin/tui.mjs +49 -3
  17. package/commands/adopt.md +1 -1
  18. package/commands/analytics.md +8 -8
  19. package/commands/audit.md +1 -1
  20. package/commands/autopilot.md +958 -77
  21. package/commands/ci-audit.md +1 -1
  22. package/commands/cleanup.md +139 -55
  23. package/commands/compat-audit.md +1 -1
  24. package/commands/deploy-info.md +1 -1
  25. package/commands/deploy-pr.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/explain.md +1 -1
  28. package/commands/fix-ci.md +1 -1
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/incident-response.md +3 -3
  31. package/commands/issue.md +1 -1
  32. package/commands/optimize.md +586 -1
  33. package/commands/orchestrate/config.md +13 -3
  34. package/commands/orchestrate/phase-1-resolve.md +36 -26
  35. package/commands/orchestrate/phase-2-triage.md +1 -1
  36. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  37. package/commands/orchestrate/phase-3-dependency.md +587 -48
  38. package/commands/orchestrate/phase-4-execution.md +1031 -57
  39. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  40. package/commands/orchestrate/phase-6-report.md +248 -11
  41. package/commands/orchestrate/safety.md +2 -2
  42. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  43. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  44. package/commands/qa-sweep.md +1 -1
  45. package/commands/quality-gate.md +418 -1
  46. package/commands/recover-orphans.md +5 -5
  47. package/commands/replay.md +1 -1
  48. package/commands/review-pr-agents/api.md +2 -2
  49. package/commands/review-pr-agents/auth.md +2 -2
  50. package/commands/review-pr-agents/billing.md +2 -2
  51. package/commands/review-pr-agents/concurrency.md +2 -2
  52. package/commands/review-pr-agents/database.md +2 -2
  53. package/commands/review-pr-agents/frontend.md +2 -2
  54. package/commands/review-pr-agents/infra.md +2 -2
  55. package/commands/review-pr-agents/protocols.md +1 -1
  56. package/commands/review-pr-agents/scraper.md +2 -2
  57. package/commands/review-pr-agents/security.md +2 -2
  58. package/commands/review-pr-staging.md +41 -3
  59. package/commands/review-pr.md +462 -19
  60. package/commands/rollback.md +1 -1
  61. package/commands/scope.md +1 -1
  62. package/commands/security-audit.md +1 -1
  63. package/commands/spec-doctor.md +429 -0
  64. package/commands/test-gate.md +1 -1
  65. package/commands/validate.md +53 -1
  66. package/commands/work-on/build/architect.md +78 -1
  67. package/commands/work-on/build/context.md +360 -22
  68. package/commands/work-on/build/implement.md +3 -1
  69. package/commands/work-on/build/validate.md +29 -1
  70. package/commands/work-on/build.md +75 -6
  71. package/commands/work-on/close.md +624 -10
  72. package/commands/work-on/decompose.md +1 -1
  73. package/commands/work-on/investigate.md +235 -5
  74. package/commands/work-on/remediate.md +331 -0
  75. package/commands/work-on/review.md +1 -1
  76. package/commands/work-on-monolithic.md +1 -1
  77. package/commands/work-on.md +205 -66
  78. package/docs/CI.md +2 -2
  79. package/docs/CODEX.md +1 -1
  80. package/docs/CONFIG.md +186 -0
  81. package/docs/FORGE-PROTOCOL.md +101 -0
  82. package/docs/site/command-reference.md +31 -1
  83. package/docs/site/getting-started.md +9 -34
  84. package/docs/site/index.md +1 -1
  85. package/docs/spec/forge-protocol-v1.md +97 -5
  86. package/docs/spec/label-state-machine.md +6 -1
  87. package/package.json +3 -2
  88. package/packages/protocol/src/cli.js +374 -84
  89. package/packages/protocol/src/types.js +75 -1
  90. package/scripts/build-knowledge-index.mjs +1761 -0
  91. package/scripts/calibration.mjs +996 -0
  92. package/scripts/check-command-docs-drift.sh +4 -2
  93. package/scripts/danger-zones.mjs +789 -0
  94. package/scripts/doctor-pipeline-state.sh +88 -2
  95. package/scripts/select-fix-targets.sh +234 -0
  96. package/scripts/transition-label.sh +245 -6
  97. package/scripts/refresh-bot-token.sh +0 -97
@@ -8,6 +8,41 @@ const _require = createRequire(import.meta.url);
8
8
  /** Absolute path to the ForgeDock installation root (parent of bin/). */
9
9
  const FORGE_HOME = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
10
 
11
+ // Lazy-loaded invariants module and YAML declarations (loaded once on first use).
12
+ // Using a lazy import avoids top-level await and keeps the module synchronous
13
+ // for the simple path — invariant loading only happens when a branch checkout
14
+ // command is detected.
15
+ // Sentinel states for _invariants:
16
+ // null — import not yet attempted
17
+ // false — a prior import attempt failed (do NOT retry; stay fail-open)
18
+ // object — successfully loaded module namespace (cached)
19
+ let _invariants = null;
20
+ let _invariantDecls = null;
21
+
22
+ async function getInvariants() {
23
+ // A prior import attempt already failed — stay fail-open, do not retry.
24
+ if (_invariants === false) return { module: null, decls: [] };
25
+ // Successful load cached from a previous call.
26
+ if (_invariants !== null) return { module: _invariants, decls: _invariantDecls };
27
+ // Not yet attempted — try importing now.
28
+ try {
29
+ _invariants = await import(
30
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "invariants.mjs")).href
31
+ );
32
+ _invariantDecls = _invariants.loadInvariants(
33
+ join(FORGE_HOME, "forge-invariants.yaml")
34
+ );
35
+ } catch {
36
+ // invariants.mjs unavailable (fresh install, partial update) — fail-open.
37
+ // Mark the failure with a distinct sentinel so we don't re-attempt the
38
+ // import on every subsequent call.
39
+ _invariants = false;
40
+ _invariantDecls = [];
41
+ return { module: null, decls: [] };
42
+ }
43
+ return { module: _invariants, decls: _invariantDecls };
44
+ }
45
+
11
46
  /**
12
47
  * bin/hooks/pre-tool-use.mjs — ForgeDock PreToolUse hook.
13
48
  *
@@ -40,6 +75,23 @@ const FORGE_HOME = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", ".
40
75
  * against the workflow label state machine.
41
76
  * Blocks invalid transitions (e.g. jumping from investigating to merged).
42
77
  *
78
+ * 3. Declared precondition checks (from forge-invariants.yaml)
79
+ * Intercepts git checkout / git worktree add / git switch commands and
80
+ * evaluates pretooluse-scope invariants declared in forge-invariants.yaml.
81
+ * Currently: branch_must_exist_on_remote — blocks checkouts of branches
82
+ * that don't exist on origin (catches hallucinated branch names before
83
+ * they cause a mid-build git failure).
84
+ *
85
+ * 4. Gist visibility guard (issue #1729)
86
+ * Intercepts `gh gist create --public` and hard-blocks it.
87
+ * Memory-bearing pipeline gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX,
88
+ * FORGE:PRIOR_GIST, FORGE:MEMORY_INDEX) MUST be secret — publishing them to
89
+ * a world-readable Gist exposes root causes, file paths, and security findings.
90
+ * Override: set FORGE_ALLOW_PUBLIC_GIST=1 in the shell environment before
91
+ * starting Claude Code (operator-set only — agents cannot bypass this by
92
+ * setting env vars via Bash tool calls, as this hook reads process.env which
93
+ * is set at process start, not at tool-call time).
94
+ *
43
95
  * Both rules only apply inside a ForgeDock-managed directory (a directory
44
96
  * with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
45
97
  * (issue #1591). The hook installs into the user's global
@@ -139,9 +191,87 @@ async function main() {
139
191
  return;
140
192
  }
141
193
 
194
+ // --- Rule 3: Declared precondition checks (forge-invariants.yaml) ---
195
+ const preconditionViolation = await checkDeclaredPreconditions(command);
196
+ if (preconditionViolation) {
197
+ process.stderr.write(preconditionViolation);
198
+ process.exit(2);
199
+ return;
200
+ }
201
+
202
+ // --- Rule 4: Gist visibility guard ---
203
+ const gistViolation = checkGistVisibility(command);
204
+ if (gistViolation) {
205
+ process.stderr.write(gistViolation);
206
+ process.exit(2);
207
+ return;
208
+ }
209
+
142
210
  process.exit(0);
143
211
  }
144
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Rule 3: Declared precondition checks (forge-invariants.yaml)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Check declared pretooluse-scope invariants from forge-invariants.yaml.
219
+ *
220
+ * Currently evaluates the branch_must_exist_on_remote precondition for:
221
+ * - git checkout <branch>
222
+ * - git worktree add <path> [-b <branch>] <base>
223
+ * - git switch <branch>
224
+ *
225
+ * Wrapped in a try/catch in the caller (main()) — fail-open on any error.
226
+ *
227
+ * @param {string} command
228
+ * @returns {Promise<string|null>} Error message or null if allowed.
229
+ */
230
+ async function checkDeclaredPreconditions(command) {
231
+ // Only intercept git commands that reference a branch.
232
+ const isCheckout = /git\s+(?:checkout|switch)/.test(command) &&
233
+ !/git\s+checkout\s+-[bf]/.test(command); // -b creates new branch (no check needed)
234
+ const isWorktreeAdd = /git\s+worktree\s+add/.test(command);
235
+
236
+ if (!isCheckout && !isWorktreeAdd) return null;
237
+
238
+ // Extract branch name from command.
239
+ let branch = null;
240
+ if (isCheckout) {
241
+ // git checkout <branch> or git switch <branch>
242
+ // Avoid matching flags: skip tokens starting with -
243
+ const m = command.match(/git\s+(?:checkout|switch)\s+(?:--\s+)?([^\s-][^\s]*)/);
244
+ if (m) branch = m[1];
245
+ } else if (isWorktreeAdd) {
246
+ // git worktree add <path> <base> — base is typically origin/<branch> or just <branch>
247
+ // Also handle: git worktree add <path> -b <new-branch> <base>
248
+ const tokens = tokenizeCommand(command).map((t) => t.value);
249
+ // Find -b flag if present (new branch — no remote check needed)
250
+ const bIdx = tokens.indexOf("-b");
251
+ if (bIdx !== -1) return null; // creating new branch — no remote existence check
252
+ // Otherwise the last non-option argument after 'add' and path is the base
253
+ const addIdx = tokens.findIndex((t) => t === "add");
254
+ if (addIdx !== -1 && addIdx + 2 < tokens.length) {
255
+ const base = tokens[addIdx + 2];
256
+ // base may be 'origin/<branch>' — strip the remote prefix
257
+ branch = base.replace(/^origin\//, "");
258
+ }
259
+ }
260
+
261
+ if (!branch || branch.startsWith("-") || branch === "HEAD") return null;
262
+
263
+ // Load invariants (cached after first load).
264
+ const { module, decls } = await getInvariants();
265
+ if (!module || !decls?.length) return null;
266
+
267
+ // Evaluate the branch_must_exist precondition.
268
+ const result = await module.checkPrecondition(decls, "branch_must_exist_on_remote", { branch });
269
+ if (!result.ok) {
270
+ return module.formatViolation(result) + "\n";
271
+ }
272
+ return null;
273
+ }
274
+
145
275
  // ---------------------------------------------------------------------------
146
276
  // Project-scope guard (issue #1591)
147
277
  // ---------------------------------------------------------------------------
@@ -326,6 +456,58 @@ function checkLabelTransition(command) {
326
456
  return null; // valid transition — allow
327
457
  }
328
458
 
459
+ // ---------------------------------------------------------------------------
460
+ // Rule 4: Gist visibility guard (issue #1729)
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Check whether a gh gist create command uses the --public flag.
465
+ *
466
+ * Pipeline gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX, FORGE:PRIOR_GIST,
467
+ * FORGE:MEMORY_INDEX) MUST be secret. Passing --public to gh gist create publishes
468
+ * investigation findings — root causes, file paths, security details — to a
469
+ * world-readable URL. gh gist create is secret by default; this rule blocks any
470
+ * explicit --public override so a future spec edit cannot reintroduce a public gist.
471
+ *
472
+ * Override: FORGE_ALLOW_PUBLIC_GIST=1 in the operator's shell environment.
473
+ * This env var must be set BEFORE starting Claude Code — it is read from process.env
474
+ * at hook startup, not from the tool payload, so agents cannot bypass it by setting
475
+ * the variable via a Bash tool call in the same session.
476
+ *
477
+ * @param {string} command
478
+ * @returns {string|null} Error message to show, or null if allowed.
479
+ */
480
+ function checkGistVisibility(command) {
481
+ // Only check gh gist create commands.
482
+ if (!/gh\s+gist\s+create/.test(command)) return null;
483
+
484
+ // Allow operator override via env var.
485
+ if (process.env.FORGE_ALLOW_PUBLIC_GIST === "1") return null;
486
+
487
+ // Check for --public flag (space form, equals form, and standalone --public).
488
+ // extractFlag handles --public=value; for the bare boolean flag --public we
489
+ // additionally check whether "--public" appears as a standalone token.
490
+ const tokens = tokenizeCommand(command);
491
+ const hasPublicFlag = tokens.some(({ value }) => value === "--public") ||
492
+ extractFlag(command, "--public") !== null;
493
+
494
+ if (hasPublicFlag) {
495
+ return [
496
+ `[ForgeDock] BLOCKED: gh gist create --public is a pipeline violation.`,
497
+ ``,
498
+ `Pipeline gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX, FORGE:PRIOR_GIST,`,
499
+ `FORGE:MEMORY_INDEX) MUST be created secret. Passing --public publishes`,
500
+ `investigation findings — root causes, file paths, security details — to a`,
501
+ `world-readable URL. Remove the --public flag (gh gist create is secret by default).`,
502
+ ``,
503
+ `Exception: set FORGE_ALLOW_PUBLIC_GIST=1 in your shell environment BEFORE`,
504
+ `starting Claude Code if you intentionally need a public gist.`,
505
+ ].join("\n");
506
+ }
507
+
508
+ return null;
509
+ }
510
+
329
511
  // ---------------------------------------------------------------------------
330
512
  // Git helpers
331
513
  // ---------------------------------------------------------------------------
@@ -63,6 +63,16 @@ let markNudgeSeen;
63
63
  let parseForgeYaml;
64
64
  let sanitizeContextValue;
65
65
 
66
+ // ---------------------------------------------------------------------------
67
+ // init-detect helpers — populated inside the try block to honour fail-open
68
+ // ---------------------------------------------------------------------------
69
+
70
+ // Declared at module scope so it is accessible from handler functions
71
+ // (handleUnmanaged) called from within the try block. Assigned inside the
72
+ // try/catch so that any import failure (missing file, permissions error,
73
+ // syntax error) is caught and the hook still exits 0. <!-- fix: forge#1927 -->
74
+ let resolveGitRoot;
75
+
66
76
  // ---------------------------------------------------------------------------
67
77
  // Main — wrapped in try/catch to guarantee fail-open
68
78
  // ---------------------------------------------------------------------------
@@ -98,6 +108,21 @@ try {
98
108
  )
99
109
  );
100
110
 
111
+ // Import the git-root resolver from the same installation.
112
+ // Placed inside try/catch to honour the fail-open contract: if init-detect.mjs
113
+ // cannot be loaded (broken install, missing file, permissions error) the
114
+ // catch block below ensures we still exit 0 without blocking Claude Code.
115
+ //
116
+ // Use pathToFileURL() to convert the OS-native path to a file:// URL before
117
+ // passing it to dynamic import(). On Windows, join() produces backslash paths
118
+ // that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
119
+ /** @type {import('../init-detect.mjs')} */
120
+ (
121
+ { resolveGitRoot } = await import(
122
+ pathToFileURL(join(FORGE_HOME, "bin", "init-detect.mjs")).href
123
+ )
124
+ );
125
+
101
126
  const cwd = process.cwd();
102
127
  const state = resolveState(cwd);
103
128
 
@@ -165,19 +190,32 @@ async function handleManagedActive(dir) {
165
190
  * Uses the registry to ensure the nudge fires at most once per directory.
166
191
  * After showing, records the directory so subsequent sessions are silent.
167
192
  *
193
+ * The nudge-tracking key is the *resolved git root*, not the raw `dir` — a
194
+ * user who launches Claude Code from a parent directory that merely contains
195
+ * the real git repo as its single subdirectory (e.g. `Projects/` containing
196
+ * `Projects/my-app/.git`) would otherwise have every sibling project under
197
+ * that same parent share one nudge-seen entry: the first project to trigger
198
+ * the nudge silently suppresses it for all the others. `resolveGitRoot`
199
+ * degrades gracefully (falls back to the unresolved `dir`) when the case
200
+ * doesn't apply, so this is safe to call unconditionally. <!-- fix: forge#1927 -->
201
+ *
168
202
  * @param {string} dir - Absolute path to the project directory.
169
203
  */
170
204
  async function handleUnmanaged(dir) {
171
- if (nudgeSeen(dir)) {
205
+ const trackingKey = resolveGitRoot(dir).root;
206
+
207
+ if (nudgeSeen(trackingKey)) {
172
208
  // Nudge already shown — stay silent
173
209
  return;
174
210
  }
175
211
 
176
- // Show the nudge, then record it so it won't show again
212
+ // Show the nudge, then record it so it won't show again. The displayed
213
+ // message still shows the raw `dir` the user is actually in — only the
214
+ // tracking key changes.
177
215
  console.log(buildNudgeContext(dir));
178
216
 
179
217
  // Best-effort — if this fails the nudge may show once more next session
180
- await markNudgeSeen(dir).catch(() => {});
218
+ await markNudgeSeen(trackingKey).catch(() => {});
181
219
  }
182
220
 
183
221
  // ---------------------------------------------------------------------------
@@ -223,12 +261,39 @@ function buildActiveContext(dir, forgeYaml) {
223
261
  200,
224
262
  );
225
263
 
264
+ // Default agent model — resolved from forge.yaml's optional
265
+ // `agents.default_model` field, falling back to "sonnet" (the hardcoded
266
+ // default every command spec's "Agent model policy" line already uses).
267
+ // Rendered unconditionally, like stagingBranch/featurePattern above, so
268
+ // command specs always have a concrete value to reference — not a
269
+ // conditional *Note line like milestoneNote, which is omitted when unset.
270
+ const defaultModel = sanitizeContextValue(
271
+ forgeYaml.agents?.default_model ?? "sonnet",
272
+ 100,
273
+ ) ?? "sonnet";
274
+
275
+ // Sub-agent model — resolved from forge.yaml's optional
276
+ // `agents.subagent_model` field. Governs child agents that a top-level
277
+ // command spawns internally (e.g. /orchestrate's per-issue /work-on
278
+ // agents, /review-pr's domain persona reviewers), which is a distinct
279
+ // hardcoded call-site from the top-level "Agent model policy" banner that
280
+ // `defaultModel` above already covers. Falls back to `defaultModel` (not
281
+ // directly to "sonnet") so a project that only sets `default_model` gets
282
+ // consistent behavior across both the main agent and its children.
283
+ // Rendered unconditionally, same as defaultModel above. <!-- issue #1852 -->
284
+ const subagentModel = sanitizeContextValue(
285
+ forgeYaml.agents?.subagent_model ?? defaultModel,
286
+ 100,
287
+ ) ?? defaultModel;
288
+
226
289
  return `\
227
290
  <!-- ForgeDock: managed-active -->
228
291
  **ForgeDock** is active in this directory (${safeDir}).
229
292
  ${nameNote}${repoNote}${descNote}${milestoneNote}
230
293
  - **Staging branch**: \`${stagingBranch}\`
231
294
  - **Feature branch pattern**: \`${featurePattern}\`
295
+ - **Default agent model**: \`${defaultModel}\`
296
+ - **Sub-agent model**: \`${subagentModel}\`
232
297
 
233
298
  ### Available pipeline commands
234
299
 
@@ -2,7 +2,11 @@
2
2
  /**
3
3
  * init-detect.mjs — Pure deterministic config detection for ForgeDock.
4
4
  *
5
- * Exports a single function: detectConfig(cwd)
5
+ * Exports:
6
+ * detectConfig(cwd) → Promise<ConfigDraft> (main deterministic detector)
7
+ * resolveGitRoot(cwd) → { root, resolved, why } (git-root resolution,
8
+ * reused outside this module by session-start.mjs's
9
+ * nudge-tracking path — see forge#1927)
6
10
  *
7
11
  * Returns a ConfigDraft: a structured object mirroring forge.yaml's required
8
12
  * sections (project, paths, branches) where every leaf is:
@@ -21,7 +25,8 @@
21
25
  */
22
26
 
23
27
  import { execFileSync } from "child_process";
24
- import { join } from "path";
28
+ import { join, resolve } from "path";
29
+ import { readdirSync, existsSync } from "fs";
25
30
 
26
31
  // ---------------------------------------------------------------------------
27
32
  // Field factory helpers
@@ -228,13 +233,88 @@ function detectStagingBranch(cwd, defaultBranch) {
228
233
  // git not available or no remotes
229
234
  }
230
235
 
231
- return medium(
236
+ // No 'origin/staging' found. Deriving a merge target from a failed lookup
237
+ // is not a "likely correct" inference — it's a guess, and `staging` is the
238
+ // single most destructive field to get wrong (it's the fast-lane PR merge
239
+ // target). Flag it "low" so it gets a # TODO comment and the [low] badge
240
+ // instead of silently blending in as if it were verified. <!-- Added: forge#1850 -->
241
+ return low(
232
242
  defaultBranch,
233
- "derived from default branch",
234
- `No 'origin/staging' remote branch found; staging defaults to the default branch '${defaultBranch}'`,
243
+ "default placeholder",
244
+ `No 'origin/staging' remote branch found; guessed the default branch '${defaultBranch}' — verify this is actually your staging branch`,
235
245
  );
236
246
  }
237
247
 
248
+ /**
249
+ * Resolve the effective git repository root for detection purposes.
250
+ *
251
+ * `execFileSync("git", ...)` already searches UPWARD through parent
252
+ * directories to find a repo root, so a `cwd` nested inside a repo is
253
+ * already handled correctly with no extra code here. The gap this closes is
254
+ * the other direction: `cwd` is a *parent* of the actual repo (a common
255
+ * monorepo-adjacent layout, e.g. `ScraperAPI/` containing the real repo at
256
+ * `ScraperAPI/alterlab/`) — git has no way to discover a repo by looking
257
+ * downward, so every detector below would otherwise degrade straight to
258
+ * placeholders.
259
+ *
260
+ * Never throws; falls back to the original `cwd` unchanged when neither
261
+ * case applies (no repo found at all, or the subdirectory scan is
262
+ * ambiguous — zero or multiple candidates).
263
+ *
264
+ * Exported for reuse outside `detectConfig()` — `bin/hooks/session-start.mjs`
265
+ * uses it to resolve the nudge-tracking key so that a directory which is a
266
+ * parent of the real git repo doesn't collide with sibling projects sharing
267
+ * the same parent. <!-- Added: forge#1927 -->
268
+ *
269
+ * @param {string} cwd
270
+ * @returns {{ root: string, resolved: boolean, why: string }}
271
+ */
272
+ export function resolveGitRoot(cwd) {
273
+ // cwd is already inside a repo (possibly nested) — `--show-toplevel` finds
274
+ // the true root regardless of depth. Compare with path.resolve() so a pure
275
+ // separator-style difference (git always emits forward slashes, even on
276
+ // Windows) doesn't get mistaken for an actual resolution.
277
+ try {
278
+ const toplevel = execFileSync(
279
+ "git",
280
+ ["rev-parse", "--show-toplevel"],
281
+ { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
282
+ ).trim();
283
+ if (toplevel) {
284
+ const resolved = resolve(toplevel) !== resolve(cwd);
285
+ return {
286
+ root: resolved ? toplevel : cwd,
287
+ resolved,
288
+ why: `'${cwd}' is nested inside a git repository rooted at '${toplevel}'`,
289
+ };
290
+ }
291
+ } catch {
292
+ // Not inside any git repository — try the subdirectory scan below.
293
+ }
294
+
295
+ // cwd itself isn't a repo — check whether it holds exactly one immediate
296
+ // subdirectory that is. Ambiguous (0 or 2+) candidates fall through
297
+ // unchanged, preserving the existing placeholder behavior.
298
+ try {
299
+ const entries = readdirSync(cwd, { withFileTypes: true });
300
+ const gitDirs = entries.filter(
301
+ (e) => e.isDirectory() && existsSync(join(cwd, e.name, ".git")),
302
+ );
303
+ if (gitDirs.length === 1) {
304
+ const sub = join(cwd, gitDirs[0].name);
305
+ return {
306
+ root: sub,
307
+ resolved: true,
308
+ why: `'${cwd}' is not a git repository, but its only subdirectory '${gitDirs[0].name}' is — resolved detection to that subdirectory`,
309
+ };
310
+ }
311
+ } catch {
312
+ // cwd unreadable — fall through to the unresolved case.
313
+ }
314
+
315
+ return { root: cwd, resolved: false, why: "" };
316
+ }
317
+
238
318
  /**
239
319
  * Derive a human-readable project name from the repository slug.
240
320
  *
@@ -295,23 +375,32 @@ function deriveProjectName(repoSlug) {
295
375
  * @returns {Promise<ConfigDraft>}
296
376
  */
297
377
  export async function detectConfig(cwd = process.cwd()) {
378
+ // Resolve the effective repo root FIRST — every detector below runs
379
+ // against this, not the raw cwd argument. See resolveGitRoot() docblock.
380
+ const { root: gitRoot, resolved: rootResolved, why: rootWhy } = resolveGitRoot(cwd);
381
+
298
382
  // Project identity
299
- const { owner, repo, remoteDetected } = detectRemote(cwd);
383
+ const { owner, repo, remoteDetected } = detectRemote(gitRoot);
300
384
 
301
385
  // Project name derived from the repo slug
302
386
  const name = deriveProjectName(repo.value);
303
387
 
304
- // Paths — always high confidence (derived from the cwd argument)
305
- const root = high(cwd, "process.cwd()", "Absolute path passed to detectConfig — the project root");
388
+ // Paths — high confidence in the common case (root === cwd, nothing to
389
+ // resolve). When detection had to redirect to a git subdirectory or a
390
+ // parent toplevel, flag it medium confidence with an explanatory `why` so
391
+ // the review screen surfaces the redirection instead of silently adopting it.
392
+ const root = rootResolved
393
+ ? medium(gitRoot, "resolved git repository root", rootWhy)
394
+ : high(cwd, "process.cwd()", "Absolute path passed to detectConfig — the project root");
306
395
  const worktreeBase = high(
307
- join(cwd, ".claude", "worktrees"),
396
+ join(gitRoot, ".claude", "worktrees"),
308
397
  "derived from root",
309
- `Convention: {root}/.claude/worktrees (root = ${cwd})`,
398
+ `Convention: {root}/.claude/worktrees (root = ${gitRoot})`,
310
399
  );
311
400
 
312
401
  // Branch detection
313
- const defaultBranchField = detectDefaultBranch(cwd);
314
- const stagingBranchField = detectStagingBranch(cwd, defaultBranchField.value);
402
+ const defaultBranchField = detectDefaultBranch(gitRoot);
403
+ const stagingBranchField = detectStagingBranch(gitRoot, defaultBranchField.value);
315
404
 
316
405
  return {
317
406
  project: { owner, repo, name },