From d63d11b86ab4811ed1dcb0950c7cd3321c45ca60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 23 Mar 2026 09:03:34 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20batch=20isolated=20fixes=20=E2=80=94=20e?= =?UTF-8?q?rror=20messages,=20preferences,=20web=20auth,=20MCP=20vars,=20d?= =?UTF-8?q?etection,=20gitignore=20(#2232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix merge failure notification referencing non-existent /complete-milestone command (#1891) - Rephrase heartbeat mismatch warning to be less alarming (#1567) - Add fallback parser for heading+list format in preferences.md (#2036) - Print authenticated URL with token to stderr for headless environments (#2082) - Apply variable expansion to HTTP MCP server URLs (#2150) - Add missing PROJECT_FILES entries for .NET, Xcode, Docker, git submodules (#2200) - Use git add --force for .gsd/ paths in plan-slice commit instruction (#2155) Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto-prompts.ts | 2 +- src/resources/extensions/gsd/detection.ts | 19 ++++++ src/resources/extensions/gsd/preferences.ts | 67 +++++++++++++++++-- src/resources/extensions/gsd/session-lock.ts | 4 +- .../extensions/gsd/worktree-resolver.ts | 4 +- src/resources/extensions/mcp-client/index.ts | 6 +- src/web-mode.ts | 10 ++- 7 files changed, 98 insertions(+), 14 deletions(-) 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 }