fix: batch isolated fixes — error messages, preferences, web auth, MCP vars, detection, gitignore (#2232)

- 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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-23 09:03:34 -06:00 committed by GitHub
parent c7acc3a7c4
commit d63d11b86a
7 changed files with 98 additions and 14 deletions

View file

@ -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,

View file

@ -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<string, string> = {
@ -106,6 +118,13 @@ const LANGUAGE_MAP: Record<string, string> = {
"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 = [

View file

@ -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<string, Record<string, string>> = {};
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<string, unknown> = {};
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 ────────────────────────────────────────────────────────────────
/**

View file

@ -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;
}

View file

@ -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",
);

View file

@ -149,7 +149,11 @@ async function getOrConnect(name: string, signal?: AbortSignal): Promise<Client>
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}`);
}

View file

@ -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
}