diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 7d1228873..6c22d38ef 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -624,6 +624,9 @@ export async function bootstrapAutoSession( s.consecutiveCompleteBootstraps = 0; // ── Initialize session state ── + // Notify shared phase state so subagent conflict checks can fire + const { activateGSD: activateGSDPhaseState } = await import("../shared/gsd-phase-state.js"); + activateGSDPhaseState(); s.active = true; s.stepMode = requestedStepMode; s.verbose = verboseMode; diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 3eae4ce69..18cad0d22 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -115,6 +115,7 @@ import { resetSkillTelemetry, } from "./skill-telemetry.js"; import { getRtkSessionSavings } from "../shared/rtk-session-stats.js"; +import { deactivateGSD } from "../shared/gsd-phase-state.js"; import { initMetrics, resetMetrics, @@ -622,6 +623,7 @@ function handleLostSessionLock( }); s.active = false; s.paused = false; + deactivateGSD(); clearUnitTimeout(); restoreProjectRootEnv(); restoreMilestoneLockEnv(); @@ -659,6 +661,7 @@ function handleLostSessionLock( function cleanupAfterLoopExit(ctx: ExtensionContext): void { s.currentUnit = null; s.active = false; + deactivateGSD(); clearUnitTimeout(); restoreProjectRootEnv(); restoreMilestoneLockEnv(); @@ -1024,6 +1027,7 @@ export async function pauseAuto( s.active = false; s.paused = true; + deactivateGSD(); restoreProjectRootEnv(); restoreMilestoneLockEnv(); s.pendingVerificationRetry = null; diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 71283447c..8151b4f3e 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -27,6 +27,7 @@ import { runUnit } from "./run-unit.js"; import { debugLog } from "../debug-logger.js"; import { PROJECT_FILES } from "../detection.js"; import { MergeConflictError } from "../git-service.js"; +import { setCurrentPhase, clearCurrentPhase } from "../../shared/gsd-phase-state.js"; import { join, basename, dirname, parse as parsePath } from "node:path"; import { existsSync, cpSync, readdirSync } from "node:fs"; import { logWarning, logError } from "../workflow-logger.js"; @@ -1068,6 +1069,7 @@ export async function runUnitPhase( const previousTier = s.currentUnitRouting?.tier; s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + setCurrentPhase(unitType); s.lastToolInvocationError = null; // #2883: clear stale error from previous unit const unitStartSeq = ic.nextSeq(); deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: unitStartSeq, eventType: "unit-start", data: { unitType, unitId } }); @@ -1529,6 +1531,7 @@ export async function runFinalize( // Detach session from the timed-out unit so late async completions // cannot mutate state for the next unit (#3757). s.currentUnit = null; + clearCurrentPhase(); loopState.consecutiveFinalizeTimeouts++; debugLog("autoLoop", { phase: "pre-verification-timeout", @@ -1626,6 +1629,7 @@ export async function runFinalize( // Detach session from the timed-out unit so late async completions // cannot mutate state for the next unit (#3757). s.currentUnit = null; + clearCurrentPhase(); loopState.consecutiveFinalizeTimeouts++; debugLog("autoLoop", { phase: "post-verification-timeout", diff --git a/src/resources/extensions/shared/gsd-phase-state.ts b/src/resources/extensions/shared/gsd-phase-state.ts new file mode 100644 index 000000000..360410e2a --- /dev/null +++ b/src/resources/extensions/shared/gsd-phase-state.ts @@ -0,0 +1,42 @@ +/** + * GSD Phase State — cross-extension coordination + * Copyright (c) 2026 Jeremy McSpadden + * + * Lightweight module-level state that GSD auto-mode writes to and the + * subagent tool reads from. Both extensions run in the same process so + * a module variable is sufficient — no file I/O needed. + */ + +let _active = false; +let _currentPhase: string | null = null; + +/** Mark GSD auto-mode as active. */ +export function activateGSD(): void { + _active = true; +} + +/** Mark GSD auto-mode as inactive and clear the current phase. */ +export function deactivateGSD(): void { + _active = false; + _currentPhase = null; +} + +/** Set the currently dispatched GSD phase (e.g. "plan-milestone"). */ +export function setCurrentPhase(phase: string): void { + _currentPhase = phase; +} + +/** Clear the current phase (unit completed or aborted). */ +export function clearCurrentPhase(): void { + _currentPhase = null; +} + +/** Returns true if GSD auto-mode is currently active. */ +export function isGSDActive(): boolean { + return _active; +} + +/** Returns the current GSD phase, or null if none is active. */ +export function getCurrentPhase(): string | null { + return _active ? _currentPhase : null; +} diff --git a/src/resources/extensions/shared/tests/gsd-phase-state.test.ts b/src/resources/extensions/shared/tests/gsd-phase-state.test.ts new file mode 100644 index 000000000..2047c3cf6 --- /dev/null +++ b/src/resources/extensions/shared/tests/gsd-phase-state.test.ts @@ -0,0 +1,48 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + activateGSD, + deactivateGSD, + setCurrentPhase, + clearCurrentPhase, + isGSDActive, + getCurrentPhase, +} from "../gsd-phase-state.js"; + +describe("gsd-phase-state", () => { + beforeEach(() => { + deactivateGSD(); + }); + + it("tracks active/inactive state", () => { + assert.equal(isGSDActive(), false); + activateGSD(); + assert.equal(isGSDActive(), true); + deactivateGSD(); + assert.equal(isGSDActive(), false); + }); + + it("tracks the current phase when active", () => { + activateGSD(); + assert.equal(getCurrentPhase(), null); + setCurrentPhase("plan-milestone"); + assert.equal(getCurrentPhase(), "plan-milestone"); + clearCurrentPhase(); + assert.equal(getCurrentPhase(), null); + }); + + it("returns null phase when inactive even if phase was set", () => { + activateGSD(); + setCurrentPhase("plan-milestone"); + deactivateGSD(); + assert.equal(getCurrentPhase(), null); + }); + + it("deactivation clears the current phase", () => { + activateGSD(); + setCurrentPhase("execute-task"); + deactivateGSD(); + activateGSD(); + assert.equal(getCurrentPhase(), null); + }); +}); diff --git a/src/resources/extensions/subagent/agents.ts b/src/resources/extensions/subagent/agents.ts index 6f14c3bcf..7f69f3f18 100644 --- a/src/resources/extensions/subagent/agents.ts +++ b/src/resources/extensions/subagent/agents.ts @@ -15,6 +15,7 @@ export interface AgentConfig { description: string; tools?: string[]; model?: string; + conflictsWith?: string[]; systemPrompt: string; source: "user" | "project"; filePath: string; @@ -30,6 +31,13 @@ interface AgentFrontmatter extends Record { description?: string; tools?: string | string[]; model?: string; + conflicts_with?: string; +} + +export function parseConflictsWith(value: string | undefined): string[] | undefined { + if (typeof value !== "string") return undefined; + const conflicts = value.split(",").map((s) => s.trim()).filter(Boolean); + return conflicts.length > 0 ? conflicts : undefined; } function parseAgentTools(value: string | string[] | undefined): string[] | undefined { @@ -85,12 +93,14 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig } const tools = parseAgentTools(frontmatter.tools); + const conflictsWith = parseConflictsWith(frontmatter.conflicts_with); agents.push({ name: frontmatter.name, description: frontmatter.description, tools: tools && tools.length > 0 ? tools : undefined, model: frontmatter.model, + conflictsWith, systemPrompt: body, source, filePath, diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index 62b60757f..8bca18bf7 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -24,6 +24,7 @@ import { type ExtensionAPI, getMarkdownTheme } from "@gsd/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@gsd/pi-tui"; import { Type } from "@sinclair/typebox"; import { formatTokenCount } from "../shared/mod.js"; +import { getCurrentPhase } from "../shared/gsd-phase-state.js"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; import { type IsolationEnvironment, @@ -352,6 +353,23 @@ async function runSingleAgent( }; } + // GSD phase guard: block agents that conflict with the active GSD phase + if (agent.conflictsWith && agent.conflictsWith.length > 0) { + const activePhase = getCurrentPhase(); + if (activePhase && agent.conflictsWith.includes(activePhase)) { + return { + agent: agentName, + agentSource: agent.source, + task, + exitCode: 1, + messages: [], + stderr: `Agent "${agentName}" is blocked: it conflicts with the active GSD phase "${activePhase}". Use the built-in GSD workflow instead.`, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + step, + }; + } + } + let tmpPromptDir: string | null = null; let tmpPromptPath: string | null = null; diff --git a/src/resources/extensions/subagent/tests/agents-conflicts.test.ts b/src/resources/extensions/subagent/tests/agents-conflicts.test.ts new file mode 100644 index 000000000..c6b00b382 --- /dev/null +++ b/src/resources/extensions/subagent/tests/agents-conflicts.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseConflictsWith } from "../agents.js"; + +describe("parseConflictsWith", () => { + it("parses comma-separated conflict list", () => { + const result = parseConflictsWith("plan-milestone, plan-slice, research-milestone"); + assert.deepEqual(result, ["plan-milestone", "plan-slice", "research-milestone"]); + }); + + it("returns undefined for undefined input", () => { + assert.equal(parseConflictsWith(undefined), undefined); + }); + + it("returns undefined for empty string", () => { + assert.equal(parseConflictsWith(""), undefined); + }); + + it("handles single value without commas", () => { + const result = parseConflictsWith("plan-milestone"); + assert.deepEqual(result, ["plan-milestone"]); + }); + + it("trims whitespace from values", () => { + const result = parseConflictsWith(" plan-milestone , plan-slice "); + assert.deepEqual(result, ["plan-milestone", "plan-slice"]); + }); + + it("filters out empty entries from trailing commas", () => { + const result = parseConflictsWith("plan-milestone,,plan-slice,"); + assert.deepEqual(result, ["plan-milestone", "plan-slice"]); + }); +});