diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 94d24facf..48bddc015 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -986,7 +986,7 @@ export async function buildPlanSlicePrompt( const prefs = loadEffectiveGSDPreferences(); const commitDocsEnabled = prefs?.preferences?.git?.commit_docs !== false; const commitInstruction = commitDocsEnabled - ? `Commit the plan files only: \`git add ${relSlicePath(base, mid, sid)}/ .gsd/DECISIONS.md .gitignore && git commit -m "docs(${sid}): add slice plan"\`. Do not stage .gsd/STATE.md or other runtime files — the system manages those.` + ? `Commit the plan files only: \`git add --force ${relSlicePath(base, mid, sid)}/ .gsd/DECISIONS.md .gitignore && git commit -m "docs(${sid}): add slice plan"\`. Do not stage .gsd/STATE.md or other runtime files — the system manages those.` : "Do not commit — planning docs are not tracked in git for this project."; return loadPrompt("plan-slice", { workingDirectory: base, diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 9a0c159eb..3c01a277a 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -87,6 +87,18 @@ export const PROJECT_FILES = [ "mix.exs", "deno.json", "deno.jsonc", + // .NET + ".sln", + ".csproj", + "Directory.Build.props", + // Git submodules + ".gitmodules", + // Xcode + "project.yml", + ".xcodeproj", + ".xcworkspace", + // Docker + "Dockerfile", ] as const; const LANGUAGE_MAP: Record = { @@ -106,6 +118,13 @@ const LANGUAGE_MAP: Record = { "mix.exs": "elixir", "deno.json": "typescript/deno", "deno.jsonc": "typescript/deno", + ".sln": "dotnet", + ".csproj": "dotnet", + "Directory.Build.props": "dotnet", + "project.yml": "swift/xcode", + ".xcodeproj": "swift/xcode", + ".xcworkspace": "swift/xcode", + "Dockerfile": "docker", }; const MONOREPO_MARKERS = [ diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index e369525cc..62df4726e 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -200,12 +200,22 @@ function loadPreferencesFile(path: string, scope: "global" | "project"): LoadedG export function parsePreferencesMarkdown(content: string): GSDPreferences | null { // Use indexOf instead of [\s\S]*? regex to avoid backtracking (#468) const startMarker = content.startsWith('---\r\n') ? '---\r\n' : '---\n'; - if (!content.startsWith(startMarker)) return null; - const searchStart = startMarker.length; - const endIdx = content.indexOf('\n---', searchStart); - if (endIdx === -1) return null; - const block = content.slice(searchStart, endIdx); - return parseFrontmatterBlock(block.replace(/\r/g, '')); + if (content.startsWith(startMarker)) { + const searchStart = startMarker.length; + const endIdx = content.indexOf('\n---', searchStart); + if (endIdx === -1) return null; + const block = content.slice(searchStart, endIdx); + return parseFrontmatterBlock(block.replace(/\r/g, '')); + } + + // Fallback: heading+list format (e.g. "## Git\n- isolation: none") (#2036) + // GSD agents may write preferences files without frontmatter delimiters. + if (/^##\s+\w/m.test(content)) { + return parseHeadingListFormat(content); + } + + console.warn("[parsePreferencesMarkdown] preferences.md exists but uses an unrecognized format — skipping."); + return null; } function parseFrontmatterBlock(frontmatter: string): GSDPreferences { @@ -221,6 +231,51 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { } } +/** + * Parse heading+list format into a nested object, then cast to GSDPreferences. + * Handles markdown like: + * ## Git + * - isolation: none + * - commit_docs: true + * ## Models + * - planner: sonnet + */ +function parseHeadingListFormat(content: string): GSDPreferences { + const result: Record> = {}; + let currentSection: string | null = null; + + for (const rawLine of content.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + const headingMatch = line.match(/^##\s+(.+)$/); + if (headingMatch) { + currentSection = headingMatch[1].trim().toLowerCase().replace(/\s+/g, '_'); + continue; + } + if (currentSection) { + const itemMatch = line.match(/^-\s+([^:]+):\s*(.*)$/); + if (itemMatch) { + if (!result[currentSection]) result[currentSection] = {}; + const value = itemMatch[2].trim(); + // Coerce "true"/"false" strings and numbers + result[currentSection][itemMatch[1].trim()] = value; + } + } + } + + // Convert string values to appropriate types via YAML parser for each section + const typed: Record = {}; + for (const [section, entries] of Object.entries(result)) { + const yamlLines = Object.entries(entries).map(([k, v]) => `${k}: ${v}`).join('\n'); + try { + typed[section] = parseYaml(yamlLines); + } catch { + typed[section] = entries; + } + } + + return typed as GSDPreferences; +} + // ─── Merging ──────────────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/session-lock.ts b/src/resources/extensions/gsd/session-lock.ts index eb9ea9fcc..dc19f86c4 100644 --- a/src/resources/extensions/gsd/session-lock.ts +++ b/src/resources/extensions/gsd/session-lock.ts @@ -239,7 +239,7 @@ export function acquireSessionLock(basePath: string): SessionLockResult { const elapsed = Date.now() - _lockAcquiredAt; if (elapsed < 1_800_000) { process.stderr.write( - `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — event loop stall, continuing.\n`, + `[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`, ); return; // Suppress false positive } @@ -299,7 +299,7 @@ export function acquireSessionLock(basePath: string): SessionLockResult { const elapsed = Date.now() - _lockAcquiredAt; if (elapsed < 1_800_000) { process.stderr.write( - `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — event loop stall, continuing.\n`, + `[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`, ); return; } diff --git a/src/resources/extensions/gsd/worktree-resolver.ts b/src/resources/extensions/gsd/worktree-resolver.ts index 4a7723eee..7eeeb634e 100644 --- a/src/resources/extensions/gsd/worktree-resolver.ts +++ b/src/resources/extensions/gsd/worktree-resolver.ts @@ -410,10 +410,10 @@ export class WorktreeResolver { }); // Surface a clear, actionable error. The worktree and milestone branch are // intentionally preserved — nothing has been deleted. The user can retry - // /complete-milestone or merge manually once the underlying issue is fixed + // /gsd dispatch complete-milestone or merge manually once the underlying issue is fixed // (e.g. checkout to wrong branch, unresolved conflicts). (#1668) ctx.notify( - `Milestone merge failed: ${msg}. Your worktree and milestone branch are preserved — retry /complete-milestone or merge manually.`, + `Milestone merge failed: ${msg}. Your worktree and milestone branch are preserved — retry /gsd dispatch complete-milestone or merge manually.`, "warning", ); diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts index 904fbbcb4..2113540ff 100644 --- a/src/resources/extensions/mcp-client/index.ts +++ b/src/resources/extensions/mcp-client/index.ts @@ -149,7 +149,11 @@ async function getOrConnect(name: string, signal?: AbortSignal): Promise stderr: "pipe", }); } else if (config.transport === "http" && config.url) { - transport = new StreamableHTTPClientTransport(new URL(config.url)); + const resolvedUrl = config.url.replace( + /\$\{([^}]+)\}/g, + (_, name) => process.env[name] ?? "", + ); + transport = new StreamableHTTPClientTransport(new URL(resolvedUrl)); } else { throw new Error(`Server "${name}" has unsupported transport: ${config.transport}`); } diff --git a/src/web-mode.ts b/src/web-mode.ts index 08696bcf1..42683a667 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -687,7 +687,12 @@ export async function launchWebMode( // Register in multi-instance registry registerInstance(options.cwd, { pid, port, url }, deps.registryPath) } - ;(deps.openBrowser ?? openBrowser)(`${url}/#token=${authToken}`) + const authenticatedUrl = `${url}/#token=${authToken}` + try { + ;(deps.openBrowser ?? openBrowser)(authenticatedUrl) + } catch (browserError) { + stderr.write(`[gsd] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`) + } } catch (error) { const failure: WebModeLaunchFailure = { mode: 'web', @@ -706,6 +711,7 @@ export async function launchWebMode( return failure } + const authenticatedUrl = `${url}/#token=${authToken}` const success: WebModeLaunchSuccess = { mode: 'web', ok: true, @@ -718,7 +724,7 @@ export async function launchWebMode( hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, } - stderr.write(`[gsd] Ready → ${url}\n`) + stderr.write(`[gsd] Ready → ${authenticatedUrl}\n`) emitLaunchStatus(stderr, success) return success }