forgedock 1.0.8 → 1.0.10

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.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-start.mjs — ForgeDock SessionStart hook.
4
+ *
5
+ * Installed into ~/.claude/settings.json by `forgedock install`.
6
+ * Removed by `forgedock uninstall`.
7
+ *
8
+ * Called by Claude Code at the start of every session. Resolves the
9
+ * per-directory ForgeDock state and acts accordingly:
10
+ *
11
+ * managed-active → print ForgeDock context to stdout (forge.yaml summary
12
+ * + available commands). If forge.yaml is missing or
13
+ * stale, offer a one-shot autopilot init suggestion.
14
+ * managed-optedout → completely silent (no output, no context injection).
15
+ * unmanaged → print a one-time, suppressible "Enable ForgeDock here?"
16
+ * nudge. Subsequent sessions for the same directory are
17
+ * silent after the nudge is recorded.
18
+ *
19
+ * Fail-open contract: any uncaught error is swallowed and the hook exits 0
20
+ * so that a broken hook never prevents a Claude Code session from starting.
21
+ *
22
+ * Environment:
23
+ * Claude Code sets the working directory before invoking the hook, so
24
+ * process.cwd() reliably returns the session's project directory.
25
+ * FORGE_HOME is resolved at install time and baked into the command path,
26
+ * so this file's __dirname is always the hooks/ directory inside the
27
+ * ForgeDock installation.
28
+ */
29
+
30
+ import { fileURLToPath, pathToFileURL } from "url";
31
+ import { dirname, join, resolve } from "path";
32
+ import { existsSync, readFileSync } from "fs";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Bootstrap — resolve paths from the hook's own location, not from cwd
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = dirname(__filename);
40
+ /** Absolute path to the ForgeDock installation root (parent of bin/). */
41
+ const FORGE_HOME = resolve(__dirname, "..", "..");
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Registry helpers — populated inside the try block to honour fail-open
45
+ // ---------------------------------------------------------------------------
46
+
47
+ // Declared at module scope so they are accessible from handler functions
48
+ // (handleUnmanaged) called from within the try block. Assigned inside the
49
+ // try/catch so that any import failure (missing file, permissions error,
50
+ // syntax error) is caught and the hook still exits 0. <!-- fix: forge#383 -->
51
+ let resolveState;
52
+ let nudgeSeen;
53
+ let markNudgeSeen;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // forge-utils helpers — populated inside the try block to honour fail-open
57
+ // ---------------------------------------------------------------------------
58
+
59
+ // Declared at module scope so they are accessible from context-builder
60
+ // functions called from within the try block. Assigned inside the try/catch
61
+ // so that any import failure (missing file, permissions error, syntax error)
62
+ // is caught and the hook still exits 0. <!-- fix: forge#489 -->
63
+ let parseForgeYaml;
64
+ let sanitizeContextValue;
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Main — wrapped in try/catch to guarantee fail-open
68
+ // ---------------------------------------------------------------------------
69
+
70
+ try {
71
+ // Import registry helpers from the same installation.
72
+ // Placed inside try/catch to honour the fail-open contract: if registry.mjs
73
+ // cannot be loaded (broken install, missing file, permissions error) the
74
+ // catch block below ensures we still exit 0 without blocking Claude Code.
75
+ //
76
+ // Use pathToFileURL() to convert the OS-native path to a file:// URL before
77
+ // passing it to dynamic import(). On Windows, join() produces backslash paths
78
+ // that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
79
+ /** @type {import('../registry.mjs')} */
80
+ (
81
+ { resolveState, nudgeSeen, markNudgeSeen } = await import(
82
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
83
+ )
84
+ );
85
+
86
+ // Import forge-utils helpers from the same installation.
87
+ // Placed inside try/catch to honour the fail-open contract: if forge-utils.mjs
88
+ // cannot be loaded (broken install, missing file, permissions error) the
89
+ // catch block below ensures we still exit 0 without blocking Claude Code.
90
+ //
91
+ // Use pathToFileURL() to convert the OS-native path to a file:// URL before
92
+ // passing it to dynamic import(). On Windows, join() produces backslash paths
93
+ // that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
94
+ /** @type {import('../forge-utils.mjs')} */
95
+ (
96
+ { parseForgeYaml, sanitizeContextValue } = await import(
97
+ pathToFileURL(join(FORGE_HOME, "bin", "forge-utils.mjs")).href
98
+ )
99
+ );
100
+
101
+ const cwd = process.cwd();
102
+ const state = resolveState(cwd);
103
+
104
+ switch (state) {
105
+ case "managed-active":
106
+ await handleManagedActive(cwd);
107
+ break;
108
+
109
+ case "managed-optedout":
110
+ // Completely silent — no output, no context injection
111
+ break;
112
+
113
+ case "unmanaged":
114
+ await handleUnmanaged(cwd);
115
+ break;
116
+
117
+ default:
118
+ // Unknown state — fail open (silent)
119
+ break;
120
+ }
121
+ } catch {
122
+ // Fail open — never block a Claude Code session
123
+ process.exit(0);
124
+ }
125
+
126
+ // Always exit 0 regardless of what happened above
127
+ process.exit(0);
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Handlers
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Emit ForgeDock context for a managed-active directory.
135
+ *
136
+ * Prints a concise forge.yaml summary plus the list of available pipeline
137
+ * commands. If forge.yaml is absent, prints a suggestion to run init instead.
138
+ *
139
+ * @param {string} dir - Absolute path to the project directory.
140
+ */
141
+ async function handleManagedActive(dir) {
142
+ const forgeYamlPath = join(dir, "forge.yaml");
143
+
144
+ if (!existsSync(forgeYamlPath)) {
145
+ // Directory is managed (has .forgedock marker) but has no forge.yaml yet
146
+ console.log(buildMissingConfigContext(dir));
147
+ return;
148
+ }
149
+
150
+ let forgeYaml;
151
+ try {
152
+ forgeYaml = readForgeYaml(forgeYamlPath);
153
+ } catch {
154
+ // Unreadable or corrupt forge.yaml — suggest init
155
+ console.log(buildMissingConfigContext(dir));
156
+ return;
157
+ }
158
+
159
+ console.log(buildActiveContext(dir, forgeYaml));
160
+ }
161
+
162
+ /**
163
+ * Show a one-time suppressible nudge for unmanaged directories.
164
+ *
165
+ * Uses the registry to ensure the nudge fires at most once per directory.
166
+ * After showing, records the directory so subsequent sessions are silent.
167
+ *
168
+ * @param {string} dir - Absolute path to the project directory.
169
+ */
170
+ async function handleUnmanaged(dir) {
171
+ if (nudgeSeen(dir)) {
172
+ // Nudge already shown — stay silent
173
+ return;
174
+ }
175
+
176
+ // Show the nudge, then record it so it won't show again
177
+ console.log(buildNudgeContext(dir));
178
+
179
+ // Best-effort — if this fails the nudge may show once more next session
180
+ await markNudgeSeen(dir).catch(() => {});
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Context builders
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Build the context string injected for a managed-active directory with a
189
+ * valid forge.yaml.
190
+ *
191
+ * @param {string} dir - Absolute path to the project directory.
192
+ * @param {object} forgeYaml - Parsed forge.yaml object.
193
+ * @returns {string}
194
+ */
195
+ function buildActiveContext(dir, forgeYaml) {
196
+ // Sanitize the directory path before interpolating it into the context output.
197
+ // process.cwd() is user-controlled on Linux (directory names may contain <, >,
198
+ // or sequences that form <!-- -->), so it must be treated like any other
199
+ // untrusted value. <!-- fix: forge#450 -->
200
+ const safeDir = sanitizeContextValue(dir, 500) ?? "[project directory]";
201
+ const project = forgeYaml.project ?? {};
202
+ const projectName = sanitizeContextValue(project.name ?? null, 200);
203
+ const owner = sanitizeContextValue(project.owner ?? null, 200);
204
+ const repoName = sanitizeContextValue(project.repo ?? null, 200);
205
+ const repo = owner && repoName ? `${owner}/${repoName}` : (repoName ?? null);
206
+ const description = sanitizeContextValue(project.description ?? null, 400);
207
+
208
+ const rawMilestone = sanitizeContextValue(forgeYaml.milestone ?? null, 200);
209
+ const milestoneNote = rawMilestone
210
+ ? `\n- **Active milestone**: ${rawMilestone}`
211
+ : "";
212
+
213
+ const nameNote = projectName ? `\n- **Project**: ${projectName}` : "";
214
+ const repoNote = repo ? `\n- **Repo**: ${repo}` : "";
215
+ const descNote = description ? `\n- **Description**: ${description}` : "";
216
+
217
+ const stagingBranch = sanitizeContextValue(
218
+ forgeYaml.branches?.staging ?? "staging",
219
+ 200,
220
+ );
221
+ const featurePattern = sanitizeContextValue(
222
+ forgeYaml.branches?.feature_pattern ?? "milestone/{slug}",
223
+ 200,
224
+ );
225
+
226
+ return `\
227
+ <!-- ForgeDock: managed-active -->
228
+ **ForgeDock** is active in this directory (${safeDir}).
229
+ ${nameNote}${repoNote}${descNote}${milestoneNote}
230
+ - **Staging branch**: \`${stagingBranch}\`
231
+ - **Feature branch pattern**: \`${featurePattern}\`
232
+
233
+ ### Available pipeline commands
234
+
235
+ | Command | Purpose |
236
+ |---------|---------|
237
+ | /work-on | Pick up a GitHub issue and run the full pipeline |
238
+ | /orchestrate | Parallel work on multiple issues or a milestone |
239
+ | /review-pr | Context-aware PR review |
240
+ | /quality-gate | Pre-commit quality check |
241
+ | /issue | Create a well-structured GitHub issue |
242
+ | /milestone | Create, manage, and ship milestones |
243
+ | /deploy-info | Show what will deploy next |
244
+ | /analytics | Pull production analytics and create issues |
245
+ | /autopilot | Autonomous platform improvement cycle |
246
+
247
+ Run \`/help\` to see the full command list.
248
+ To disable ForgeDock in this directory: \`npx forgedock disable\``;
249
+ }
250
+
251
+ /**
252
+ * Build the context string shown when a managed directory is missing or has
253
+ * a corrupt forge.yaml.
254
+ *
255
+ * @param {string} dir - Absolute path to the project directory.
256
+ * @returns {string}
257
+ */
258
+ function buildMissingConfigContext(dir) {
259
+ // Sanitize dir before display — see forge#450.
260
+ const safeDir = sanitizeContextValue(dir, 500) ?? "[project directory]";
261
+ return `\
262
+ <!-- ForgeDock: managed-active (no forge.yaml) -->
263
+ **ForgeDock** is active in this directory (${safeDir}) but no \`forge.yaml\` was found.
264
+
265
+ Run autopilot init to generate your configuration automatically:
266
+ \`npx forgedock init\`
267
+
268
+ ForgeDock's AI will infer your project structure, GitHub repo, and pipeline settings
269
+ from the codebase — then present a single annotated review screen for you to confirm.`;
270
+ }
271
+
272
+ /**
273
+ * Build the one-time nudge shown for unmanaged directories.
274
+ *
275
+ * @param {string} dir - Absolute path to the project directory.
276
+ * @returns {string}
277
+ */
278
+ function buildNudgeContext(dir) {
279
+ // Sanitize dir before display — see forge#450.
280
+ const safeDir = sanitizeContextValue(dir, 500) ?? "[project directory]";
281
+ return `\
282
+ <!-- ForgeDock: unmanaged nudge -->
283
+ **ForgeDock** is installed but not active in this directory (${safeDir}).
284
+
285
+ To enable the autonomous development pipeline here:
286
+ \`npx forgedock enable\` — mark this directory and stay silent until you run init
287
+ \`npx forgedock init\` — immediately generate forge.yaml with AI autopilot
288
+
289
+ This message will not appear again in this directory.
290
+ To suppress it globally, run: \`npx forgedock disable\``;
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // forge.yaml reader
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * Read and parse forge.yaml from the given path.
299
+ *
300
+ * Uses a minimal YAML parser (key: "value" lines only — sufficient for the
301
+ * fields we need from forge.yaml). Does NOT pull in a full YAML library so
302
+ * the hook stays fast and dependency-free.
303
+ *
304
+ * The real forge.yaml schema has nested sections; this returns a two-level
305
+ * object covering the top-level keys that session-start needs.
306
+ *
307
+ * @param {string} path - Absolute path to forge.yaml.
308
+ * @returns {object} Parsed forge.yaml as a plain object.
309
+ * @throws {Error} If the file cannot be read.
310
+ */
311
+ function readForgeYaml(path) {
312
+ const raw = readFileSync(path, "utf-8");
313
+ return parseForgeYaml(raw);
314
+ }
315
+
316
+ // parseForgeYaml and sanitizeContextValue are dynamically imported from
317
+ // ../forge-utils.mjs inside the try block to honour the fail-open contract.
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * init-detect.mjs — Pure deterministic config detection for ForgeDock.
4
+ *
5
+ * Exports a single function: detectConfig(cwd)
6
+ *
7
+ * Returns a ConfigDraft: a structured object mirroring forge.yaml's required
8
+ * sections (project, paths, branches) where every leaf is:
9
+ * { value, confidence, source, why }
10
+ *
11
+ * Confidence levels:
12
+ * "high" — verified from a concrete, unambiguous source
13
+ * "medium" — inferred from available signals; likely correct
14
+ * "low" — guessed default; no supporting evidence found
15
+ *
16
+ * Contract guarantees:
17
+ * - Pure: no prompts, no writes; reads only git metadata and the filesystem
18
+ * - Safe: every try/catch degrades to "low" confidence defaults, never throws
19
+ * - Isolated: imports only Node builtins (child_process, path)
20
+ * - Testable: inject `cwd` to point at a fixture repo and assert the draft
21
+ */
22
+
23
+ import { execFileSync } from "child_process";
24
+ import { join } from "path";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Field factory helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Create a high-confidence field.
32
+ * @param {string} value
33
+ * @param {string} source
34
+ * @param {string} why
35
+ * @returns {{ value: string, confidence: 'high', source: string, why: string }}
36
+ */
37
+ function high(value, source, why) {
38
+ return { value, confidence: "high", source, why };
39
+ }
40
+
41
+ /**
42
+ * Create a medium-confidence field.
43
+ * @param {string} value
44
+ * @param {string} source
45
+ * @param {string} why
46
+ * @returns {{ value: string, confidence: 'medium', source: string, why: string }}
47
+ */
48
+ function medium(value, source, why) {
49
+ return { value, confidence: "medium", source, why };
50
+ }
51
+
52
+ /**
53
+ * Create a low-confidence field (default / guessed).
54
+ * @param {string} value
55
+ * @param {string} source
56
+ * @param {string} why
57
+ * @returns {{ value: string, confidence: 'low', source: string, why: string }}
58
+ */
59
+ function low(value, source, why) {
60
+ return { value, confidence: "low", source, why };
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Detection helpers — each returns a field object; never throws
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Detect GitHub owner and repo from the git remote named "origin".
69
+ *
70
+ * Supports both SSH (git@github.com:owner/repo.git) and HTTPS
71
+ * (https://github.com/owner/repo.git) remote URL formats.
72
+ *
73
+ * Uses execFileSync (no shell) so remote URL content cannot be used for
74
+ * command injection.
75
+ *
76
+ * @param {string} cwd - Absolute path to the repo root to inspect.
77
+ * @returns {{ owner: import('./init-detect.mjs').ConfigField, repo: import('./init-detect.mjs').ConfigField, remoteDetected: boolean }}
78
+ */
79
+ function detectRemote(cwd) {
80
+ try {
81
+ const remoteUrl = execFileSync(
82
+ "git",
83
+ ["remote", "get-url", "origin"],
84
+ {
85
+ cwd,
86
+ encoding: "utf-8",
87
+ stdio: ["pipe", "pipe", "pipe"],
88
+ timeout: 5000,
89
+ },
90
+ ).trim();
91
+
92
+ // SSH: git@github.com:owner/repo.git
93
+ const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
94
+ // HTTPS: https://github.com/owner/repo.git
95
+ const httpsMatch = remoteUrl.match(
96
+ /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
97
+ );
98
+
99
+ if (sshMatch) {
100
+ const [, owner, repo] = sshMatch;
101
+ const sourceLabel = "git remote origin (SSH)";
102
+ const why = `Parsed from SSH remote URL: ${remoteUrl}`;
103
+ return {
104
+ owner: high(owner, sourceLabel, why),
105
+ repo: high(repo, sourceLabel, why),
106
+ remoteDetected: true,
107
+ };
108
+ }
109
+
110
+ if (httpsMatch) {
111
+ const [, owner, repo] = httpsMatch;
112
+ const sourceLabel = "git remote origin (HTTPS)";
113
+ const why = `Parsed from HTTPS remote URL: ${remoteUrl}`;
114
+ return {
115
+ owner: high(owner, sourceLabel, why),
116
+ repo: high(repo, sourceLabel, why),
117
+ remoteDetected: true,
118
+ };
119
+ }
120
+
121
+ // URL not in a recognised format — treat as no remote
122
+ } catch {
123
+ // git not available, not a repo, or no remote named "origin"
124
+ }
125
+
126
+ return {
127
+ owner: low("your-github-org", "default placeholder", "No git remote named 'origin' found"),
128
+ repo: low("your-repo-name", "default placeholder", "No git remote named 'origin' found"),
129
+ remoteDetected: false,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Detect the default branch for the repository.
135
+ *
136
+ * Strategy (first success wins):
137
+ * 1. git symbolic-ref refs/remotes/origin/HEAD → the authoritative remote default
138
+ * 2. git rev-parse --abbrev-ref HEAD → the current local branch
139
+ * 3. Fall back to "main" at low confidence
140
+ *
141
+ * @param {string} cwd
142
+ * @returns {{ value: string, confidence: 'high'|'medium'|'low', source: string, why: string }}
143
+ */
144
+ function detectDefaultBranch(cwd) {
145
+ // Strategy 1: symbolic ref (authoritative)
146
+ try {
147
+ const headRef = execFileSync(
148
+ "git",
149
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
150
+ {
151
+ cwd,
152
+ encoding: "utf-8",
153
+ stdio: ["pipe", "pipe", "pipe"],
154
+ timeout: 5000,
155
+ },
156
+ ).trim();
157
+ const branch = headRef.replace(/^refs\/remotes\/origin\//, "");
158
+ if (branch) {
159
+ return high(
160
+ branch,
161
+ "git symbolic-ref refs/remotes/origin/HEAD",
162
+ `Remote HEAD points to ${branch}`,
163
+ );
164
+ }
165
+ } catch {
166
+ // symbolic-ref not set — try next strategy
167
+ }
168
+
169
+ // Strategy 2: current local branch
170
+ try {
171
+ const cur = execFileSync(
172
+ "git",
173
+ ["rev-parse", "--abbrev-ref", "HEAD"],
174
+ {
175
+ cwd,
176
+ encoding: "utf-8",
177
+ stdio: ["pipe", "pipe", "pipe"],
178
+ timeout: 5000,
179
+ },
180
+ ).trim();
181
+ if (cur && cur !== "HEAD") {
182
+ return medium(
183
+ cur,
184
+ "git rev-parse --abbrev-ref HEAD",
185
+ `Current local branch is ${cur}; used as default branch estimate`,
186
+ );
187
+ }
188
+ } catch {
189
+ // git unavailable or not a git repo
190
+ }
191
+
192
+ // Strategy 3: fall back
193
+ return low("main", "default placeholder", "Could not determine branch from git; defaulting to 'main'");
194
+ }
195
+
196
+ /**
197
+ * Detect the staging branch.
198
+ *
199
+ * Strategy: check whether a remote branch named "staging" exists in the
200
+ * remote listing. Falls back to the default branch at medium confidence
201
+ * when staging is absent (many projects use main as their staging target).
202
+ *
203
+ * @param {string} cwd
204
+ * @param {string} defaultBranch - The already-detected default branch value.
205
+ * @returns {{ value: string, confidence: 'high'|'medium'|'low', source: string, why: string }}
206
+ */
207
+ function detectStagingBranch(cwd, defaultBranch) {
208
+ try {
209
+ const remoteBranches = execFileSync(
210
+ "git",
211
+ ["branch", "-r"],
212
+ {
213
+ cwd,
214
+ encoding: "utf-8",
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ timeout: 5000,
217
+ },
218
+ );
219
+ const branchLines = remoteBranches.split("\n").map((l) => l.trim());
220
+ if (branchLines.some((l) => l === "origin/staging")) {
221
+ return high(
222
+ "staging",
223
+ "git branch -r",
224
+ "Found 'origin/staging' in the remote branch listing",
225
+ );
226
+ }
227
+ } catch {
228
+ // git not available or no remotes
229
+ }
230
+
231
+ return medium(
232
+ defaultBranch,
233
+ "derived from default branch",
234
+ `No 'origin/staging' remote branch found; staging defaults to the default branch '${defaultBranch}'`,
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Derive a human-readable project name from the repository slug.
240
+ *
241
+ * Splits on hyphens and underscores, title-cases each word.
242
+ * E.g. "my-cool-repo" → "My Cool Repo"
243
+ *
244
+ * @param {string} repoSlug
245
+ * @returns {{ value: string, confidence: 'medium', source: string, why: string }}
246
+ */
247
+ function deriveProjectName(repoSlug) {
248
+ const name = repoSlug
249
+ .split(/[-_]/)
250
+ .filter(Boolean)
251
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
252
+ .join(" ");
253
+ return medium(
254
+ name || repoSlug,
255
+ "derived from repo slug",
256
+ `Title-cased version of repo name '${repoSlug}' (split on hyphens/underscores)`,
257
+ );
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Public API
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * @typedef {{ value: string, confidence: 'high'|'medium'|'low', source: string, why: string }} ConfigField
266
+ *
267
+ * @typedef {{
268
+ * project: {
269
+ * owner: ConfigField,
270
+ * repo: ConfigField,
271
+ * name: ConfigField,
272
+ * },
273
+ * paths: {
274
+ * root: ConfigField,
275
+ * worktreeBase: ConfigField,
276
+ * },
277
+ * branches: {
278
+ * default: ConfigField,
279
+ * staging: ConfigField,
280
+ * },
281
+ * meta: {
282
+ * remoteDetected: boolean,
283
+ * },
284
+ * }} ConfigDraft
285
+ */
286
+
287
+ /**
288
+ * Run all deterministic detection steps for a repository rooted at `cwd`.
289
+ *
290
+ * Returns a ConfigDraft — never throws. Every field in the draft carries a
291
+ * confidence level so downstream consumers (init-enrich, review-render) can
292
+ * decide how to treat each value.
293
+ *
294
+ * @param {string} [cwd] - Absolute path to the repo root. Defaults to process.cwd().
295
+ * @returns {Promise<ConfigDraft>}
296
+ */
297
+ export async function detectConfig(cwd = process.cwd()) {
298
+ // Project identity
299
+ const { owner, repo, remoteDetected } = detectRemote(cwd);
300
+
301
+ // Project name derived from the repo slug
302
+ const name = deriveProjectName(repo.value);
303
+
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");
306
+ const worktreeBase = high(
307
+ join(cwd, ".claude", "worktrees"),
308
+ "derived from root",
309
+ `Convention: {root}/.claude/worktrees (root = ${cwd})`,
310
+ );
311
+
312
+ // Branch detection
313
+ const defaultBranchField = detectDefaultBranch(cwd);
314
+ const stagingBranchField = detectStagingBranch(cwd, defaultBranchField.value);
315
+
316
+ return {
317
+ project: { owner, repo, name },
318
+ paths: { root, worktreeBase },
319
+ branches: {
320
+ default: defaultBranchField,
321
+ staging: stagingBranchField,
322
+ },
323
+ meta: { remoteDetected },
324
+ };
325
+ }