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:
parent
c7acc3a7c4
commit
d63d11b86a
7 changed files with 98 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue