From 1b1df587499b091668348c10f0d17cc6e5dcfc95 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 17 Mar 2026 16:59:42 -0400 Subject: [PATCH] refactor: encapsulate auto.ts state into AutoSession class (#898) (#948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: encapsulate auto.ts state into AutoSession class (#898) Follow-up to PR #906 (7 module extractions). All ~40 mutable module-level variables in auto.ts are replaced with properties on a single AutoSession class instance (s). Changes: - auto/session.ts: 200-line AutoSession class with typed properties, clearTimers(), resetDispatchCounters(), completeCurrentUnit(), reset(), and toJSON() for diagnostics. - auto.ts: ~700 variable references renamed from bare names to s.xxx. All module-level let/const state declarations removed. Constants (MAX_UNIT_DISPATCHES, etc.) re-exported from session.ts. - Tests updated: milestone-transition-worktree.test.ts and triage-dispatch.test.ts source-grep patterns updated for s.xxx names. Benefits: - 40 scattered declarations → 1 class with typed properties - Manual reset of 25+ variables in stopAuto → s.reset() - s.toJSON() for state snapshots and diagnostics - grep 's.' shows every state access No behavioral changes. 1224 tests pass. * fix: import constants locally for tsconfig.extensions.json compatibility The extensions tsconfig couldn't resolve re-exported constants from auto/session.js. Fix: import them explicitly in addition to re-exporting. Also remove leftover DISPATCH_GAP_TIMEOUT_MS local declaration. --- src/resources/extensions/gsd/auto.ts | 1303 ++++++++--------- src/resources/extensions/gsd/auto/session.ts | 224 +++ .../milestone-transition-worktree.test.ts | 2 +- .../gsd/tests/triage-dispatch.test.ts | 18 +- 4 files changed, 857 insertions(+), 690 deletions(-) create mode 100644 src/resources/extensions/gsd/auto/session.ts diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index cdb9ddb9c..a01cde834 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -170,41 +170,24 @@ import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from ". // Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts -// ─── State ──────────────────────────────────────────────────────────────────── +// ─── Session State ───────────────────────────────────────────────────────── -let active = false; -let paused = false; -let stepMode = false; -let verbose = false; -let cmdCtx: ExtensionCommandContext | null = null; -let basePath = ""; -let originalBasePath = ""; -let gitService: GitServiceImpl | null = null; +import { + AutoSession, + MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES, + MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH, +} from "./auto/session.js"; +import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerificationRetry } from "./auto/session.js"; +export { + MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES, + MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH, +} from "./auto/session.js"; +export type { CompletedUnit, CurrentUnit, UnitRouting, StartModel } from "./auto/session.js"; -/** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */ -const unitDispatchCount = new Map(); -const MAX_UNIT_DISPATCHES = 3; -/** Retry index at which a stub summary placeholder is written when the summary is still absent. */ -const STUB_RECOVERY_THRESHOLD = 2; -/** Hard cap on total dispatches per unit across ALL reconciliation cycles. - * unitDispatchCount can be reset by loop-recovery/self-repair paths, but this - * counter is never reset — it catches infinite reconciliation loops where - * artifacts exist but deriveState keeps returning the same unit. */ -const unitLifetimeDispatches = new Map(); -const MAX_LIFETIME_DISPATCHES = 6; +const s = new AutoSession(); -/** Tracks recovery attempt count per unit for backoff and diagnostics. */ -const unitRecoveryCount = new Map(); - -/** Track consecutive skips per unit — catches infinite skip loops where deriveState - * keeps returning the same already-completed unit. Reset on any real dispatch. */ -const unitConsecutiveSkips = new Map(); -const MAX_CONSECUTIVE_SKIPS = 3; - -/** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */ -const completedKeySet = new Set(); - -let resourceVersionOnStart: string | null = null; +/** Throttle STATE.md rebuilds — at most once per 30 seconds */ +const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; export function shouldUseWorktreeIsolation(): boolean { const prefs = loadEffectiveGSDPreferences()?.preferences?.git; @@ -214,26 +197,18 @@ export function shouldUseWorktreeIsolation(): boolean { } /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */ -let pendingCrashRecovery: string | null = null; /** Pending verification retry — set when gate fails with retries remaining, consumed by dispatchNextUnit */ -let pendingVerificationRetry: { unitId: string; failureContext: string; attempt: number } | null = null; -/** Verification retry count per unitId — separate from unitDispatchCount which tracks artifact-missing retries */ -const verificationRetryCount = new Map(); + +/** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */ /** Session file path captured at pause — used to synthesize recovery briefing on resume */ -let pausedSessionFile: string | null = null; /** Dashboard tracking */ -let autoStartTime: number = 0; -let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = []; -let currentUnit: { type: string; id: string; startedAt: number } | null = null; /** Track dynamic routing decision for the current unit (for metrics) */ -let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null; /** Queue of quick-task captures awaiting dispatch after triage resolution */ -let pendingQuickTasks: import("./captures.js").CaptureEntry[] = []; /** * Model captured at auto-mode start. Used to prevent model bleed between @@ -242,41 +217,24 @@ let pendingQuickTasks: import("./captures.js").CaptureEntry[] = []; * the session's original model is re-applied instead of reading from * the shared global settings (which another instance may have overwritten). */ -let autoModeStartModel: { provider: string; id: string } | null = null; /** Track current milestone to detect transitions */ -let currentMilestoneId: string | null = null; -let lastBudgetAlertLevel: BudgetAlertLevel = 0; - -/** Throttle STATE.md rebuilds — at most once per 30 seconds */ -let lastStateRebuildAt = 0; -const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; /** Model the user had selected before auto-mode started */ -let originalModelId: string | null = null; -let originalModelProvider: string | null = null; /** Progress-aware timeout supervision */ -let unitTimeoutHandle: ReturnType | null = null; -let wrapupWarningHandle: ReturnType | null = null; -let idleWatchdogHandle: ReturnType | null = null; + /** Context-pressure continue-here monitor — fires once when context usage >= 70% */ -let continueHereHandle: ReturnType | null = null; /** Dispatch gap watchdog — detects when the state machine stalls between units. * After handleAgentEnd completes, if auto-mode is still active but no new unit * has been dispatched (sendMessage not called), this timer fires to force a * re-evaluation. Covers the case where dispatchNextUnit silently fails or * an unhandled error kills the dispatch chain. */ -let dispatchGapHandle: ReturnType | null = null; -const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds /** Prompt character measurement for token savings analysis (R051). */ -let lastPromptCharCount: number | undefined; -let lastBaselineCharCount: number | undefined; /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */ -let _sigtermHandler: (() => void) | null = null; /** * Tool calls currently being executed — prevents false idle detection during long-running tools. @@ -288,13 +246,13 @@ export { getBudgetAlertLevel, getNewBudgetAlertLevel, getBudgetEnforcementAction /** Wrapper: register SIGTERM handler and store reference. */ function registerSigtermHandler(currentBasePath: string): void { - _sigtermHandler = _registerSigtermHandler(currentBasePath, _sigtermHandler); + s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler); } /** Wrapper: deregister SIGTERM handler and clear reference. */ function deregisterSigtermHandler(): void { - _deregisterSigtermHandler(_sigtermHandler); - _sigtermHandler = null; + _deregisterSigtermHandler(s.sigtermHandler); + s.sigtermHandler = null; } export { type AutoDashboardData } from "./auto-dashboard.js"; @@ -305,21 +263,18 @@ export function getAutoDashboardData(): AutoDashboardData { // Pending capture count — lazy check, non-fatal let pendingCaptureCount = 0; try { - if (basePath) { - pendingCaptureCount = countPendingCaptures(basePath); + if (s.basePath) { + pendingCaptureCount = countPendingCaptures(s.basePath); } } catch { // Non-fatal — captures module may not be loaded } - return { - active, - paused, - stepMode, - startTime: autoStartTime, - elapsed: (active || paused) ? Date.now() - autoStartTime : 0, - currentUnit: currentUnit ? { ...currentUnit } : null, - completedUnits: [...completedUnits], - basePath, + return { active: s.active, paused: s.paused, + stepMode: s.stepMode, + startTime: s.autoStartTime, + elapsed: (s.active || s.paused) ? Date.now() - s.autoStartTime : 0, + currentUnit: s.currentUnit ? { ...s.currentUnit } : null, + completedUnits: [...s.completedUnits], basePath: s.basePath, totalCost: totals?.cost ?? 0, totalTokens: totals?.tokens.total ?? 0, pendingCaptureCount, @@ -329,16 +284,16 @@ export function getAutoDashboardData(): AutoDashboardData { // ─── Public API ─────────────────────────────────────────────────────────────── export function isAutoActive(): boolean { - return active; + return s.active; } export function isAutoPaused(): boolean { - return paused; + return s.paused; } // Tool tracking — delegates to auto-tool-tracking.ts export function markToolStart(toolCallId: string): void { - _markToolStart(toolCallId, active); + _markToolStart(toolCallId, s.active); } export function markToolEnd(toolCallId: string): void { @@ -355,7 +310,7 @@ export function getOldestInFlightToolAgeMs(): number { * a second terminal can discover and stop a running auto-mode session. */ function lockBase(): string { - return originalBasePath || basePath; + return s.originalBasePath || s.basePath; } /** @@ -385,44 +340,44 @@ export function stopAutoRemote(projectRoot: string): { found: boolean; pid?: num } export function isStepMode(): boolean { - return stepMode; + return s.stepMode; } function clearUnitTimeout(): void { - if (unitTimeoutHandle) { - clearTimeout(unitTimeoutHandle); - unitTimeoutHandle = null; + if (s.unitTimeoutHandle) { + clearTimeout(s.unitTimeoutHandle); + s.unitTimeoutHandle = null; } - if (wrapupWarningHandle) { - clearTimeout(wrapupWarningHandle); - wrapupWarningHandle = null; + if (s.wrapupWarningHandle) { + clearTimeout(s.wrapupWarningHandle); + s.wrapupWarningHandle = null; } - if (idleWatchdogHandle) { - clearInterval(idleWatchdogHandle); - idleWatchdogHandle = null; + if (s.idleWatchdogHandle) { + clearInterval(s.idleWatchdogHandle); + s.idleWatchdogHandle = null; } - if (continueHereHandle) { - clearInterval(continueHereHandle); - continueHereHandle = null; + if (s.continueHereHandle) { + clearInterval(s.continueHereHandle); + s.continueHereHandle = null; } clearInFlightTools(); clearDispatchGapWatchdog(); } function clearDispatchGapWatchdog(): void { - if (dispatchGapHandle) { - clearTimeout(dispatchGapHandle); - dispatchGapHandle = null; + if (s.dispatchGapHandle) { + clearTimeout(s.dispatchGapHandle); + s.dispatchGapHandle = null; } } /** Build snapshot metric opts, enriching with continueHereFired from the runtime record. */ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number } & Record { - const runtime = currentUnit ? readUnitRuntimeRecord(basePath, unitType, unitId) : null; + const runtime = s.currentUnit ? readUnitRuntimeRecord(s.basePath, unitType, unitId) : null; return { - promptCharCount: lastPromptCharCount, - baselineCharCount: lastBaselineCharCount, - ...(currentUnitRouting ?? {}), + promptCharCount: s.lastPromptCharCount, + baselineCharCount: s.lastBaselineCharCount, + ...(s.currentUnitRouting ?? {}), ...(runtime?.continueHereFired ? { continueHereFired: true } : {}), }; } @@ -430,20 +385,20 @@ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFire /** * Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS * after handleAgentEnd completes. This catches the case where the dispatch chain silently - * breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left active but idle. + * breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left s.active but idle. * * The watchdog is cleared on the next successful unit dispatch (clearUnitTimeout is called * at the start of handleAgentEnd, which calls clearDispatchGapWatchdog). */ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void { clearDispatchGapWatchdog(); - dispatchGapHandle = setTimeout(async () => { - dispatchGapHandle = null; - if (!active || !cmdCtx) return; + s.dispatchGapHandle = setTimeout(async () => { + s.dispatchGapHandle = null; + if (!s.active || !s.cmdCtx) return; // Auto-mode is active but no unit was dispatched — the state machine stalled. // Re-derive state and attempt a fresh dispatch. - if (verbose) { + if (s.verbose) { ctx.ui.notify( "Dispatch gap detected — re-evaluating state.", "info", @@ -460,37 +415,37 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void // If dispatchNextUnit returned normally but still didn't dispatch a unit // (no sendMessage called → no timeout set), auto-mode is permanently - // stalled. Stop cleanly instead of leaving it active but idle (#537). - if (active && !unitTimeoutHandle && !wrapupWarningHandle) { + // stalled. Stop cleanly instead of leaving it s.active but idle (#537). + if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) { await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry"); } }, DISPATCH_GAP_TIMEOUT_MS); } export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise { - if (!active && !paused) return; + if (!s.active && !s.paused) return; const reasonSuffix = reason ? ` — ${reason}` : ""; clearUnitTimeout(); if (lockBase()) clearLock(lockBase()); clearSkillSnapshot(); resetSkillTelemetry(); - _dispatching = false; - _skipDepth = 0; + s.dispatching = false; + s.skipDepth = 0; // Remove SIGTERM handler registered at auto-mode start deregisterSigtermHandler(); - // ── Auto-worktree: exit worktree and reset basePath on stop ── + // ── Auto-worktree: exit worktree and reset s.basePath on stop ── // Preserve the milestone branch so the next /gsd auto can re-enter // where it left off. The branch is only deleted during milestone // completion (mergeMilestoneToMain) after the work has been squash-merged. - if (currentMilestoneId && isInAutoWorktree(basePath)) { + if (s.currentMilestoneId && isInAutoWorktree(s.basePath)) { try { // Auto-commit any dirty state before leaving so work isn't lost - try { autoCommitCurrentBranch(basePath, "stop", currentMilestoneId); } catch { /* non-fatal */ } - teardownAutoWorktree(originalBasePath, currentMilestoneId, { preserveBranch: true }); - basePath = originalBasePath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch { /* non-fatal */ } + teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true }); + s.basePath = s.originalBasePath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info"); } catch (err) { ctx?.ui.notify( @@ -511,10 +466,10 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason // Always restore cwd to project root on stop (#608). // Even if isInAutoWorktree returned false (e.g., module state was already // cleared by mergeMilestoneToMain), the process cwd may still be inside - // the worktree directory. Force it back to originalBasePath. - if (originalBasePath) { - basePath = originalBasePath; - try { process.chdir(basePath); } catch { /* best-effort */ } + // the worktree directory. Force it back to s.originalBasePath. + if (s.originalBasePath) { + s.basePath = s.originalBasePath; + try { process.chdir(s.basePath); } catch { /* best-effort */ } } const ledger = getLedger(); @@ -529,8 +484,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason } // Sync disk state so next resume starts from accurate state - if (basePath) { - try { await rebuildState(basePath); } catch { /* non-fatal */ } + if (s.basePath) { + try { await rebuildState(s.basePath); } catch { /* non-fatal */ } } // Write debug summary before resetting state @@ -544,45 +499,45 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason resetMetrics(); resetRoutingHistory(); resetHookState(); - if (basePath) clearPersistedHookState(basePath); - active = false; - paused = false; - stepMode = false; - unitDispatchCount.clear(); - unitRecoveryCount.clear(); - unitConsecutiveSkips.clear(); + if (s.basePath) clearPersistedHookState(s.basePath); + s.active = false; + s.paused = false; + s.stepMode = false; + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.unitConsecutiveSkips.clear(); clearInFlightTools(); - lastBudgetAlertLevel = 0; - lastStateRebuildAt = 0; - unitLifetimeDispatches.clear(); - currentUnit = null; - autoModeStartModel = null; - currentMilestoneId = null; - originalBasePath = ""; - completedUnits = []; - pendingQuickTasks = []; + s.lastBudgetAlertLevel = 0; + s.lastStateRebuildAt = 0; + s.unitLifetimeDispatches.clear(); + s.currentUnit = null; + s.autoModeStartModel = null; + s.currentMilestoneId = null; + s.originalBasePath = ""; + s.completedUnits = []; + s.pendingQuickTasks = []; clearSliceProgressCache(); clearActivityLogState(); resetProactiveHealing(); - recentlyEvictedKeys.clear(); - pendingCrashRecovery = null; - pendingVerificationRetry = null; - verificationRetryCount.clear(); - pausedSessionFile = null; - _handlingAgentEnd = false; + s.recentlyEvictedKeys.clear(); + s.pendingCrashRecovery = null; + s.pendingVerificationRetry = null; + s.verificationRetryCount.clear(); + s.pausedSessionFile = null; + s.handlingAgentEnd = false; ctx?.ui.setStatus("gsd-auto", undefined); ctx?.ui.setWidget("gsd-progress", undefined); ctx?.ui.setFooter(undefined); // Restore the user's original model - if (pi && ctx && originalModelId && originalModelProvider) { - const original = ctx.modelRegistry.find(originalModelProvider, originalModelId); + if (pi && ctx && s.originalModelId && s.originalModelProvider) { + const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId); if (original) await pi.setModel(original); - originalModelId = null; - originalModelProvider = null; + s.originalModelId = null; + s.originalModelProvider = null; } - cmdCtx = null; + s.cmdCtx = null; } /** @@ -591,31 +546,31 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason * from disk state. Called when the user presses Escape during auto-mode. */ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise { - if (!active) return; + if (!s.active) return; clearUnitTimeout(); // Capture the current session file before clearing state — used for // recovery briefing on resume so the next agent knows what already happened. - pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null; + s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null; if (lockBase()) clearLock(lockBase()); // Remove SIGTERM handler registered at auto-mode start deregisterSigtermHandler(); - active = false; - paused = true; - pendingVerificationRetry = null; - verificationRetryCount.clear(); - // Preserve: unitDispatchCount, currentUnit, basePath, verbose, cmdCtx, - // completedUnits, autoStartTime, currentMilestoneId, originalModelId + s.active = false; + s.paused = true; + s.pendingVerificationRetry = null; + s.verificationRetryCount.clear(); + // Preserve: s.unitDispatchCount, s.currentUnit, s.basePath, s.verbose, s.cmdCtx, + // s.completedUnits, s.autoStartTime, s.currentMilestoneId, s.originalModelId // — all needed for resume and dashboard display ctx?.ui.setStatus("gsd-auto", "paused"); ctx?.ui.setWidget("gsd-progress", undefined); ctx?.ui.setFooter(undefined); - const resumeCmd = stepMode ? "/gsd next" : "/gsd auto"; + const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto"; ctx?.ui.notify( - `${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, + `${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, "info", ); } @@ -637,38 +592,38 @@ export async function startAuto( // If resuming from paused state, just re-activate and dispatch next unit. // The conversation is still intact — no need to reinitialize everything. - if (paused) { - paused = false; - active = true; - verbose = verboseMode; + if (s.paused) { + s.paused = false; + s.active = true; + s.verbose = verboseMode; // Allow switching between step/auto on resume - stepMode = requestedStepMode; - cmdCtx = ctx; - basePath = base; - unitDispatchCount.clear(); - unitLifetimeDispatches.clear(); - unitConsecutiveSkips.clear(); + s.stepMode = requestedStepMode; + s.cmdCtx = ctx; + s.basePath = base; + s.unitDispatchCount.clear(); + s.unitLifetimeDispatches.clear(); + s.unitConsecutiveSkips.clear(); // Re-initialize metrics in case ledger was lost during pause if (!getLedger()) initMetrics(base); // Ensure milestone ID is set on git service for integration branch resolution - if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId); + if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId); // ── Auto-worktree: re-enter worktree on resume if not already inside ── // Skip if already inside a worktree (manual /worktree) to prevent nesting. // Skip entirely in branch or none isolation mode (#531). - if (currentMilestoneId && shouldUseWorktreeIsolation() && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) { + if (s.currentMilestoneId && shouldUseWorktreeIsolation() && s.originalBasePath && !isInAutoWorktree(s.basePath) && !detectWorktreeName(s.basePath) && !detectWorktreeName(s.originalBasePath)) { try { - const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId); + const existingWtPath = getAutoWorktreePath(s.originalBasePath, s.currentMilestoneId); if (existingWtPath) { - const wtPath = enterAutoWorktree(originalBasePath, currentMilestoneId); - basePath = wtPath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const wtPath = enterAutoWorktree(s.originalBasePath, s.currentMilestoneId); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info"); } else { // Worktree was deleted while paused — recreate it. - const wtPath = createAutoWorktree(originalBasePath, currentMilestoneId); - basePath = wtPath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info"); } } catch (err) { @@ -682,46 +637,45 @@ export async function startAuto( // Re-register SIGTERM handler for the resumed session (use original base for lock) registerSigtermHandler(lockBase()); - ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); - ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); + ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); // Restore hook state from disk in case session was interrupted - restoreHookState(basePath); + restoreHookState(s.basePath); // Rebuild disk state before resuming — user interaction during pause may have changed files - try { await rebuildState(basePath); } catch { /* non-fatal */ } + try { await rebuildState(s.basePath); } catch { /* non-fatal */ } try { - const report = await runGSDDoctor(basePath, { fix: true }); + const report = await runGSDDoctor(s.basePath, { fix: true }); if (report.fixesApplied.length > 0) { ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info"); } } catch { /* non-fatal */ } // Self-heal: clear stale runtime records where artifacts already exist - await selfHealRuntimeRecords(basePath, ctx, completedKeySet); + await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); invalidateAllCaches(); // Synthesize recovery briefing from the paused session so the next agent // knows what already happened (reuses crash recovery infrastructure). - if (pausedSessionFile) { - const activityDir = join(gsdRoot(basePath), "activity"); + if (s.pausedSessionFile) { + const activityDir = join(gsdRoot(s.basePath), "activity"); const recovery = synthesizeCrashRecovery( - basePath, - currentUnit?.type ?? "unknown", - currentUnit?.id ?? "unknown", - pausedSessionFile, + s.basePath, + s.currentUnit?.type ?? "unknown", + s.currentUnit?.id ?? "unknown", s.pausedSessionFile ?? undefined, activityDir, ); if (recovery && recovery.trace.toolCallCount > 0) { - pendingCrashRecovery = recovery.prompt; + s.pendingCrashRecovery = recovery.prompt; ctx.ui.notify( `Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, "info", ); } - pausedSessionFile = null; + s.pausedSessionFile = null; } // Write lock on resume so cross-process status detection works (#723). - writeLock(lockBase(), "resuming", currentMilestoneId ?? "unknown", completedUnits.length); + writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length); await dispatchNextUnit(ctx, pi); return; @@ -751,8 +705,8 @@ export async function startAuto( } } - // Initialize GitServiceImpl — basePath is set and git repo confirmed - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + // Initialize GitServiceImpl — s.basePath is set and git repo confirmed + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); // Check for crash from previous session const crashLock = readCrashLock(base); @@ -786,7 +740,7 @@ export async function startAuto( crashLock.sessionFile, activityDir, ); if (recovery && recovery.trace.toolCallCount > 0) { - pendingCrashRecovery = recovery.prompt; + s.pendingCrashRecovery = recovery.prompt; ctx.ui.notify( `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`, "warning", @@ -961,29 +915,29 @@ export async function startAuto( return; } - active = true; - stepMode = requestedStepMode; - verbose = verboseMode; - cmdCtx = ctx; - basePath = base; - unitDispatchCount.clear(); - unitRecoveryCount.clear(); - unitConsecutiveSkips.clear(); - lastBudgetAlertLevel = 0; - unitLifetimeDispatches.clear(); - completedKeySet.clear(); - loadPersistedKeys(base, completedKeySet); + s.active = true; + s.stepMode = requestedStepMode; + s.verbose = verboseMode; + s.cmdCtx = ctx; + s.basePath = base; + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.unitConsecutiveSkips.clear(); + s.lastBudgetAlertLevel = 0; + s.unitLifetimeDispatches.clear(); + s.completedKeySet.clear(); + loadPersistedKeys(base, s.completedKeySet); resetHookState(); restoreHookState(base); resetProactiveHealing(); - autoStartTime = Date.now(); - resourceVersionOnStart = readResourceVersion(); - completedUnits = []; - pendingQuickTasks = []; - currentUnit = null; - currentMilestoneId = state.activeMilestone?.id ?? null; - originalModelId = ctx.model?.id ?? null; - originalModelProvider = ctx.model?.provider ?? null; + s.autoStartTime = Date.now(); + s.resourceVersionOnStart = readResourceVersion(); + s.completedUnits = []; + s.pendingQuickTasks = []; + s.currentUnit = null; + s.currentMilestoneId = state.activeMilestone?.id ?? null; + s.originalModelId = ctx.model?.id ?? null; + s.originalModelProvider = ctx.model?.provider ?? null; // Register a SIGTERM handler so `kill ` cleans up the lock and exits. registerSigtermHandler(base); @@ -992,18 +946,18 @@ export async function startAuto( // auto-mode started. Slice branches will merge back to this branch instead // of the repo's default (main/master). Idempotent when the branch is the // same; updates the record when started from a different branch (#300). - if (currentMilestoneId) { + if (s.currentMilestoneId) { if (getIsolationMode() !== "none") { - captureIntegrationBranch(base, currentMilestoneId, { commitDocs }); + captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs }); } - setActiveMilestoneId(base, currentMilestoneId); + setActiveMilestoneId(base, s.currentMilestoneId); } // ── Auto-worktree: create or enter worktree for the active milestone ── // Store the original project root before any chdir so we can restore on stop. // Skip if already inside a worktree (manual /worktree or another auto-worktree) // to prevent nested worktree creation. - originalBasePath = base; + s.originalBasePath = base; const isUnderGsdWorktrees = (p: string): boolean => { // Prevent creating nested auto-worktrees when running from within any @@ -1016,30 +970,30 @@ export async function startAuto( return p.endsWith(worktreesSuffix); }; - if (currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) { + if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) { try { - const existingWtPath = getAutoWorktreePath(base, currentMilestoneId); + const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId); if (existingWtPath) { // Worktree already exists (e.g., previous session created it) — enter it. - const wtPath = enterAutoWorktree(base, currentMilestoneId); - basePath = wtPath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const wtPath = enterAutoWorktree(base, s.currentMilestoneId); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info"); } else { // Fresh start — create worktree and enter it. - const wtPath = createAutoWorktree(base, currentMilestoneId); - basePath = wtPath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const wtPath = createAutoWorktree(base, s.currentMilestoneId); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info"); } - // Re-register SIGTERM handler with the original basePath (lock lives there) - registerSigtermHandler(originalBasePath); + // Re-register SIGTERM handler with the original s.basePath (lock lives there) + registerSigtermHandler(s.originalBasePath); // After worktree entry, load completed keys from BOTH locations (project root // + worktree) so the in-memory set is the union. Prevents re-dispatch of units // completed in either location after crash/restart (#769). - if (basePath !== originalBasePath) { - loadPersistedKeys(basePath, completedKeySet); + if (s.basePath !== s.originalBasePath) { + loadPersistedKeys(s.basePath, s.completedKeySet); } } catch (err) { // Worktree creation is non-fatal — continue in the project root. @@ -1051,8 +1005,8 @@ export async function startAuto( } // ── DB lifecycle: auto-migrate or open existing database ── - const gsdDbPath = join(basePath, ".gsd", "gsd.db"); - const gsdDirPath = join(basePath, ".gsd"); + const gsdDbPath = join(s.basePath, ".gsd", "gsd.db"); + const gsdDirPath = join(s.basePath, ".gsd"); if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) { const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md")); const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md")); @@ -1062,7 +1016,7 @@ export async function startAuto( const { openDatabase: openDb } = await import("./gsd-db.js"); const { migrateFromMarkdown } = await import("./md-importer.js"); openDb(gsdDbPath); - migrateFromMarkdown(basePath); + migrateFromMarkdown(s.basePath); } catch (err) { process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`); } @@ -1078,18 +1032,18 @@ export async function startAuto( } // Initialize metrics — loads existing ledger from disk. - // Use basePath (not base) so worktree-mode reads the worktree ledger (#769). - initMetrics(basePath); + // Use s.basePath (not base) so worktree-mode reads the worktree ledger (#769). + initMetrics(s.basePath); // Initialize routing history for adaptive learning - initRoutingHistory(basePath); + initRoutingHistory(s.basePath); // Capture the session's current model at auto-mode start (#650). // This prevents model bleed when multiple GSD instances share the // same global settings.json — each instance remembers its own model. const currentModel = ctx.model; if (currentModel) { - autoModeStartModel = { provider: currentModel.provider, id: currentModel.id }; + s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id }; } // Snapshot installed skills so we can detect new ones after research @@ -1097,9 +1051,9 @@ export async function startAuto( snapshotSkills(); } - ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); - const modeLabel = stepMode ? "Step-mode" : "Auto-mode"; + const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode"; const pendingCount = state.registry.filter(m => m.status !== 'complete').length; const scopeMsg = pendingCount > 1 ? `Will loop through ${pendingCount} milestones.` @@ -1109,7 +1063,7 @@ export async function startAuto( // Write initial lock file immediately so cross-process status detection // works even before the first unit is dispatched (#723). // The lock is updated with unit-specific info on each dispatch and cleared on stop. - writeLock(lockBase(), "starting", currentMilestoneId ?? "unknown", 0); + writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); // Secrets collection gate — collect pending secrets before first dispatch const mid = state.activeMilestone!.id; @@ -1134,9 +1088,9 @@ export async function startAuto( } // Self-heal: clear stale runtime records where artifacts already exist. - // Use basePath (not base) — in worktree mode, basePath points to the worktree + // Use s.basePath (not base) — in worktree mode, s.basePath points to the worktree // where runtime records and artifacts actually live (#769). - await selfHealRuntimeRecords(basePath, ctx, completedKeySet); + await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); // Self-heal: remove stale .git/index.lock from prior crash. // A stale lock file blocks all git operations (commit, merge, checkout). @@ -1187,15 +1141,14 @@ export async function startAuto( * await). Without this guard, concurrent dispatchNextUnit calls race on * newSession(), causing one to cancel the other and silently stopping * auto-mode. */ -let _handlingAgentEnd = false; export async function handleAgentEnd( ctx: ExtensionContext, pi: ExtensionAPI, ): Promise { - if (!active || !cmdCtx) return; - if (_handlingAgentEnd) return; - _handlingAgentEnd = true; + if (!s.active || !s.cmdCtx) return; + if (s.handlingAgentEnd) return; + s.handlingAgentEnd = true; try { @@ -1207,15 +1160,15 @@ export async function handleAgentEnd( // coordinator signals before dispatching the next unit. const milestoneLock = process.env.GSD_MILESTONE_LOCK; if (milestoneLock) { - const signal = consumeSignal(basePath, milestoneLock); + const signal = consumeSignal(s.basePath, milestoneLock); if (signal) { if (signal.signal === "stop") { - _handlingAgentEnd = false; + s.handlingAgentEnd = false; await stopAuto(ctx, pi); return; } if (signal.signal === "pause") { - _handlingAgentEnd = false; + s.handlingAgentEnd = false; await pauseAuto(ctx, pi); return; } @@ -1234,15 +1187,15 @@ export async function handleAgentEnd( // For execute-task units, build a meaningful commit message from the // task summary (one-liner, key_files, inferred type). For other unit // types, fall back to the generic chore() message. - if (currentUnit) { + if (s.currentUnit) { try { let taskContext: TaskCommitContext | undefined; - if (currentUnit.type === "execute-task") { - const parts = currentUnit.id.split("/"); + if (s.currentUnit.type === "execute-task") { + const parts = s.currentUnit.id.split("/"); const [mid, sid, tid] = parts; if (mid && sid && tid) { - const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); + const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); if (summaryPath) { try { const summaryContent = await loadFile(summaryPath); @@ -1262,7 +1215,7 @@ export async function handleAgentEnd( } } - const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id, taskContext); + const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext); if (commitMsg) { ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); } @@ -1279,10 +1232,10 @@ export async function handleAgentEnd( // Exception: after complete-slice itself, use fixLevel:"all" so roadmap // checkboxes get fixed even if complete-slice crashed (#839). try { - const scopeParts = currentUnit.id.split("/").slice(0, 2); + const scopeParts = s.currentUnit.id.split("/").slice(0, 2); const doctorScope = scopeParts.join("/"); - const effectiveFixLevel = currentUnit.type === "complete-slice" ? "all" as const : "task" as const; - const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); + const effectiveFixLevel = s.currentUnit.type === "complete-slice" ? "all" as const : "task" as const; + const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); if (report.fixesApplied.length > 0) { ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info"); } @@ -1322,12 +1275,12 @@ export async function handleAgentEnd( // STATE.md is a derived diagnostic artifact — skipping a rebuild is safe; // the next unit or stop/pause will rebuild it. const now = Date.now(); - if (now - lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) { + if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) { try { - await rebuildState(basePath); - lastStateRebuildAt = now; + await rebuildState(s.basePath); + s.lastStateRebuildAt = now; // State rebuild commit is bookkeeping — generic message is appropriate - autoCommitCurrentBranch(basePath, "state-rebuild", currentUnit.id); + autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id); } catch { // Non-fatal } @@ -1346,18 +1299,18 @@ export async function handleAgentEnd( // ── Sync worktree state back to project root ────────────────────────── // Ensures that if auto-mode restarts, deriveState(projectRoot) reads // current milestone progress instead of stale pre-worktree state (#654). - if (originalBasePath && originalBasePath !== basePath) { + if (s.originalBasePath && s.originalBasePath !== s.basePath) { try { - syncStateToProjectRoot(basePath, originalBasePath, currentMilestoneId); + syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId); } catch { // Non-fatal — stale state is the existing behavior, sync is an improvement } } // ── Rewrite-docs completion: resolve overrides and reset circuit breaker ── - if (currentUnit.type === "rewrite-docs") { + if (s.currentUnit.type === "rewrite-docs") { try { - await resolveAllOverrides(basePath); + await resolveAllOverrides(s.basePath); resetRewriteCircuitBreaker(); ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); } catch { @@ -1369,15 +1322,15 @@ export async function handleAgentEnd( // After a triage-captures unit completes, the LLM has classified captures and // updated CAPTURES.md. Now we execute those classifications: inject tasks into // the plan, write replan triggers, and queue quick-tasks for dispatch. - if (currentUnit.type === "triage-captures") { + if (s.currentUnit.type === "triage-captures") { try { const { executeTriageResolutions } = await import("./triage-resolution.js"); - const state = await deriveState(basePath); + const state = await deriveState(s.basePath); const mid = state.activeMilestone?.id; const sid = state.activeSlice?.id; if (mid && sid) { - const triageResult = executeTriageResolutions(basePath, mid, sid); + const triageResult = executeTriageResolutions(s.basePath, mid, sid); if (triageResult.injected > 0) { ctx.ui.notify( @@ -1395,7 +1348,7 @@ export async function handleAgentEnd( // Queue quick-tasks for dispatch. They'll be picked up by the // quick-task dispatch block below the triage check. for (const qt of triageResult.quickTasks) { - pendingQuickTasks.push(qt); + s.pendingQuickTasks.push(qt); } ctx.ui.notify( `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, @@ -1416,20 +1369,20 @@ export async function handleAgentEnd( // After doctor + rebuildState, check whether the just-completed unit actually // produced its expected artifact. If so, persist the completion key now so the // idempotency check at the top of dispatchNextUnit() skips it — even if - // deriveState() still returns this unit as active (e.g. branch mismatch). + // deriveState() still returns this unit as s.active (e.g. branch mismatch). // // IMPORTANT: For non-hook units, defer persistence until after the hook check. // If a post-unit hook requests a retry, we need to remove the completion key // so dispatchNextUnit re-dispatches the trigger unit. let triggerArtifactVerified = false; - if (!currentUnit.type.startsWith("hook/")) { + if (!s.currentUnit.type.startsWith("hook/")) { try { - triggerArtifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath); + triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); if (triggerArtifactVerified) { - const completionKey = `${currentUnit.type}/${currentUnit.id}`; - if (!completedKeySet.has(completionKey)) { - persistCompletedKey(basePath, completionKey); - completedKeySet.add(completionKey); + const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; + if (!s.completedKeySet.has(completionKey)) { + persistCompletedKey(s.basePath, completionKey); + s.completedKeySet.add(completionKey); } invalidateAllCaches(); } @@ -1439,12 +1392,12 @@ export async function handleAgentEnd( } else { // Hook unit completed — finalize its runtime record and clear it try { - writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, { + writeUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, { phase: "finalized", progressCount: 1, lastProgressKind: "hook-completed", }); - clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id); + clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id); } catch { // Non-fatal } @@ -1452,18 +1405,18 @@ export async function handleAgentEnd( } // ── Verification gate: run typecheck/lint/test after execute-task ── - if (currentUnit && currentUnit.type === "execute-task") { + if (s.currentUnit && s.currentUnit.type === "execute-task") { try { const effectivePrefs = loadEffectiveGSDPreferences(); const prefs = effectivePrefs?.preferences; // Read task plan verify field from the current task's slice plan // unitId format is "M001/S01/T03" — extract mid, sid, tid - const parts = currentUnit.id.split("/"); + const parts = s.currentUnit.id.split("/"); let taskPlanVerify: string | undefined; if (parts.length >= 3) { const [mid, sid, tid] = parts; - const planFile = resolveSliceFile(basePath, mid, sid, "PLAN"); + const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); if (planFile) { const planContent = await loadFile(planFile); if (planContent) { @@ -1474,10 +1427,9 @@ export async function handleAgentEnd( } } - const result = runVerificationGate({ - basePath, - unitId: currentUnit.id, - cwd: basePath, + const result = runVerificationGate({ basePath: s.basePath, + unitId: s.currentUnit.id, + cwd: s.basePath, preferenceCommands: prefs?.verification_commands, taskPlanVerify, }); @@ -1493,7 +1445,7 @@ export async function handleAgentEnd( } // Conditional dependency audit (R008) - const auditWarnings = runDependencyAudit(basePath); + const auditWarnings = runDependencyAudit(s.basePath); if (auditWarnings.length > 0) { result.auditWarnings = auditWarnings; process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`); @@ -1505,7 +1457,7 @@ export async function handleAgentEnd( // Auto-fix retry preferences (R005 / D005) const autoFixEnabled = prefs?.verification_auto_fix !== false; // default true const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2; - const completionKey = `${currentUnit.type}/${currentUnit.id}`; + const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; if (result.checks.length > 0) { const passCount = result.checks.filter(c => c.exitCode === 0).length; @@ -1534,18 +1486,18 @@ export async function handleAgentEnd( } // Write verification evidence JSON artifact - const attempt = verificationRetryCount.get(currentUnit.id) ?? 0; + const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; if (parts.length >= 3) { try { const [mid, sid, tid] = parts; - const sDir = resolveSlicePath(basePath, mid, sid); + const sDir = resolveSlicePath(s.basePath, mid, sid); if (sDir) { const tasksDir = join(sDir, "tasks"); if (result.passed) { - writeVerificationJSON(result, tasksDir, tid, currentUnit.id); + writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id); } else { const nextAttempt = attempt + 1; - writeVerificationJSON(result, tasksDir, tid, currentUnit.id, nextAttempt, maxRetries); + writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries); } } } catch (evidenceErr) { @@ -1556,27 +1508,27 @@ export async function handleAgentEnd( // ── Auto-fix retry logic ── if (result.passed) { // Gate passed — clear retry state and continue normal flow - verificationRetryCount.delete(currentUnit.id); - pendingVerificationRetry = null; + s.verificationRetryCount.delete(s.currentUnit.id); + s.pendingVerificationRetry = null; } else if (autoFixEnabled && attempt + 1 <= maxRetries) { // Gate failed, retries remaining — set up retry and return early const nextAttempt = attempt + 1; - verificationRetryCount.set(currentUnit.id, nextAttempt); - pendingVerificationRetry = { - unitId: currentUnit.id, + s.verificationRetryCount.set(s.currentUnit.id, nextAttempt); + s.pendingVerificationRetry = { + unitId: s.currentUnit.id, failureContext: formatFailureContext(result), attempt: nextAttempt, }; ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning"); // Remove completion key so dispatchNextUnit re-dispatches this unit - completedKeySet.delete(completionKey); - removePersistedKey(basePath, completionKey); + s.completedKeySet.delete(completionKey); + removePersistedKey(s.basePath, completionKey); return; // ← Critical: exit before DB dual-write and post-unit hooks } else { // Gate failed, retries exhausted (or auto-fix disabled) — pause for human review const exhaustedAttempt = attempt + 1; - verificationRetryCount.delete(currentUnit.id); - pendingVerificationRetry = null; + s.verificationRetryCount.delete(s.currentUnit.id); + s.pendingVerificationRetry = null; ctx.ui.notify( `Verification gate FAILED after ${exhaustedAttempt > maxRetries ? exhaustedAttempt - 1 : exhaustedAttempt} retries — pausing for human review`, "error", @@ -1594,23 +1546,23 @@ export async function handleAgentEnd( if (isDbAvailable()) { try { const { migrateFromMarkdown } = await import("./md-importer.js"); - migrateFromMarkdown(basePath); + migrateFromMarkdown(s.basePath); } catch (err) { process.stderr.write(`gsd-db: re-import failed: ${(err as Error).message}\n`); } } // ── Post-unit hooks: check if a configured hook should run before normal dispatch ── - if (currentUnit && !stepMode) { - const hookUnit = checkPostUnitHooks(currentUnit.type, currentUnit.id, basePath); + if (s.currentUnit && !s.stepMode) { + const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); if (hookUnit) { // Dispatch the hook unit instead of normal flow const hookStartedAt = Date.now(); - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } - currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt }; - writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, { + s.currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt }; + writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, { phase: "dispatched", wrapupWarningSent: false, timeoutAt: null, @@ -1619,7 +1571,7 @@ export async function handleAgentEnd( lastProgressKind: "dispatch", }); - const state = await deriveState(basePath); + const state = await deriveState(s.basePath); updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state); const hookState = getActiveHook(); ctx.ui.notify( @@ -1640,27 +1592,27 @@ export async function handleAgentEnd( } } - const result = await cmdCtx!.newSession(); + const result = await s.cmdCtx!.newSession(); if (result.cancelled) { resetHookState(); await stopAuto(ctx, pi, "Hook session cancelled"); return; } const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile); + writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile); // Persist hook state so cycle counts survive crashes - persistHookState(basePath); + persistHookState(s.basePath); // Start supervision timers for hook units — hooks can get stuck just // like normal units, and without a watchdog auto-mode would hang forever. clearUnitTimeout(); const supervisor = resolveAutoSupervisorConfig(); const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - unitTimeoutHandle = setTimeout(async () => { - unitTimeoutHandle = null; - if (!active) return; - if (currentUnit) { - writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, currentUnit.startedAt, { + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + if (s.currentUnit) { + writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, s.currentUnit.startedAt, { phase: "timeout", timeoutAt: Date.now(), }); @@ -1674,9 +1626,9 @@ export async function handleAgentEnd( }, hookHardTimeoutMs); // Guard against race with timeout/pause before sending - if (!active) return; + if (!s.active) return; pi.sendMessage( - { customType: "gsd-auto", content: hookUnit.prompt, display: verbose }, + { customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose }, { triggerTurn: true }, ); return; // handleAgentEnd will fire again when hook session completes @@ -1689,8 +1641,8 @@ export async function handleAgentEnd( // Remove the trigger unit's completion key so dispatchNextUnit // will re-dispatch it instead of skipping it as already-complete. const triggerKey = `${trigger.unitType}/${trigger.unitId}`; - completedKeySet.delete(triggerKey); - removePersistedKey(basePath, triggerKey); + s.completedKeySet.delete(triggerKey); + removePersistedKey(s.basePath, triggerKey); ctx.ui.notify( `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`, "info", @@ -1707,17 +1659,17 @@ export async function handleAgentEnd( // Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage), // hook units (hooks run before triage conceptually). if ( - !stepMode && - currentUnit && - !currentUnit.type.startsWith("hook/") && - currentUnit.type !== "triage-captures" && - currentUnit.type !== "quick-task" + !s.stepMode && + s.currentUnit && + !s.currentUnit.type.startsWith("hook/") && + s.currentUnit.type !== "triage-captures" && + s.currentUnit.type !== "quick-task" ) { try { - if (hasPendingCaptures(basePath)) { - const pending = loadPendingCaptures(basePath); + if (hasPendingCaptures(s.basePath)) { + const pending = loadPendingCaptures(s.basePath); if (pending.length > 0) { - const state = await deriveState(basePath); + const state = await deriveState(s.basePath); const mid = state.activeMilestone?.id; const sid = state.activeSlice?.id; @@ -1725,9 +1677,9 @@ export async function handleAgentEnd( // Build triage prompt with current context let currentPlan = ""; let roadmapContext = ""; - const planFile = resolveSliceFile(basePath, mid, sid, "PLAN"); + const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); if (planFile) currentPlan = (await loadFile(planFile)) ?? ""; - const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const roadmapFile = resolveMilestoneFile(s.basePath, mid, "ROADMAP"); if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? ""; const capturesList = pending.map(c => @@ -1746,16 +1698,16 @@ export async function handleAgentEnd( ); // Close out previous unit metrics - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); } // Dispatch triage as a new unit (early-dispatch-and-return) const triageUnitType = "triage-captures"; const triageUnitId = `${mid}/${sid}/triage`; const triageStartedAt = Date.now(); - currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt }; - writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, { + s.currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt }; + writeUnitRuntimeRecord(s.basePath, triageUnitType, triageUnitId, triageStartedAt, { phase: "dispatched", wrapupWarningSent: false, timeoutAt: null, @@ -1765,21 +1717,21 @@ export async function handleAgentEnd( }); updateProgressWidget(ctx, triageUnitType, triageUnitId, state); - const result = await cmdCtx!.newSession(); + const result = await s.cmdCtx!.newSession(); if (result.cancelled) { await stopAuto(ctx, pi); return; } const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), triageUnitType, triageUnitId, completedUnits.length, sessionFile); + writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile); // Start unit timeout for triage (use same supervisor config as hooks) clearUnitTimeout(); const supervisor = resolveAutoSupervisorConfig(); const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - unitTimeoutHandle = setTimeout(async () => { - unitTimeoutHandle = null; - if (!active) return; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; ctx.ui.notify( `Triage unit exceeded timeout. Pausing auto-mode.`, "warning", @@ -1787,9 +1739,9 @@ export async function handleAgentEnd( await pauseAuto(ctx, pi); }, triageTimeoutMs); - if (!active) return; + if (!s.active) return; pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: verbose }, + { customType: "gsd-auto", content: prompt, display: s.verbose }, { triggerTurn: true }, ); return; // handleAgentEnd will fire again when triage session completes @@ -1805,13 +1757,13 @@ export async function handleAgentEnd( // Quick-tasks are self-contained one-off tasks that don't modify the plan. // They're queued during post-triage resolution and dispatched here one at a time. if ( - !stepMode && - pendingQuickTasks.length > 0 && - currentUnit && - currentUnit.type !== "quick-task" + !s.stepMode && + s.pendingQuickTasks.length > 0 && + s.currentUnit && + s.currentUnit.type !== "quick-task" ) { try { - const capture = pendingQuickTasks.shift()!; + const capture = s.pendingQuickTasks.shift()!; const { buildQuickTaskPrompt } = await import("./triage-resolution.js"); const { markCaptureExecuted } = await import("./captures.js"); const prompt = buildQuickTaskPrompt(capture); @@ -1822,16 +1774,16 @@ export async function handleAgentEnd( ); // Close out previous unit metrics - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); } // Dispatch quick-task as a new unit const qtUnitType = "quick-task"; - const qtUnitId = `${currentMilestoneId}/${capture.id}`; + const qtUnitId = `${ s.currentMilestoneId }/${capture.id}`; const qtStartedAt = Date.now(); - currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt }; - writeUnitRuntimeRecord(basePath, qtUnitType, qtUnitId, qtStartedAt, { + s.currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt }; + writeUnitRuntimeRecord(s.basePath, qtUnitType, qtUnitId, qtStartedAt, { phase: "dispatched", wrapupWarningSent: false, timeoutAt: null, @@ -1839,27 +1791,27 @@ export async function handleAgentEnd( progressCount: 0, lastProgressKind: "dispatch", }); - const state = await deriveState(basePath); + const state = await deriveState(s.basePath); updateProgressWidget(ctx, qtUnitType, qtUnitId, state); - const result = await cmdCtx!.newSession(); + const result = await s.cmdCtx!.newSession(); if (result.cancelled) { await stopAuto(ctx, pi); return; } const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), qtUnitType, qtUnitId, completedUnits.length, sessionFile); + writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile); // Mark capture as executed now that the unit is dispatched - markCaptureExecuted(basePath, capture.id); + markCaptureExecuted(s.basePath, capture.id); // Start unit timeout for quick-task clearUnitTimeout(); const supervisor = resolveAutoSupervisorConfig(); const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - unitTimeoutHandle = setTimeout(async () => { - unitTimeoutHandle = null; - if (!active) return; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; ctx.ui.notify( `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`, "warning", @@ -1867,9 +1819,9 @@ export async function handleAgentEnd( await pauseAuto(ctx, pi); }, qtTimeoutMs); - if (!active) return; + if (!s.active) return; pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: verbose }, + { customType: "gsd-auto", content: prompt, display: s.verbose }, { triggerTurn: true }, ); return; // handleAgentEnd will fire again when quick-task session completes @@ -1879,7 +1831,7 @@ export async function handleAgentEnd( } // In step mode, pause and show a wizard instead of immediately dispatching - if (stepMode) { + if (s.stepMode) { await showStepWizard(ctx, pi); return; } @@ -1889,7 +1841,7 @@ export async function handleAgentEnd( } catch (dispatchErr) { // dispatchNextUnit threw — without this catch the error would propagate // to the pi event emitter which may silently swallow async rejections, - // leaving auto-mode active but permanently stalled (see #381). + // leaving auto-mode s.active but permanently stalled (see #381). const message = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr); ctx.ui.notify( `Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`, @@ -1902,15 +1854,15 @@ export async function handleAgentEnd( return; } - // If dispatchNextUnit returned normally but auto-mode is still active and + // If dispatchNextUnit returned normally but auto-mode is still s.active and // no new unit timeout was set (meaning sendMessage was never called), start // the dispatch gap watchdog as a safety net. - if (active && !unitTimeoutHandle && !wrapupWarningHandle) { + if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) { startDispatchGapWatchdog(ctx, pi); } } finally { - _handlingAgentEnd = false; + s.handlingAgentEnd = false; } } @@ -1925,14 +1877,14 @@ async function showStepWizard( ctx: ExtensionContext, pi: ExtensionAPI, ): Promise { - if (!cmdCtx) return; + if (!s.cmdCtx) return; - const state = await deriveState(basePath); + const state = await deriveState(s.basePath); const mid = state.activeMilestone?.id; // Build summary of what just completed - const justFinished = currentUnit - ? `${unitVerb(currentUnit.type)} ${currentUnit.id}` + const justFinished = s.currentUnit + ? `${unitVerb(s.currentUnit.type)} ${s.currentUnit.id}` : "previous unit"; // If no active milestone or everything is complete, stop @@ -1940,7 +1892,7 @@ async function showStepWizard( const incomplete = state.registry.filter(m => m.status !== "complete"); if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") { const ids = incomplete.map(m => m.id).join(", "); - const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; + const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error"); await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`); } else { @@ -1952,7 +1904,7 @@ async function showStepWizard( // Peek at what's next by examining state const nextDesc = _describeNextUnit(state); - const choice = await showNextAction(cmdCtx, { + const choice = await showNextAction(s.cmdCtx, { title: `GSD — ${justFinished} complete`, summary: [ `${mid}: ${state.activeMilestone?.title ?? mid}`, @@ -1982,7 +1934,7 @@ async function showStepWizard( if (choice === "continue") { await dispatchNextUnit(ctx, pi); } else if (choice === "auto") { - stepMode = false; + s.stepMode = false; ctx.ui.setStatus("gsd-auto", "auto"); ctx.ui.notify("Switched to auto-mode.", "info"); await dispatchNextUnit(ctx, pi); @@ -2007,61 +1959,57 @@ function updateProgressWidget( unitId: string, state: GSDState, ): void { - const badge = currentUnitRouting?.tier - ? ({ light: "L", standard: "S", heavy: "H" }[currentUnitRouting.tier] ?? undefined) + const badge = s.currentUnitRouting?.tier + ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? undefined) : undefined; _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge); } /** State accessors for the widget — closures over module globals. */ const widgetStateAccessors: WidgetStateAccessors = { - getAutoStartTime: () => autoStartTime, - isStepMode: () => stepMode, - getCmdCtx: () => cmdCtx, - getBasePath: () => basePath, - isVerbose: () => verbose, + getAutoStartTime: () => s.autoStartTime, + isStepMode: () => s.stepMode, + getCmdCtx: () => s.cmdCtx, + getBasePath: () => s.basePath, + isVerbose: () => s.verbose, }; // ─── Core Loop ──────────────────────────────────────────────────────────────── /** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */ -let _skipDepth = 0; -const MAX_SKIP_DEPTH = 20; /** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd). * Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls - * that bypass the _handlingAgentEnd guard. Recursive calls (from skip paths) are - * allowed via _skipDepth > 0. */ -let _dispatching = false; + * that bypass the s.handlingAgentEnd guard. Recursive calls (from skip paths) are + * allowed via s.skipDepth > 0. */ /** Keys recently evicted by skip-loop breaker — prevents re-persistence in the fallback path (#912). */ -const recentlyEvictedKeys = new Set(); async function dispatchNextUnit( ctx: ExtensionContext, pi: ExtensionAPI, ): Promise { - if (!active || !cmdCtx) { - debugLog(`dispatchNextUnit early return — active=${active}, cmdCtx=${!!cmdCtx}`); - if (active && !cmdCtx) { + if (!s.active || !s.cmdCtx) { + debugLog(`dispatchNextUnit early return — active=${s.active}, cmdCtx=${!!s.cmdCtx}`); + if (s.active && !s.cmdCtx) { ctx.ui.notify("Auto-mode session expired. Run /gsd auto to restart.", "info"); } return; } - // Reentrancy guard: allow recursive calls from skip paths (_skipDepth > 0) + // Reentrancy guard: allow recursive calls from skip paths (s.skipDepth > 0) // but block concurrent external calls (watchdog, step wizard, etc.) - if (_dispatching && _skipDepth === 0) { + if (s.dispatching && s.skipDepth === 0) { debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing"); return; // Another dispatch is in progress — bail silently } - _dispatching = true; + s.dispatching = true; try { // Recursion depth guard: when many units are skipped in sequence (e.g., after // crash recovery with 10+ completed units), recursive dispatchNextUnit calls // can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH. - if (_skipDepth > MAX_SKIP_DEPTH) { - _skipDepth = 0; + if (s.skipDepth > MAX_SKIP_DEPTH) { + s.skipDepth = 0; ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info"); await new Promise(r => setTimeout(r, 200)); } @@ -2071,7 +2019,7 @@ async function dispatchNextUnit( // once at startup. If resources were re-synced (e.g. /gsd:update, npm update, // or dev copy-resources), templates may expect variables the in-memory code // doesn't provide. Stop gracefully instead of crashing. - const staleMsg = checkResourcesStale(resourceVersionOnStart); + const staleMsg = checkResourcesStale(s.resourceVersionOnStart); if (staleMsg) { await stopAuto(ctx, pi, staleMsg); return; @@ -2081,14 +2029,14 @@ async function dispatchNextUnit( // Parse cache is also cleared — doctor may have re-populated it with // stale data between handleAgentEnd and this dispatch call (Path B fix). invalidateAllCaches(); - lastPromptCharCount = undefined; - lastBaselineCharCount = undefined; + s.lastPromptCharCount = undefined; + s.lastBaselineCharCount = undefined; // ── Pre-dispatch health gate ────────────────────────────────────────── // Lightweight check for critical issues that would cause the next unit // to fail or corrupt state. Auto-heals what it can, blocks on the rest. try { - const healthGate = await preDispatchHealthGate(basePath); + const healthGate = await preDispatchHealthGate(s.basePath); if (healthGate.fixesApplied.length > 0) { ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info"); } @@ -2105,12 +2053,12 @@ async function dispatchNextUnit( // When the LLM writes artifacts to the main repo filesystem instead of // the worktree, the worktree's gsd.db becomes stale. Sync before // deriveState to ensure the worktree has the latest artifacts. - if (originalBasePath && basePath !== originalBasePath && currentMilestoneId) { - syncProjectRootToWorktree(originalBasePath, basePath, currentMilestoneId); + if (s.originalBasePath && s.basePath !== s.originalBasePath && s.currentMilestoneId) { + syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId); } const stopDeriveTimer = debugTime("derive-state"); - let state = await deriveState(basePath); + let state = await deriveState(s.basePath); stopDeriveTimer({ phase: state.phase, milestone: state.activeMilestone?.id, @@ -2121,12 +2069,12 @@ async function dispatchNextUnit( let midTitle = state.activeMilestone?.title; // Detect milestone transition - if (mid && currentMilestoneId && mid !== currentMilestoneId) { + if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { ctx.ui.notify( - `Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, + `Milestone ${ s.currentMilestoneId } complete. Advancing to ${mid}: ${midTitle}.`, "info", ); - sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone"); + sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone"); // Hint: visualizer available after milestone transition const vizPrefs = loadEffectiveGSDPreferences()?.preferences; if (vizPrefs?.auto_visualize) { @@ -2139,27 +2087,26 @@ async function dispatchNextUnit( const { generateHtmlReport } = await import("./export-html.js"); const { writeReportSnapshot, reportsDir } = await import("./reports.js"); const { basename } = await import("node:path"); - const snapData = await loadVisualizerData(basePath); - const completedMs = snapData.milestones.find(m => m.id === currentMilestoneId); - const msTitle = completedMs?.title ?? currentMilestoneId; + const snapData = await loadVisualizerData(s.basePath); + const completedMs = snapData.milestones.find(m => m.id === s.currentMilestoneId); + const msTitle = completedMs?.title ?? s.currentMilestoneId; const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; - const projName = basename(basePath); + const projName = basename(s.basePath); const doneSlices = snapData.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); const totalSlices = snapData.milestones.reduce((s, m) => s + m.slices.length, 0); - const outPath = writeReportSnapshot({ - basePath, + const outPath = writeReportSnapshot({ basePath: s.basePath, html: generateHtmlReport(snapData, { projectName: projName, - projectPath: basePath, + projectPath: s.basePath, gsdVersion, - milestoneId: currentMilestoneId, + milestoneId: s.currentMilestoneId, indexRelPath: "index.html", }), - milestoneId: currentMilestoneId, + milestoneId: s.currentMilestoneId, milestoneTitle: msTitle, kind: "milestone", projectName: projName, - projectPath: basePath, + projectPath: s.basePath, gsdVersion, totalCost: snapData.totals?.cost ?? 0, totalTokens: snapData.totals?.tokens.total ?? 0, @@ -2182,15 +2129,15 @@ async function dispatchNextUnit( } } // Reset stuck detection for new milestone - unitDispatchCount.clear(); - unitRecoveryCount.clear(); - unitConsecutiveSkips.clear(); - unitLifetimeDispatches.clear(); + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.unitConsecutiveSkips.clear(); + s.unitLifetimeDispatches.clear(); // Clear completed-units.json for the finished milestone try { - const file = completedKeysPath(basePath); + const file = completedKeysPath(s.basePath); if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8"); - completedKeySet.clear(); + s.completedKeySet.clear(); } catch { /* non-fatal */ } // ── Worktree lifecycle on milestone transition (#616) ────────────── @@ -2200,20 +2147,20 @@ async function dispatchNextUnit( // 3. Create a new worktree for the incoming milestone // Without this, M_new runs inside M_old's worktree on the wrong branch, // and artifact paths resolve against the wrong .gsd/ directory. - if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) { + if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) { try { - const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP"); + const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP"); if (roadmapPath) { const roadmapContent = readFileSync(roadmapPath, "utf-8"); - const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent); + const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent); ctx.ui.notify( - `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, + `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, "info", ); } else { // No roadmap found — teardown worktree without merge - teardownAutoWorktree(originalBasePath, currentMilestoneId); - ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info"); + teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId); + ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info"); } } catch (err) { ctx.ui.notify( @@ -2221,28 +2168,28 @@ async function dispatchNextUnit( "warning", ); // Force cwd back to project root even if merge failed - if (originalBasePath) { - try { process.chdir(originalBasePath); } catch { /* best-effort */ } + if (s.originalBasePath) { + try { process.chdir(s.originalBasePath); } catch { /* best-effort */ } } } - // Update basePath to project root (mergeMilestoneToMain already chdir'd) - basePath = originalBasePath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + // Update s.basePath to project root (mergeMilestoneToMain already chdir'd) + s.basePath = s.originalBasePath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); invalidateAllCaches(); // Re-derive state from project root before creating new worktree - state = await deriveState(basePath); + state = await deriveState(s.basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; // Create new worktree for the incoming milestone if (mid) { - captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + captureIntegrationBranch(s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); try { - const wtPath = createAutoWorktree(basePath, mid); - basePath = wtPath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const wtPath = createAutoWorktree(s.basePath, mid); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info"); } catch (err) { ctx.ui.notify( @@ -2255,7 +2202,7 @@ async function dispatchNextUnit( // Not in worktree — capture integration branch for the new milestone (branch mode only). // In none mode there's no milestone branch to merge back to, so skip. if (getIsolationMode() !== "none") { - captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + captureIntegrationBranch(s.originalBasePath || s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); } } @@ -2263,17 +2210,17 @@ async function dispatchNextUnit( const pendingIds = state.registry .filter(m => m.status !== "complete") .map(m => m.id); - pruneQueueOrder(basePath, pendingIds); + pruneQueueOrder(s.basePath, pendingIds); } if (mid) { - currentMilestoneId = mid; - setActiveMilestoneId(basePath, mid); + s.currentMilestoneId = mid; + setActiveMilestoneId(s.basePath, mid); } if (!mid) { // Save final session before stopping - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } const incomplete = state.registry.filter(m => m.status !== "complete"); @@ -2288,9 +2235,9 @@ async function dispatchNextUnit( ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); sendDesktopNotification("GSD", blockerMsg, "error", "attention"); } else { - // Milestones with remaining work exist but none became active — unexpected + // Milestones with remaining work exist but none became s.active — unexpected const ids = incomplete.map(m => m.id).join(", "); - const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; + const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error"); await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`); } @@ -2306,17 +2253,17 @@ async function dispatchNextUnit( } // ── Mid-merge safety check: detect leftover merge state from a prior session ── - if (reconcileMergeState(basePath, ctx)) { + if (reconcileMergeState(s.basePath, ctx)) { invalidateAllCaches(); - state = await deriveState(basePath); + state = await deriveState(s.basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; } // After merge guard removal (branchless architecture), mid/midTitle could be undefined if (!mid || !midTitle) { - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } const noMilestoneReason = !mid ? "No active milestone after merge reconciliation" @@ -2331,26 +2278,26 @@ async function dispatchNextUnit( let prompt: string; if (state.phase === "complete") { - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } // Clear completed-units.json for the finished milestone so it doesn't grow unbounded. try { - const file = completedKeysPath(basePath); + const file = completedKeysPath(s.basePath); if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8"); - completedKeySet.clear(); + s.completedKeySet.clear(); } catch { /* non-fatal */ } // ── Milestone merge: squash-merge milestone branch to main before stopping ── - if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) { + if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) { try { - const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP"); - if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${currentMilestoneId}`); + const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP"); + if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`); const roadmapContent = readFileSync(roadmapPath, "utf-8"); - const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent); - basePath = originalBasePath; - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent); + s.basePath = s.originalBasePath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify( - `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, + `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, "info", ); } catch (err) { @@ -2361,27 +2308,27 @@ async function dispatchNextUnit( // Ensure cwd is restored even if merge failed partway through (#608). // mergeMilestoneToMain may have chdir'd but then thrown, leaving us // in an indeterminate location. - if (originalBasePath) { - basePath = originalBasePath; - try { process.chdir(basePath); } catch { /* best-effort */ } + if (s.originalBasePath) { + s.basePath = s.originalBasePath; + try { process.chdir(s.basePath); } catch { /* best-effort */ } } } - } else if (currentMilestoneId && !isInAutoWorktree(basePath) && getIsolationMode() !== "none") { + } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") { // Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch. // Squash-merge back to the integration branch (or main) before stopping. try { - const currentBranch = getCurrentBranch(basePath); - const milestoneBranch = autoWorktreeBranch(currentMilestoneId); + const currentBranch = getCurrentBranch(s.basePath); + const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId); if (currentBranch === milestoneBranch) { - const roadmapPath = resolveMilestoneFile(basePath, currentMilestoneId, "ROADMAP"); + const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP"); if (roadmapPath) { const roadmapContent = readFileSync(roadmapPath, "utf-8"); // mergeMilestoneToMain handles: auto-commit, checkout integration branch, // squash merge, commit, optional push, branch deletion. - const mergeResult = mergeMilestoneToMain(basePath, currentMilestoneId, roadmapContent); - gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent); + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify( - `Milestone ${currentMilestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`, + `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`, "info", ); } @@ -2399,8 +2346,8 @@ async function dispatchNextUnit( } if (state.phase === "blocked") { - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; await stopAuto(ctx, pi, blockerMsg); @@ -2420,14 +2367,14 @@ async function dispatchNextUnit( const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0; const budgetPct = totalCost / budgetCeiling; const budgetAlertLevel = getBudgetAlertLevel(budgetPct); - const newBudgetAlertLevel = getNewBudgetAlertLevel(lastBudgetAlertLevel, budgetPct); + const newBudgetAlertLevel = getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct); const enforcement = prefs?.budget_enforcement ?? "pause"; const budgetEnforcementAction = getBudgetEnforcementAction(enforcement, budgetPct); if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") { const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`; - lastBudgetAlertLevel = newBudgetAlertLevel; + s.lastBudgetAlertLevel = newBudgetAlertLevel; if (budgetEnforcementAction === "halt") { sendDesktopNotification("GSD", msg, "error", "budget"); await stopAuto(ctx, pi, "Budget ceiling reached"); @@ -2442,28 +2389,28 @@ async function dispatchNextUnit( ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); sendDesktopNotification("GSD", msg, "warning", "budget"); } else if (newBudgetAlertLevel === 90) { - lastBudgetAlertLevel = newBudgetAlertLevel; + s.lastBudgetAlertLevel = newBudgetAlertLevel; ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning"); sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget"); } else if (newBudgetAlertLevel === 80) { - lastBudgetAlertLevel = newBudgetAlertLevel; + s.lastBudgetAlertLevel = newBudgetAlertLevel; ctx.ui.notify(`Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning"); sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget"); } else if (newBudgetAlertLevel === 75) { - lastBudgetAlertLevel = newBudgetAlertLevel; + s.lastBudgetAlertLevel = newBudgetAlertLevel; ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info"); sendDesktopNotification("GSD", `Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info", "budget"); } else if (budgetAlertLevel === 0) { - lastBudgetAlertLevel = 0; + s.lastBudgetAlertLevel = 0; } } else { - lastBudgetAlertLevel = 0; + s.lastBudgetAlertLevel = 0; } // Context window guard — pause if approaching context limits const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default - if (contextThreshold > 0 && cmdCtx) { - const contextUsage = cmdCtx.getContextUsage(); + if (contextThreshold > 0 && s.cmdCtx) { + const contextUsage = s.cmdCtx.getContextUsage(); if (contextUsage && contextUsage.percent !== null && contextUsage.percent >= contextThreshold) { const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning"); @@ -2480,9 +2427,9 @@ async function dispatchNextUnit( // into plan-slice / execute-task with no real credentials and mock everything. const runSecretsGate = async () => { try { - const manifestStatus = await getManifestStatus(basePath, mid); + const manifestStatus = await getManifestStatus(s.basePath, mid); if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await collectSecretsFromManifest(basePath, mid, ctx); + const result = await collectSecretsFromManifest(s.basePath, mid, ctx); if (result && result.applied && result.skipped && result.existingSkipped) { ctx.ui.notify( `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, @@ -2503,13 +2450,12 @@ async function dispatchNextUnit( await runSecretsGate(); // ── Dispatch table: resolve phase → unit type + prompt ── - const dispatchResult = await resolveDispatch({ - basePath, mid, midTitle: midTitle!, state, prefs, + const dispatchResult = await resolveDispatch({ basePath: s.basePath, mid, midTitle: midTitle!, state, prefs, }); if (dispatchResult.action === "stop") { - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } await stopAuto(ctx, pi, dispatchResult.reason); return; @@ -2528,7 +2474,7 @@ async function dispatchNextUnit( let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; // ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ── - const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath); + const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath); if (preDispatchResult.firedHooks.length > 0) { ctx.ui.notify( `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, @@ -2549,67 +2495,67 @@ async function dispatchNextUnit( prompt = preDispatchResult.prompt; } - const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId); + const priorSliceBlocker = getPriorSliceCompletionBlocker(s.basePath, getMainBranch(s.basePath), unitType, unitId); if (priorSliceBlocker) { await stopAuto(ctx, pi, priorSliceBlocker); return; } - const observabilityIssues = await _collectObservabilityWarnings(ctx, basePath, unitType, unitId); + const observabilityIssues = await _collectObservabilityWarnings(ctx, s.basePath, unitType, unitId); // Idempotency: skip units already completed in a prior session. const idempotencyKey = `${unitType}/${unitId}`; - if (completedKeySet.has(idempotencyKey)) { + if (s.completedKeySet.has(idempotencyKey)) { // Cross-validate: does the expected artifact actually exist? - const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath); + const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath); if (artifactExists) { // Guard against infinite skip loops: if deriveState keeps returning the // same completed unit, consecutive skips will trip this breaker. Evict the // key so the next dispatch forces full reconciliation instead of looping. - const skipCount = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; - unitConsecutiveSkips.set(idempotencyKey, skipCount); + const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; + s.unitConsecutiveSkips.set(idempotencyKey, skipCount); if (skipCount > MAX_CONSECUTIVE_SKIPS) { // Cross-check: verify deriveState actually returns this unit (#790). // If the unit's milestone is already complete, this is a phantom skip // loop from stale crash recovery context — don't evict. const skippedMid = unitId.split("/")[0]; const skippedMilestoneComplete = skippedMid - ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY") + ? !!resolveMilestoneFile(s.basePath, skippedMid, "SUMMARY") : false; if (skippedMilestoneComplete) { // Milestone is complete — evicting this key would fight self-heal. // Clear skip counter and re-dispatch from fresh state. - unitConsecutiveSkips.delete(idempotencyKey); + s.unitConsecutiveSkips.delete(idempotencyKey); invalidateAllCaches(); ctx.ui.notify( `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`, "info", ); - _skipDepth++; + s.skipDepth++; await new Promise(r => setTimeout(r, 50)); await dispatchNextUnit(ctx, pi); - _skipDepth = Math.max(0, _skipDepth - 1); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } - unitConsecutiveSkips.delete(idempotencyKey); - completedKeySet.delete(idempotencyKey); - recentlyEvictedKeys.add(idempotencyKey); - removePersistedKey(basePath, idempotencyKey); + s.unitConsecutiveSkips.delete(idempotencyKey); + s.completedKeySet.delete(idempotencyKey); + s.recentlyEvictedKeys.add(idempotencyKey); + removePersistedKey(s.basePath, idempotencyKey); invalidateAllCaches(); ctx.ui.notify( `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`, "warning", ); - if (!active) return; - _skipDepth++; + if (!s.active) return; + s.skipDepth++; await new Promise(r => setTimeout(r, 150)); await dispatchNextUnit(ctx, pi); - _skipDepth = Math.max(0, _skipDepth - 1); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } // Count toward lifetime cap so hard-stop fires during skip loops (#792) - const lifeSkip = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; - unitLifetimeDispatches.set(idempotencyKey, lifeSkip); + const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; + s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip); if (lifeSkip > MAX_LIFETIME_DISPATCHES) { await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`); ctx.ui.notify( @@ -2622,16 +2568,16 @@ async function dispatchNextUnit( `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`, "info", ); - if (!active) return; - _skipDepth++; + if (!s.active) return; + s.skipDepth++; await new Promise(r => setTimeout(r, 150)); await dispatchNextUnit(ctx, pi); - _skipDepth = Math.max(0, _skipDepth - 1); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } else { // Stale completion record — artifact missing. Remove and re-run. - completedKeySet.delete(idempotencyKey); - removePersistedKey(basePath, idempotencyKey); + s.completedKeySet.delete(idempotencyKey); + removePersistedKey(s.basePath, idempotencyKey); ctx.ui.notify( `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`, "warning", @@ -2646,50 +2592,50 @@ async function dispatchNextUnit( // // EXCEPTION: if the key was just evicted by the skip-loop breaker above, do NOT // re-persist — that would recreate the exact loop the breaker was trying to break (#912). - if (verifyExpectedArtifact(unitType, unitId, basePath) && !recentlyEvictedKeys.has(idempotencyKey)) { - persistCompletedKey(basePath, idempotencyKey); - completedKeySet.add(idempotencyKey); + if (verifyExpectedArtifact(unitType, unitId, s.basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) { + persistCompletedKey(s.basePath, idempotencyKey); + s.completedKeySet.add(idempotencyKey); invalidateAllCaches(); // Same consecutive-skip guard as the idempotency path above. - const skipCount2 = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; - unitConsecutiveSkips.set(idempotencyKey, skipCount2); + const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; + s.unitConsecutiveSkips.set(idempotencyKey, skipCount2); if (skipCount2 > MAX_CONSECUTIVE_SKIPS) { // Cross-check: verify the unit's milestone is still active (#790). const skippedMid2 = unitId.split("/")[0]; const skippedMilestoneComplete2 = skippedMid2 - ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY") + ? !!resolveMilestoneFile(s.basePath, skippedMid2, "SUMMARY") : false; if (skippedMilestoneComplete2) { - unitConsecutiveSkips.delete(idempotencyKey); + s.unitConsecutiveSkips.delete(idempotencyKey); invalidateAllCaches(); ctx.ui.notify( `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`, "info", ); - _skipDepth++; + s.skipDepth++; await new Promise(r => setTimeout(r, 50)); await dispatchNextUnit(ctx, pi); - _skipDepth = Math.max(0, _skipDepth - 1); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } - unitConsecutiveSkips.delete(idempotencyKey); - completedKeySet.delete(idempotencyKey); - removePersistedKey(basePath, idempotencyKey); + s.unitConsecutiveSkips.delete(idempotencyKey); + s.completedKeySet.delete(idempotencyKey); + removePersistedKey(s.basePath, idempotencyKey); invalidateAllCaches(); ctx.ui.notify( `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`, "warning", ); - if (!active) return; - _skipDepth++; + if (!s.active) return; + s.skipDepth++; await new Promise(r => setTimeout(r, 150)); await dispatchNextUnit(ctx, pi); - _skipDepth = Math.max(0, _skipDepth - 1); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } // Count toward lifetime cap so hard-stop fires during skip loops (#792) - const lifeSkip2 = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; - unitLifetimeDispatches.set(idempotencyKey, lifeSkip2); + const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; + s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2); if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) { await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`); ctx.ui.notify( @@ -2702,41 +2648,41 @@ async function dispatchNextUnit( `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`, "info", ); - if (!active) return; - _skipDepth++; + if (!s.active) return; + s.skipDepth++; await new Promise(r => setTimeout(r, 150)); await dispatchNextUnit(ctx, pi); - _skipDepth = Math.max(0, _skipDepth - 1); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } // Stuck detection — tracks total dispatches per unit (not just consecutive repeats). // Pattern A→B→A→B would reset retryCount every time; this map catches it. const dispatchKey = `${unitType}/${unitId}`; - const prevCount = unitDispatchCount.get(dispatchKey) ?? 0; + const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0; // Real dispatch reached — clear the consecutive-skip counter for this unit. - unitConsecutiveSkips.delete(dispatchKey); + s.unitConsecutiveSkips.delete(dispatchKey); debugLog("dispatch-unit", { type: unitType, id: unitId, cycle: prevCount + 1, - lifetime: (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1, + lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1, }); debugCount("dispatches"); // Hard lifetime cap — survives counter resets from loop-recovery/self-repair. // Catches the case where reconciliation "succeeds" (artifacts exist) but // deriveState keeps returning the same unit, creating an infinite cycle. - const lifetimeCount = (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1; - unitLifetimeDispatches.set(dispatchKey, lifetimeCount); + const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1; + s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount); if (lifetimeCount > MAX_LIFETIME_DISPATCHES) { - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } else { - saveActivityLog(ctx, basePath, unitType, unitId); + saveActivityLog(ctx, s.basePath, unitType, unitId); } - const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); + const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath); await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`); ctx.ui.notify( `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`, @@ -2745,10 +2691,10 @@ async function dispatchNextUnit( return; } if (prevCount >= MAX_UNIT_DISPATCHES) { - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } else { - saveActivityLog(ctx, basePath, unitType, unitId); + saveActivityLog(ctx, s.basePath, unitType, unitId); } // Final reconciliation pass for execute-task: write any missing durable @@ -2757,13 +2703,13 @@ async function dispatchNextUnit( if (unitType === "execute-task") { const [mid, sid, tid] = unitId.split("/"); if (mid && sid && tid) { - const status = await inspectExecuteTaskDurability(basePath, unitId); + const status = await inspectExecuteTaskDurability(s.basePath, unitId); if (status) { - const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount); + const reconciled = skipExecuteTask(s.basePath, mid, sid, tid, status, "loop-recovery", prevCount); // reconciled: skipExecuteTask attempted to write missing artifacts. // verifyExpectedArtifact: confirms physical artifacts (summary + [x]) now exist on disk. // Both must pass before we clear the dispatch counter and advance. - if (reconciled && verifyExpectedArtifact(unitType, unitId, basePath)) { + if (reconciled && verifyExpectedArtifact(unitType, unitId, s.basePath)) { ctx.ui.notify( `Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`, "warning", @@ -2771,9 +2717,9 @@ async function dispatchNextUnit( // Persist completion so idempotency check prevents re-dispatch // if deriveState keeps returning this unit (#462). const reconciledKey = `${unitType}/${unitId}`; - persistCompletedKey(basePath, reconciledKey); - completedKeySet.add(reconciledKey); - unitDispatchCount.delete(dispatchKey); + persistCompletedKey(s.basePath, reconciledKey); + s.completedKeySet.add(reconciledKey); + s.unitDispatchCount.delete(dispatchKey); invalidateAllCaches(); await new Promise(r => setImmediate(r)); await dispatchNextUnit(ctx, pi); @@ -2791,16 +2737,16 @@ async function dispatchNextUnit( // dispatch limit succeeded but the counter check fires before anyone // verifies disk state. Without this, a successful final attempt is // indistinguishable from a failed one. - if (verifyExpectedArtifact(unitType, unitId, basePath)) { + if (verifyExpectedArtifact(unitType, unitId, s.basePath)) { ctx.ui.notify( `Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`, "info", ); // Persist completion so the idempotency check prevents re-dispatch // if deriveState keeps returning this unit (see #462). - persistCompletedKey(basePath, dispatchKey); - completedKeySet.add(dispatchKey); - unitDispatchCount.delete(dispatchKey); + persistCompletedKey(s.basePath, dispatchKey); + s.completedKeySet.add(dispatchKey); + s.unitDispatchCount.delete(dispatchKey); invalidateAllCaches(); await new Promise(r => setImmediate(r)); await dispatchNextUnit(ctx, pi); @@ -2812,15 +2758,15 @@ async function dispatchNextUnit( // but the LLM failed to write the summary N times. A stub lets the pipeline advance. if (unitType === "complete-milestone") { try { - const mPath = resolveMilestonePath(basePath, unitId); + const mPath = resolveMilestonePath(s.basePath, unitId); if (mPath) { const stubPath = join(mPath, `${unitId}-SUMMARY.md`); if (!existsSync(stubPath)) { writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`); ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning"); - persistCompletedKey(basePath, dispatchKey); - completedKeySet.add(dispatchKey); - unitDispatchCount.delete(dispatchKey); + persistCompletedKey(s.basePath, dispatchKey); + s.completedKeySet.add(dispatchKey); + s.unitDispatchCount.delete(dispatchKey); invalidateAllCaches(); await new Promise(r => setImmediate(r)); await dispatchNextUnit(ctx, pi); @@ -2830,8 +2776,8 @@ async function dispatchNextUnit( } catch { /* non-fatal — fall through to normal stop */ } } - const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); - const remediation = buildLoopRemediationSteps(unitType, unitId, basePath); + const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath); + const remediation = buildLoopRemediationSteps(unitType, unitId, s.basePath); await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`); sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error"); ctx.ui.notify( @@ -2840,28 +2786,28 @@ async function dispatchNextUnit( ); return; } - unitDispatchCount.set(dispatchKey, prevCount + 1); + s.unitDispatchCount.set(dispatchKey, prevCount + 1); if (prevCount > 0) { // Adaptive self-repair: each retry attempts a different remediation step. if (unitType === "execute-task") { - const status = await inspectExecuteTaskDurability(basePath, unitId); + const status = await inspectExecuteTaskDurability(s.basePath, unitId); const [mid, sid, tid] = unitId.split("/"); if (status && mid && sid && tid) { if (status.summaryExists && !status.taskChecked) { // Retry 1+: summary exists but checkbox not marked — mark [x] and advance. - const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0); + const repaired = skipExecuteTask(s.basePath, mid, sid, tid, status, "self-repair", 0); // repaired: skipExecuteTask updated metadata (returned early-true even if regex missed). // verifyExpectedArtifact: confirms the physical artifact (summary + [x]) now exists. - if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) { + if (repaired && verifyExpectedArtifact(unitType, unitId, s.basePath)) { ctx.ui.notify( `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`, "warning", ); // Persist completion so idempotency check prevents re-dispatch (#462). const repairedKey = `${unitType}/${unitId}`; - persistCompletedKey(basePath, repairedKey); - completedKeySet.add(repairedKey); - unitDispatchCount.delete(dispatchKey); + persistCompletedKey(s.basePath, repairedKey); + s.completedKeySet.add(repairedKey); + s.unitDispatchCount.delete(dispatchKey); invalidateAllCaches(); await new Promise(r => setImmediate(r)); await dispatchNextUnit(ctx, pi); @@ -2871,8 +2817,8 @@ async function dispatchNextUnit( // Retry STUB_RECOVERY_THRESHOLD+: summary still missing after multiple attempts. // Write a minimal stub summary so the next agent session has a recovery artifact // to overwrite, rather than starting from scratch again. - const tasksDir = resolveTasksDir(basePath, mid, sid); - const sDir = resolveSlicePath(basePath, mid, sid); + const tasksDir = resolveTasksDir(s.basePath, mid, sid); + const sDir = resolveSlicePath(s.basePath, mid, sid); const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null); if (targetDir) { if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); @@ -2903,15 +2849,15 @@ async function dispatchNextUnit( } // Snapshot metrics + activity log for the PREVIOUS unit before we reassign. // The session still holds the previous unit's data (newSession hasn't fired yet). - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); // Record routing outcome for adaptive learning - if (currentUnitRouting) { - const isRetry = currentUnit.type === unitType && currentUnit.id === unitId; + if (s.currentUnitRouting) { + const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId; recordOutcome( - currentUnit.type, - currentUnitRouting.tier as "light" | "standard" | "heavy", + s.currentUnit.type, + s.currentUnitRouting.tier as "light" | "standard" | "heavy", !isRetry, // success = not being retried ); } @@ -2921,55 +2867,55 @@ async function dispatchNextUnit( // 2. The expected artifact actually exists on disk // For hook units, skip artifact verification — hooks don't produce standard // artifacts and their runtime records were already finalized in handleAgentEnd. - const closeoutKey = `${currentUnit.type}/${currentUnit.id}`; + const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`; const incomingKey = `${unitType}/${unitId}`; - const isHookUnit = currentUnit.type.startsWith("hook/"); - const artifactVerified = isHookUnit || verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath); + const isHookUnit = s.currentUnit.type.startsWith("hook/"); + const artifactVerified = isHookUnit || verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); if (closeoutKey !== incomingKey && artifactVerified) { if (!isHookUnit) { // Only persist completion keys for real units — hook keys are // ephemeral and should not pollute the idempotency set. - persistCompletedKey(basePath, closeoutKey); - completedKeySet.add(closeoutKey); + persistCompletedKey(s.basePath, closeoutKey); + s.completedKeySet.add(closeoutKey); } - completedUnits.push({ - type: currentUnit.type, - id: currentUnit.id, - startedAt: currentUnit.startedAt, + s.completedUnits.push({ + type: s.currentUnit.type, + id: s.currentUnit.id, + startedAt: s.currentUnit.startedAt, finishedAt: Date.now(), }); // Cap to last 200 entries to prevent unbounded growth (#611) - if (completedUnits.length > 200) { - completedUnits = completedUnits.slice(-200); + if (s.completedUnits.length > 200) { + s.completedUnits = s.completedUnits.slice(-200); } - clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id); - unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`); - unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`); + clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id); + s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`); + s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`); } } - currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; captureAvailableSkills(); // Capture skill telemetry at dispatch time (#599) - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { phase: "dispatched", wrapupWarningSent: false, timeoutAt: null, - lastProgressAt: currentUnit.startedAt, + lastProgressAt: s.currentUnit.startedAt, progressCount: 0, lastProgressKind: "dispatch", }); // Status bar + progress widget ctx.ui.setStatus("gsd-auto", "auto"); - if (mid) updateSliceProgressCache(basePath, mid, state.activeSlice?.id); + if (mid) updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); updateProgressWidget(ctx, unitType, unitId, state); // Ensure preconditions — create directories, branches, etc. // so the LLM doesn't have to get these right - ensurePreconditions(unitType, unitId, basePath, state); + ensurePreconditions(unitType, unitId, s.basePath, state); // Fresh session - const result = await cmdCtx!.newSession(); + const result = await s.cmdCtx!.newSession(); if (result.cancelled) { await stopAuto(ctx, pi, "Session cancelled"); return; @@ -2983,7 +2929,7 @@ async function dispatchNextUnit( // Pi appends entries incrementally via appendFileSync, so on crash the // session file survives with every tool call up to the crash point. const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), unitType, unitId, completedUnits.length, sessionFile); + writeLock(lockBase(), unitType, unitId, s.completedUnits.length, sessionFile); // On crash recovery, prepend the full recovery briefing // On retry (stuck detection), prepend deep diagnostic from last attempt @@ -2992,23 +2938,23 @@ async function dispatchNextUnit( let finalPrompt = prompt; // Verification retry — inject failure context so the agent can auto-fix - if (pendingVerificationRetry) { - const retryCtx = pendingVerificationRetry; - pendingVerificationRetry = null; + if (s.pendingVerificationRetry) { + const retryCtx = s.pendingVerificationRetry; + s.pendingVerificationRetry = null; const capped = retryCtx.failureContext.length > MAX_RECOVERY_CHARS ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...failure context truncated]" : retryCtx.failureContext; finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`; } - if (pendingCrashRecovery) { - const capped = pendingCrashRecovery.length > MAX_RECOVERY_CHARS - ? pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]" - : pendingCrashRecovery; + if (s.pendingCrashRecovery) { + const capped = s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS + ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]" + : s.pendingCrashRecovery; finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`; - pendingCrashRecovery = null; - } else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { - const diagnostic = getDeepDiagnostic(basePath); + s.pendingCrashRecovery = null; + } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { + const diagnostic = getDeepDiagnostic(s.basePath); if (diagnostic) { const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...diagnostic truncated to prevent memory exhaustion]" @@ -3025,17 +2971,17 @@ async function dispatchNextUnit( } // ── Prompt char measurement (R051) ── - lastPromptCharCount = finalPrompt.length; - lastBaselineCharCount = undefined; + s.lastPromptCharCount = finalPrompt.length; + s.lastBaselineCharCount = undefined; if (isDbAvailable()) { try { const { inlineGsdRootFile } = await import("./auto-prompts.js"); const [decisionsContent, requirementsContent, projectContent] = await Promise.all([ - inlineGsdRootFile(basePath, "decisions.md", "Decisions"), - inlineGsdRootFile(basePath, "requirements.md", "Requirements"), - inlineGsdRootFile(basePath, "project.md", "Project"), + inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"), + inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"), + inlineGsdRootFile(s.basePath, "project.md", "Project"), ]); - lastBaselineCharCount = + s.lastBaselineCharCount = (decisionsContent?.length ?? 0) + (requirementsContent?.length ?? 0) + (projectContent?.length ?? 0); @@ -3045,8 +2991,8 @@ async function dispatchNextUnit( } // Select and apply model for this unit (dynamic routing, fallback chains, etc.) - const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, basePath, prefs, verbose, autoModeStartModel); - currentUnitRouting = modelResult.routing; + const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel); + s.currentUnitRouting = modelResult.routing; // Start progress-aware supervision: a soft warning, an idle watchdog, and // a larger hard ceiling. Productive long-running tasks may continue past the @@ -3057,17 +3003,17 @@ async function dispatchNextUnit( const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000; const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000; - wrapupWarningHandle = setTimeout(() => { - wrapupWarningHandle = null; - if (!active || !currentUnit) return; - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + s.wrapupWarningHandle = setTimeout(() => { + s.wrapupWarningHandle = null; + if (!s.active || !s.currentUnit) return; + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { phase: "wrapup-warning-sent", wrapupWarningSent: true, }); pi.sendMessage( { customType: "gsd-auto-wrapup", - display: verbose, + display: s.verbose, content: [ "**TIME BUDGET WARNING — keep going only if progress is real.**", "This unit crossed the soft time budget.", @@ -3082,10 +3028,10 @@ async function dispatchNextUnit( ); }, softTimeoutMs); - idleWatchdogHandle = setInterval(async () => { + s.idleWatchdogHandle = setInterval(async () => { try { - if (!active || !currentUnit) return; - const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); + if (!s.active || !s.currentUnit) return; + const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); if (!runtime) return; if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return; @@ -3098,7 +3044,7 @@ async function dispatchNextUnit( const oldestStart = getOldestInFlightToolStart()!; const toolAgeMs = Date.now() - oldestStart; if (toolAgeMs < idleTimeoutMs) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { lastProgressAt: Date.now(), lastProgressKind: "tool-in-flight", }); @@ -3116,24 +3062,24 @@ async function dispatchNextUnit( // Before triggering recovery, check if the agent is actually producing // work on disk. `git status --porcelain` is cheap and catches any // staged/unstaged/untracked changes the agent made since lastProgressAt. - if (detectWorkingTreeActivity(basePath)) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + if (detectWorkingTreeActivity(s.basePath)) { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { lastProgressAt: Date.now(), lastProgressKind: "filesystem-activity", }); return; } - if (currentUnit) { - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } else { - saveActivityLog(ctx, basePath, unitType, unitId); + saveActivityLog(ctx, s.basePath, unitType, unitId); } const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle", buildRecoveryContext()); if (recovery === "recovered") return; - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { phase: "paused", }); ctx.ui.notify( @@ -3153,18 +3099,18 @@ async function dispatchNextUnit( } }, 15000); - unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = setTimeout(async () => { try { - unitTimeoutHandle = null; - if (!active) return; - if (currentUnit) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + s.unitTimeoutHandle = null; + if (!s.active) return; + if (s.currentUnit) { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { phase: "timeout", timeoutAt: Date.now(), }); - await closeoutUnit(ctx, basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, buildSnapshotOpts(currentUnit.type, currentUnit.id)); + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } else { - saveActivityLog(ctx, basePath, unitType, unitId); + saveActivityLog(ctx, s.basePath, unitType, unitId); } const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard", buildRecoveryContext()); @@ -3189,9 +3135,9 @@ async function dispatchNextUnit( // threshold (70%), sends a one-shot wrap-up signal so the agent finishes // gracefully and the next unit gets a fresh session. This is softer than // context_pause_threshold which hard-pauses auto-mode entirely. - if (continueHereHandle) { - clearInterval(continueHereHandle); - continueHereHandle = null; + if (s.continueHereHandle) { + clearInterval(s.continueHereHandle); + s.continueHereHandle = null; } const executorContextWindow = resolveExecutorContextWindow( ctx.modelRegistry as Parameters[0], @@ -3199,21 +3145,21 @@ async function dispatchNextUnit( ctx.model?.contextWindow, ); const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent; - continueHereHandle = setInterval(() => { - if (!active || !currentUnit || !cmdCtx) return; + s.continueHereHandle = setInterval(() => { + if (!s.active || !s.currentUnit || !s.cmdCtx) return; // One-shot guard: skip if already fired for this unit - const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); + const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); if (runtime?.continueHereFired) return; - const contextUsage = cmdCtx.getContextUsage(); + const contextUsage = s.cmdCtx.getContextUsage(); if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return; // Fire once — mark runtime record and send wrap-up message - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit!.startedAt, { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit!.startedAt, { continueHereFired: true, }); - if (verbose) { + if (s.verbose) { ctx.ui.notify( `Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`, "info", @@ -3223,7 +3169,7 @@ async function dispatchNextUnit( pi.sendMessage( { customType: "gsd-auto-wrapup", - display: verbose, + display: s.verbose, content: [ "**CONTEXT BUDGET WARNING — wrap up this unit now.**", `Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`, @@ -3239,16 +3185,16 @@ async function dispatchNextUnit( ); // Clear the interval after firing — no need to keep polling - if (continueHereHandle) { - clearInterval(continueHereHandle); - continueHereHandle = null; + if (s.continueHereHandle) { + clearInterval(s.continueHereHandle); + s.continueHereHandle = null; } }, 15_000); - // Inject prompt — verify auto-mode still active (guards against race with timeout/pause) - if (!active) return; + // Inject prompt — verify auto-mode still s.active (guards against race with timeout/pause) + if (!s.active) return; pi.sendMessage( - { customType: "gsd-auto", content: finalPrompt, display: verbose }, + { customType: "gsd-auto", content: finalPrompt, display: s.verbose }, { triggerTurn: true }, ); @@ -3263,7 +3209,7 @@ async function dispatchNextUnit( await pauseAuto(ctx, pi); } } finally { - _dispatching = false; + s.dispatching = false; } } @@ -3319,11 +3265,8 @@ function ensurePreconditions( /** Build recovery context from module state for recoverTimedOutUnit */ function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryContext { - return { - basePath, - verbose, - currentUnitStartedAt: currentUnit?.startedAt ?? Date.now(), - unitRecoveryCount, + return { basePath: s.basePath, verbose: s.verbose, + currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), unitRecoveryCount: s.unitRecoveryCount, dispatchNextUnit, }; } @@ -3341,9 +3284,9 @@ export { * Test-only: expose skip-loop state for unit tests. * Not part of the public API. */ -export function _getUnitConsecutiveSkips(): Map { return unitConsecutiveSkips; } -export function _resetUnitConsecutiveSkips(): void { unitConsecutiveSkips.clear(); } -export { MAX_CONSECUTIVE_SKIPS }; +export function _getUnitConsecutiveSkips(): Map { return s.unitConsecutiveSkips; } +export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); } +// MAX_CONSECUTIVE_SKIPS re-exported from auto/session.ts at top of file /** * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks. @@ -3359,37 +3302,37 @@ export async function dispatchHookUnit( hookModel: string | undefined, targetBasePath: string, ): Promise { - // Ensure auto-mode is active - if (!active) { + // Ensure auto-mode is s.active + if (!s.active) { // Initialize auto-mode state minimally - active = true; - stepMode = true; - cmdCtx = ctx as ExtensionCommandContext; - basePath = targetBasePath; - autoStartTime = Date.now(); - currentUnit = null; - completedUnits = []; - pendingQuickTasks = []; + s.active = true; + s.stepMode = true; + s.cmdCtx = ctx as ExtensionCommandContext; + s.basePath = targetBasePath; + s.autoStartTime = Date.now(); + s.currentUnit = null; + s.completedUnits = []; + s.pendingQuickTasks = []; } const hookUnitType = `hook/${hookName}`; const hookStartedAt = Date.now(); // Set up the trigger unit as the "current" unit so post-unit hooks can reference it - currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt }; + s.currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt }; // Create a new session for the hook - const result = await cmdCtx!.newSession(); + const result = await s.cmdCtx!.newSession(); if (result.cancelled) { await stopAuto(ctx, pi); return false; } // Update current unit to the hook unit - currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt }; + s.currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt }; // Write runtime record - writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, { + writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, { phase: "dispatched", wrapupWarningSent: false, timeoutAt: null, @@ -3413,17 +3356,17 @@ export async function dispatchHookUnit( // Write lock const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), hookUnitType, triggerUnitId, completedUnits.length, sessionFile); + writeLock(lockBase(), hookUnitType, triggerUnitId, s.completedUnits.length, sessionFile); // Set up timeout clearUnitTimeout(); const supervisor = resolveAutoSupervisorConfig(); const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - unitTimeoutHandle = setTimeout(async () => { - unitTimeoutHandle = null; - if (!active) return; - if (currentUnit) { - writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, { + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + if (s.currentUnit) { + writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, { phase: "timeout", timeoutAt: Date.now(), }); @@ -3437,7 +3380,7 @@ export async function dispatchHookUnit( }, hookHardTimeoutMs); // Update status - ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); // Send the hook prompt diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts new file mode 100644 index 000000000..c061440f9 --- /dev/null +++ b/src/resources/extensions/gsd/auto/session.ts @@ -0,0 +1,224 @@ +/** + * AutoSession — encapsulates all mutable auto-mode state into a single instance. + * + * Replaces ~40 module-level variables scattered across auto.ts with typed + * properties on a class instance. Benefits: + * + * - reset() clears everything in one call (was 25+ manual resets in stopAuto) + * - toJSON() provides diagnostic snapshots + * - grep `s.` shows every state access + * - Constructable for testing + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import type { GitServiceImpl } from "../git-service.js"; +import type { CaptureEntry } from "../captures.js"; +import type { BudgetAlertLevel } from "../auto-budget.js"; + +// ─── Exported Types ────────────────────────────────────────────────────────── + +export interface CompletedUnit { + type: string; + id: string; + startedAt: number; + finishedAt: number; +} + +export interface CurrentUnit { + type: string; + id: string; + startedAt: number; +} + +export interface UnitRouting { + tier: string; + modelDowngraded: boolean; +} + +export interface StartModel { + provider: string; + id: string; +} + +export interface PendingVerificationRetry { + unitId: string; + failureContext: string; + attempt: number; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const MAX_UNIT_DISPATCHES = 3; +export const STUB_RECOVERY_THRESHOLD = 2; +export const MAX_LIFETIME_DISPATCHES = 6; +export const MAX_CONSECUTIVE_SKIPS = 3; +export const DISPATCH_GAP_TIMEOUT_MS = 5_000; +export const MAX_SKIP_DEPTH = 20; + +// ─── AutoSession ───────────────────────────────────────────────────────────── + +export class AutoSession { + // ── Lifecycle ──────────────────────────────────────────────────────────── + active = false; + paused = false; + stepMode = false; + verbose = false; + cmdCtx: ExtensionCommandContext | null = null; + + // ── Paths ──────────────────────────────────────────────────────────────── + basePath = ""; + originalBasePath = ""; + gitService: GitServiceImpl | null = null; + + // ── Dispatch counters ──────────────────────────────────────────────────── + readonly unitDispatchCount = new Map(); + readonly unitLifetimeDispatches = new Map(); + readonly unitRecoveryCount = new Map(); + readonly unitConsecutiveSkips = new Map(); + readonly completedKeySet = new Set(); + + // ── Timers ─────────────────────────────────────────────────────────────── + unitTimeoutHandle: ReturnType | null = null; + wrapupWarningHandle: ReturnType | null = null; + idleWatchdogHandle: ReturnType | null = null; + continueHereHandle: ReturnType | null = null; + dispatchGapHandle: ReturnType | null = null; + + // ── Current unit ───────────────────────────────────────────────────────── + currentUnit: CurrentUnit | null = null; + currentUnitRouting: UnitRouting | null = null; + completedUnits: CompletedUnit[] = []; + currentMilestoneId: string | null = null; + + // ── Model state ────────────────────────────────────────────────────────── + autoModeStartModel: StartModel | null = null; + originalModelId: string | null = null; + originalModelProvider: string | null = null; + lastBudgetAlertLevel: BudgetAlertLevel = 0; + + // ── Recovery ───────────────────────────────────────────────────────────── + pendingCrashRecovery: string | null = null; + pendingVerificationRetry: PendingVerificationRetry | null = null; + readonly verificationRetryCount = new Map(); + pausedSessionFile: string | null = null; + resourceVersionOnStart: string | null = null; + lastStateRebuildAt = 0; + + // ── Guards ─────────────────────────────────────────────────────────────── + handlingAgentEnd = false; + dispatching = false; + skipDepth = 0; + readonly recentlyEvictedKeys = new Set(); + + // ── Metrics ────────────────────────────────────────────────────────────── + autoStartTime = 0; + lastPromptCharCount: number | undefined; + lastBaselineCharCount: number | undefined; + pendingQuickTasks: CaptureEntry[] = []; + + // ── Signal handler ─────────────────────────────────────────────────────── + sigtermHandler: (() => void) | null = null; + + // ── Methods ────────────────────────────────────────────────────────────── + + clearTimers(): void { + if (this.unitTimeoutHandle) { clearTimeout(this.unitTimeoutHandle); this.unitTimeoutHandle = null; } + if (this.wrapupWarningHandle) { clearTimeout(this.wrapupWarningHandle); this.wrapupWarningHandle = null; } + if (this.idleWatchdogHandle) { clearInterval(this.idleWatchdogHandle); this.idleWatchdogHandle = null; } + if (this.continueHereHandle) { clearInterval(this.continueHereHandle); this.continueHereHandle = null; } + if (this.dispatchGapHandle) { clearTimeout(this.dispatchGapHandle); this.dispatchGapHandle = null; } + } + + resetDispatchCounters(): void { + this.unitDispatchCount.clear(); + this.unitLifetimeDispatches.clear(); + this.unitConsecutiveSkips.clear(); + } + + get lockBasePath(): string { + return this.originalBasePath || this.basePath; + } + + completeCurrentUnit(): CompletedUnit | null { + if (!this.currentUnit) return null; + const done: CompletedUnit = { ...this.currentUnit, finishedAt: Date.now() }; + this.completedUnits.push(done); + this.currentUnit = null; + return done; + } + + reset(): void { + this.clearTimers(); + + // Lifecycle + this.active = false; + this.paused = false; + this.stepMode = false; + this.verbose = false; + this.cmdCtx = null; + + // Paths + this.basePath = ""; + this.originalBasePath = ""; + this.gitService = null; + + // Dispatch + this.unitDispatchCount.clear(); + this.unitLifetimeDispatches.clear(); + this.unitRecoveryCount.clear(); + this.unitConsecutiveSkips.clear(); + // Note: completedKeySet is intentionally NOT cleared — it persists + // across restarts to prevent re-dispatching completed units. + + // Unit + this.currentUnit = null; + this.currentUnitRouting = null; + this.completedUnits = []; + this.currentMilestoneId = null; + + // Model + this.autoModeStartModel = null; + this.originalModelId = null; + this.originalModelProvider = null; + this.lastBudgetAlertLevel = 0; + + // Recovery + this.pendingCrashRecovery = null; + this.pendingVerificationRetry = null; + this.verificationRetryCount.clear(); + this.pausedSessionFile = null; + this.resourceVersionOnStart = null; + this.lastStateRebuildAt = 0; + + // Guards + this.handlingAgentEnd = false; + this.dispatching = false; + this.skipDepth = 0; + this.recentlyEvictedKeys.clear(); + + // Metrics + this.autoStartTime = 0; + this.lastPromptCharCount = undefined; + this.lastBaselineCharCount = undefined; + this.pendingQuickTasks = []; + + // Signal handler + this.sigtermHandler = null; + } + + toJSON(): Record { + return { + active: this.active, + paused: this.paused, + stepMode: this.stepMode, + basePath: this.basePath, + currentMilestoneId: this.currentMilestoneId, + currentUnit: this.currentUnit, + completedUnits: this.completedUnits.length, + completedKeySet: this.completedKeySet.size, + unitDispatchCount: Object.fromEntries(this.unitDispatchCount), + dispatching: this.dispatching, + skipDepth: this.skipDepth, + }; + } +} diff --git a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts index 514a0dc0c..66ff99ab7 100644 --- a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts @@ -134,7 +134,7 @@ test("auto.ts milestone transition block contains worktree lifecycle", () => { "auto.ts should contain the worktree lifecycle comment marker", ); assert.ok( - autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== currentMilestoneId"), + autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== s.currentMilestoneId"), "auto.ts should call mergeMilestoneToMain during milestone transition", ); assert.ok( diff --git a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts index 57db9f1a8..604dd2c39 100644 --- a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts @@ -66,7 +66,7 @@ test("dispatch: triage check guards against step mode", () => { autoSrc.indexOf("In step mode, pause and show a wizard"), ); assert.ok( - triageBlock.includes("!stepMode"), + triageBlock.includes("!s.stepMode"), "triage block should guard against step mode", ); }); @@ -77,7 +77,7 @@ test("dispatch: triage check guards against hook unit types", () => { autoSrc.indexOf("In step mode, pause and show a wizard"), ); assert.ok( - triageBlock.includes('!currentUnit.type.startsWith("hook/")'), + triageBlock.includes('!s.currentUnit.type.startsWith("hook/")'), "triage block should not fire for hook units", ); }); @@ -88,7 +88,7 @@ test("dispatch: triage check guards against triage-on-triage", () => { autoSrc.indexOf("In step mode, pause and show a wizard"), ); assert.ok( - triageBlock.includes('currentUnit.type !== "triage-captures"'), + triageBlock.includes('s.currentUnit.type !== "triage-captures"'), "triage block should not fire for triage units", ); }); @@ -99,7 +99,7 @@ test("dispatch: triage check guards against quick-task triggering triage", () => autoSrc.indexOf("In step mode, pause and show a wizard"), ); assert.ok( - triageBlock.includes('currentUnit.type !== "quick-task"'), + triageBlock.includes('s.currentUnit.type !== "quick-task"'), "triage block should not fire for quick-task units", ); }); @@ -231,7 +231,7 @@ test("dispatch: post-triage resolution executor fires after triage-captures unit autoSrc.indexOf("Path A fix: verify artifact"), ); assert.ok( - triageCompletionBlock.includes('currentUnit.type === "triage-captures"'), + triageCompletionBlock.includes('s.currentUnit.type === "triage-captures"'), "should check for triage-captures unit completion", ); assert.ok( @@ -268,8 +268,8 @@ test("dispatch: post-triage executor queues quick-tasks", () => { autoSrc.indexOf("Path A fix: verify artifact"), ); assert.ok( - triageCompletionBlock.includes("pendingQuickTasks"), - "should push quick-tasks to pendingQuickTasks queue", + triageCompletionBlock.includes("s.pendingQuickTasks"), + "should push quick-tasks to s.pendingQuickTasks queue", ); }); @@ -336,9 +336,9 @@ test("dispatch: quick-task excluded from post-unit hook triggering", () => { // ─── pendingQuickTasks queue lifecycle ──────────────────────────────────────── test("dispatch: pendingQuickTasks queue is reset on auto-mode start/stop", () => { - const resetMatches = autoSrc.match(/pendingQuickTasks = \[\]/g); + const resetMatches = autoSrc.match(/s\.pendingQuickTasks = \[\]/g); assert.ok( resetMatches && resetMatches.length >= 3, - "pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)", + "s.pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)", ); });