diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts index 53f792937..932f28e2e 100644 --- a/src/resources/extensions/gsd/activity-log.ts +++ b/src/resources/extensions/gsd/activity-log.ts @@ -11,6 +11,7 @@ import { writeFileSync, writeSync, mkdirSync, readdirSync, unlinkSync, statSync, openSync, closeSync, constants } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; +import { GSDError, GSD_IO_ERROR } from "./errors.js"; const SEQ_PREFIX_RE = /^(\d+)-/; import type { ExtensionContext } from "@gsd/pi-coding-agent"; @@ -95,7 +96,7 @@ function nextActivityFilePath( } } // Fallback: should never reach here in practice - throw new Error(`Failed to find available activity log sequence in ${activityDir}`); + throw new GSDError(GSD_IO_ERROR, `Failed to find available activity log sequence in ${activityDir}`); } export function saveActivityLog( diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 874a9e699..3edd40475 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -8,6 +8,7 @@ import { existsSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, realpathSync, utimesSync, unlinkSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; +import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js"; import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js"; import { execSync, execFileSync } from "node:child_process"; import { @@ -301,7 +302,8 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin } catch (err) { // If chdir fails, the worktree was created but we couldn't enter it. // Don't store originalBase -- caller can retry or clean up. - throw new Error( + throw new GSDError( + GSD_IO_ERROR, `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`, ); } @@ -367,7 +369,8 @@ export function teardownAutoWorktree( process.chdir(originalBasePath); originalBase = null; } catch (err) { - throw new Error( + throw new GSDError( + GSD_IO_ERROR, `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`, ); } @@ -425,22 +428,22 @@ export function getAutoWorktreePath(basePath: string, milestoneId: string): stri export function enterAutoWorktree(basePath: string, milestoneId: string): string { const p = worktreePath(basePath, milestoneId); if (!existsSync(p)) { - throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`); + throw new GSDError(GSD_IO_ERROR, `Auto-worktree for ${milestoneId} does not exist at ${p}`); } // Validate this is a real git worktree, not a stray directory (#695) const gitPath = join(p, ".git"); if (!existsSync(gitPath)) { - throw new Error(`Auto-worktree path ${p} exists but is not a git worktree (no .git)`); + throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} exists but is not a git worktree (no .git)`); } try { const content = readFileSync(gitPath, "utf8").trim(); if (!content.startsWith("gitdir: ")) { - throw new Error(`Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`); + throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`); } } catch (err) { if (err instanceof Error && err.message.includes("worktree")) throw err; - throw new Error(`Auto-worktree path ${p} exists but .git is unreadable`); + throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`); } const previousCwd = process.cwd(); @@ -449,7 +452,8 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string process.chdir(p); originalBase = basePath; } catch (err) { - throw new Error( + throw new GSDError( + GSD_IO_ERROR, `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`, ); } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index dc687c4e7..0904a4d3e 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -101,6 +101,7 @@ import { getProjectTotals, formatCost, formatTokenCount, } from "./metrics.js"; import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js"; +import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js"; import { join } from "node:path"; import { sep as pathSep } from "node:path"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, unlinkSync, statSync } from "node:fs"; @@ -2301,7 +2302,7 @@ async function dispatchNextUnit( if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) { try { const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP"); - if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`); + if (!roadmapPath) throw new GSDError(GSD_ARTIFACT_MISSING, `Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`); const roadmapContent = readFileSync(roadmapPath, "utf-8"); const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent); s.basePath = s.originalBasePath; diff --git a/src/resources/extensions/gsd/db-writer.ts b/src/resources/extensions/gsd/db-writer.ts index c62fe0140..6671a47b5 100644 --- a/src/resources/extensions/gsd/db-writer.ts +++ b/src/resources/extensions/gsd/db-writer.ts @@ -12,6 +12,7 @@ import { join, resolve } from 'node:path'; import type { Decision, Requirement } from './types.js'; import { resolveGsdRootFile } from './paths.js'; import { saveFile } from './files.js'; +import { GSDError, GSD_STALE_STATE, GSD_IO_ERROR } from './errors.js'; // ─── Markdown Generators ────────────────────────────────────────────────── @@ -249,7 +250,7 @@ export async function updateRequirementInDb( const existing = db.getRequirementById(id); if (!existing) { - throw new Error(`Requirement ${id} not found`); + throw new GSDError(GSD_STALE_STATE, `Requirement ${id} not found`); } // Merge updates into existing @@ -331,7 +332,7 @@ export async function saveArtifactToDb( const gsdDir = resolve(basePath, '.gsd'); const fullPath = resolve(basePath, '.gsd', opts.path); if (!fullPath.startsWith(gsdDir)) { - throw new Error(`saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`); + throw new GSDError(GSD_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`); } await saveFile(fullPath, opts.content); } catch (err) { diff --git a/src/resources/extensions/gsd/diff-context.ts b/src/resources/extensions/gsd/diff-context.ts index c0431348e..637679c94 100644 --- a/src/resources/extensions/gsd/diff-context.ts +++ b/src/resources/extensions/gsd/diff-context.ts @@ -8,6 +8,7 @@ import { execFileSync, execFile } from "node:child_process"; import { resolve } from "node:path"; +import { GSDError, GSD_PARSE_ERROR } from "./errors.js"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -75,7 +76,7 @@ export async function getRecentlyChangedFiles( try { const days = Math.max(1, Math.floor(Number(sinceDays))); - if (!Number.isFinite(days)) throw new Error("invalid sinceDays"); + if (!Number.isFinite(days)) throw new GSDError(GSD_PARSE_ERROR, "invalid sinceDays"); // Run all three queries concurrently — they read independent git state const [logRaw, stagedRaw, statusRaw] = await Promise.all([ diff --git a/src/resources/extensions/gsd/errors.ts b/src/resources/extensions/gsd/errors.ts index ac30bb714..f6ee5090f 100644 --- a/src/resources/extensions/gsd/errors.ts +++ b/src/resources/extensions/gsd/errors.ts @@ -10,8 +10,6 @@ export const GSD_STALE_STATE = "GSD_STALE_STATE"; export const GSD_LOCK_HELD = "GSD_LOCK_HELD"; -export const GSD_DISPATCH_FAILED = "GSD_DISPATCH_FAILED"; -export const GSD_TIMEOUT = "GSD_TIMEOUT"; export const GSD_ARTIFACT_MISSING = "GSD_ARTIFACT_MISSING"; export const GSD_GIT_ERROR = "GSD_GIT_ERROR"; export const GSD_MERGE_CONFLICT = "GSD_MERGE_CONFLICT"; diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 00c65c4dd..cb0e6a5d1 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -29,7 +29,7 @@ import { nativeUpdateRef, nativeAddPaths, } from "./native-git-bridge.js"; -import { GSDError, GSD_MERGE_CONFLICT } from "./errors.js"; +import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -292,7 +292,7 @@ export function runGit(basePath: string, args: string[], options: { allowFailure } catch (error) { if (options.allowFailure) return ""; const message = error instanceof Error ? error.message : String(error); - throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`); + throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`); } } diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 70c0bbeb1..518f6946f 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -9,6 +9,7 @@ import { createRequire } from 'node:module'; import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import type { Decision, Requirement } from './types.js'; +import { GSDError, GSD_STALE_STATE } from './errors.js'; // Create a require function for loading native modules in ESM context const _require = createRequire(import.meta.url); @@ -416,7 +417,7 @@ export function closeDatabase(): void { * Runs a function inside a transaction. Rolls back on error. */ export function transaction(fn: () => T): T { - if (!currentDb) throw new Error('gsd-db: No database open'); + if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); currentDb.exec('BEGIN'); try { const result = fn(); @@ -434,7 +435,7 @@ export function transaction(fn: () => T): T { * Insert a decision. The `seq` field is auto-generated. */ export function insertDecision(d: Omit): void { - if (!currentDb) throw new Error('gsd-db: No database open'); + if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); currentDb.prepare( `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, @@ -495,7 +496,7 @@ export function getActiveDecisions(): Decision[] { * Insert a requirement. */ export function insertRequirement(r: Requirement): void { - if (!currentDb) throw new Error('gsd-db: No database open'); + if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); currentDb.prepare( `INSERT INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by) VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`, @@ -747,7 +748,7 @@ export function _resetProvider(): void { * Insert or replace a decision. Uses the `id` UNIQUE constraint for idempotency. */ export function upsertDecision(d: Omit): void { - if (!currentDb) throw new Error('gsd-db: No database open'); + if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); currentDb.prepare( `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, @@ -767,7 +768,7 @@ export function upsertDecision(d: Omit): void { * Insert or replace a requirement. Uses the `id` PK for idempotency. */ export function upsertRequirement(r: Requirement): void { - if (!currentDb) throw new Error('gsd-db: No database open'); + if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); currentDb.prepare( `INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by) VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`, @@ -813,7 +814,7 @@ export function insertArtifact(a: { task_id: string | null; full_content: string; }): void { - if (!currentDb) throw new Error('gsd-db: No database open'); + if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); currentDb.prepare( `INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at) VALUES (:path, :artifact_type, :milestone_id, :slice_id, :task_id, :full_content, :imported_at)`, diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index ee507650b..0ce89662b 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -8,6 +8,7 @@ import { execSync, execFileSync } from "node:child_process"; import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { GSDError, GSD_GIT_ERROR } from "./errors.js"; /** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ const GIT_NO_PROMPT_ENV = { @@ -148,7 +149,7 @@ function gitExec(basePath: string, args: string[], allowFailure = false): string }).trim(); } catch { if (allowFailure) return ""; - throw new Error(`git ${args.join(" ")} failed in ${basePath}`); + throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}`); } } @@ -162,7 +163,7 @@ function gitFileExec(basePath: string, args: string[], allowFailure = false): st }).trim(); } catch { if (allowFailure) return ""; - throw new Error(`git ${args.join(" ")} failed in ${basePath}`); + throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}`); } } diff --git a/src/resources/extensions/gsd/plugin-importer.ts b/src/resources/extensions/gsd/plugin-importer.ts index 863a936f1..7a8fbe29e 100644 --- a/src/resources/extensions/gsd/plugin-importer.ts +++ b/src/resources/extensions/gsd/plugin-importer.ts @@ -19,6 +19,7 @@ import { type MarketplaceDiscoveryResult, type DiscoveredPlugin, } from './marketplace-discovery.js'; +import { GSDError, GSD_STALE_STATE } from './errors.js'; import { NamespacedRegistry, componentsFromDiscovery, @@ -252,7 +253,7 @@ export class PluginImporter { componentFilter: (component: NamespacedComponent) => boolean ): NamespacedComponent[] { if (!this.registry) { - throw new Error('Must call discover() before selectComponents()'); + throw new GSDError(GSD_STALE_STATE, 'Must call discover() before selectComponents()'); } return this.registry.getAll().filter(componentFilter); @@ -270,7 +271,7 @@ export class PluginImporter { */ validateImport(selected: NamespacedComponent[]): ValidationResult { if (!this.registry) { - throw new Error('Must call discover() before validateImport()'); + throw new GSDError(GSD_STALE_STATE, 'Must call discover() before validateImport()'); } // Create a temporary resolver for the selected components diff --git a/src/resources/extensions/gsd/prompt-loader.ts b/src/resources/extensions/gsd/prompt-loader.ts index ae3017826..3a4f67b6e 100644 --- a/src/resources/extensions/gsd/prompt-loader.ts +++ b/src/resources/extensions/gsd/prompt-loader.ts @@ -18,6 +18,7 @@ */ import { readFileSync, readdirSync } from "node:fs"; +import { GSDError, GSD_PARSE_ERROR } from "./errors.js"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -87,10 +88,11 @@ export function loadPrompt(name: string, vars: Record = {}): str .map(m => m.slice(2, -2)) .filter(key => !(key in vars)); if (missing.length > 0) { - throw new Error( + throw new GSDError( + GSD_PARSE_ERROR, `loadPrompt("${name}"): template declares {{${missing.join("}}, {{")}}}} but no value was provided. ` + `This usually means the extension code in memory is older than the template on disk. ` + - `Restart pi to reload the extension.` + `Restart pi to reload the extension.`, ); } } diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 1e894e623..191676ccf 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -17,6 +17,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { join, resolve, sep } from "node:path"; +import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js"; import { nativeBranchDelete, nativeBranchExists, @@ -121,14 +122,14 @@ export function worktreeBranchName(name: string): string { export function createWorktree(basePath: string, name: string, opts: { branch?: string; startPoint?: string; reuseExistingBranch?: boolean } = {}): WorktreeInfo { // Validate name: alphanumeric, hyphens, underscores only if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - throw new Error(`Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`); + throw new GSDError(GSD_PARSE_ERROR, `Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`); } const wtPath = worktreePath(basePath, name); const branch = opts.branch ?? worktreeBranchName(name); if (existsSync(wtPath)) { - throw new Error(`Worktree "${name}" already exists at ${wtPath}`); + throw new GSDError(GSD_STALE_STATE, `Worktree "${name}" already exists at ${wtPath}`); } // Ensure the .gsd/worktrees/ directory exists @@ -151,7 +152,8 @@ export function createWorktree(basePath: string, name: string, opts: { branch?: const branchInUse = worktreeEntries.some(entry => entry.branch === branch); if (branchInUse) { - throw new Error( + throw new GSDError( + GSD_LOCK_HELD, `Branch "${branch}" is already in use by another worktree. ` + `Remove the existing worktree first with /worktree remove ${name}.`, ); @@ -432,12 +434,12 @@ export function mergeWorktreeToMain(basePath: string, name: string, commitMessag const current = nativeGetCurrentBranch(basePath); if (current !== mainBranch) { - throw new Error(`Must be on ${mainBranch} to merge. Currently on ${current}.`); + throw new GSDError(GSD_GIT_ERROR, `Must be on ${mainBranch} to merge. Currently on ${current}.`); } const result = nativeMergeSquash(basePath, branch); if (!result.success) { - throw new Error(`Merge conflicts detected in: ${result.conflicts.join(", ")}`); + throw new GSDError(GSD_MERGE_CONFLICT, `Merge conflicts detected in: ${result.conflicts.join(", ")}`); } nativeCommit(basePath, commitMessage);