forgedock 1.0.8 → 1.0.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.
- package/bin/forge-utils.mjs +164 -0
- package/bin/forgedock.mjs +1914 -394
- package/bin/hooks/session-start.mjs +317 -0
- package/bin/init-detect.mjs +325 -0
- package/bin/init-enrich-api.mjs +203 -0
- package/bin/registry.mjs +358 -0
- package/bin/tui.mjs +453 -2
- package/commands/forgedock-init.md +305 -384
- package/docs/CONFIG.md +167 -0
- package/docs/superpowers/specs/2026-06-09-intelligent-onboarding-design.md +172 -0
- package/package.json +5 -1
- package/scripts/verify-route-registration.sh +23 -17
|
@@ -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 not available or detached HEAD
|
|
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
|
+
}
|