fix(gsd): enforce workflow write gates over MCP

This commit is contained in:
Jeremy 2026-04-09 14:42:38 -05:00
parent c297559211
commit 20cbc1ed37
7 changed files with 323 additions and 8 deletions

View file

@ -27,6 +27,26 @@ function cleanup(base: string): void {
}
}
function writeWriteGateSnapshot(
base: string,
snapshot: { verifiedDepthMilestones?: string[]; activeQueuePhase?: boolean; pendingGateId?: string | null },
): void {
mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
writeFileSync(
join(base, ".gsd", "runtime", "write-gate-state.json"),
JSON.stringify(
{
verifiedDepthMilestones: snapshot.verifiedDepthMilestones ?? [],
activeQueuePhase: snapshot.activeQueuePhase ?? false,
pendingGateId: snapshot.pendingGateId ?? null,
},
null,
2,
),
"utf-8",
);
}
function makeMockServer() {
const tools: Array<{
name: string;
@ -176,6 +196,72 @@ describe("workflow MCP tools", () => {
}
});
it("blocks workflow mutation tools while a discussion gate is pending", async () => {
const base = makeTmpBase();
try {
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
writeFileSync(
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
"# S01\n\n- [ ] **T01: Demo** `est:5m`\n",
);
writeWriteGateSnapshot(base, { pendingGateId: "depth_verification_M001_confirm" });
const server = makeMockServer();
registerWorkflowTools(server as any);
const taskTool = server.tools.find((t) => t.name === "gsd_task_complete");
assert.ok(taskTool, "task tool should be registered");
await assert.rejects(
() =>
taskTool!.handler({
projectDir: base,
taskId: "T01",
sliceId: "S01",
milestoneId: "M001",
oneLiner: "Completed task",
narrative: "Did the work",
verification: "npm test",
}),
/Discussion gate .* has not been confirmed/,
);
} finally {
cleanup(base);
}
});
it("blocks workflow mutation tools during queue mode", async () => {
const base = makeTmpBase();
try {
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
writeFileSync(
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
"# S01\n\n- [ ] **T01: Demo** `est:5m`\n",
);
writeWriteGateSnapshot(base, { activeQueuePhase: true });
const server = makeMockServer();
registerWorkflowTools(server as any);
const taskTool = server.tools.find((t) => t.name === "gsd_task_complete");
assert.ok(taskTool, "task tool should be registered");
await assert.rejects(
() =>
taskTool!.handler({
projectDir: base,
taskId: "T01",
sliceId: "S01",
milestoneId: "M001",
oneLiner: "Completed task",
narrative: "Did the work",
verification: "npm test",
}),
/planning tool .* not executes work|Cannot gsd_task_complete|Unknown tools are not permitted during queue mode/,
);
} finally {
cleanup(base);
}
});
it("gsd_task_complete and gsd_milestone_status work end-to-end", async () => {
const base = makeTmpBase();
try {

View file

@ -216,8 +216,37 @@ type WorkflowToolExecutors = {
) => Promise<unknown>;
};
type WorkflowWriteGateModule = {
loadWriteGateSnapshot: (basePath?: string) => {
verifiedDepthMilestones: string[];
activeQueuePhase: boolean;
pendingGateId: string | null;
};
shouldBlockPendingGateInSnapshot: (
snapshot: {
verifiedDepthMilestones: string[];
activeQueuePhase: boolean;
pendingGateId: string | null;
},
toolName: string,
milestoneId: string | null,
queuePhaseActive?: boolean,
) => { block: boolean; reason?: string };
shouldBlockQueueExecutionInSnapshot: (
snapshot: {
verifiedDepthMilestones: string[];
activeQueuePhase: boolean;
pendingGateId: string | null;
},
toolName: string,
input: string,
queuePhaseActive?: boolean,
) => { block: boolean; reason?: string };
};
let workflowToolExecutorsPromise: Promise<WorkflowToolExecutors> | null = null;
let workflowExecutionQueue: Promise<void> = Promise.resolve();
let workflowWriteGatePromise: Promise<WorkflowWriteGateModule> | null = null;
function getAllowedProjectRoot(env: NodeJS.ProcessEnv = process.env): string | null {
const configuredRoot = env.GSD_WORKFLOW_PROJECT_ROOT?.trim();
@ -285,6 +314,13 @@ function getSupportedSummaryArtifactTypes(executors: WorkflowToolExecutors): rea
return executors.SUPPORTED_SUMMARY_ARTIFACT_TYPES;
}
function getWriteGateModuleCandidates(): string[] {
return [
new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url).href,
new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.ts", import.meta.url).href,
];
}
function toFileUrl(modulePath: string): string {
return pathToFileURL(resolve(modulePath)).href;
}
@ -334,6 +370,36 @@ async function getWorkflowToolExecutors(): Promise<WorkflowToolExecutors> {
return workflowToolExecutorsPromise;
}
async function getWorkflowWriteGateModule(): Promise<WorkflowWriteGateModule> {
if (!workflowWriteGatePromise) {
workflowWriteGatePromise = (async () => {
const attempts: string[] = [];
for (const candidate of getWriteGateModuleCandidates()) {
try {
const loaded = await import(candidate);
if (
loaded &&
typeof loaded.loadWriteGateSnapshot === "function" &&
typeof loaded.shouldBlockPendingGateInSnapshot === "function" &&
typeof loaded.shouldBlockQueueExecutionInSnapshot === "function"
) {
return loaded as WorkflowWriteGateModule;
}
attempts.push(`${candidate} (module shape mismatch)`);
} catch (err) {
attempts.push(`${candidate} (${err instanceof Error ? err.message : String(err)})`);
}
}
throw new Error(
"Unable to load GSD write-gate bridge for workflow MCP tools. " +
`Attempts: ${attempts.join("; ")}`,
);
})();
}
return workflowWriteGatePromise;
}
interface McpToolServer {
tool(
name: string,
@ -360,10 +426,39 @@ async function runSerializedWorkflowOperation<T>(fn: () => Promise<T>): Promise<
}
}
async function enforceWorkflowWriteGate(
toolName: string,
projectDir: string,
milestoneId: string | null = null,
): Promise<void> {
const writeGate = await getWorkflowWriteGateModule();
const snapshot = writeGate.loadWriteGateSnapshot(projectDir);
const pendingGate = writeGate.shouldBlockPendingGateInSnapshot(
snapshot,
toolName,
milestoneId,
snapshot.activeQueuePhase,
);
if (pendingGate.block) {
throw new Error(pendingGate.reason ?? "workflow tool blocked by pending discussion gate");
}
const queueGuard = writeGate.shouldBlockQueueExecutionInSnapshot(
snapshot,
toolName,
"",
snapshot.activeQueuePhase,
);
if (queueGuard.block) {
throw new Error(queueGuard.reason ?? "workflow tool blocked during queue mode");
}
}
async function handleTaskComplete(
projectDir: string,
args: Omit<z.infer<typeof taskCompleteSchema>, "projectDir">,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_task_complete", projectDir, args.milestoneId);
const {
taskId,
sliceId,
@ -404,6 +499,7 @@ async function handleSliceComplete(
projectDir: string,
args: z.infer<typeof sliceCompleteSchema>,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_slice_complete", projectDir, args.milestoneId);
const { executeSliceComplete } = await getWorkflowToolExecutors();
const { projectDir: _projectDir, ...params } = args;
return runSerializedWorkflowOperation(() => executeSliceComplete(params, projectDir));
@ -413,6 +509,7 @@ async function handleReplanSlice(
projectDir: string,
args: z.infer<typeof replanSliceSchema>,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_replan_slice", projectDir, args.milestoneId);
const { executeReplanSlice } = await getWorkflowToolExecutors();
const { projectDir: _projectDir, ...params } = args;
return runSerializedWorkflowOperation(() => executeReplanSlice(params, projectDir));
@ -422,6 +519,7 @@ async function handleCompleteMilestone(
projectDir: string,
args: z.infer<typeof completeMilestoneSchema>,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_complete_milestone", projectDir, args.milestoneId);
const { executeCompleteMilestone } = await getWorkflowToolExecutors();
const { projectDir: _projectDir, ...params } = args;
return runSerializedWorkflowOperation(() => executeCompleteMilestone(params, projectDir));
@ -431,6 +529,7 @@ async function handleValidateMilestone(
projectDir: string,
args: z.infer<typeof validateMilestoneSchema>,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_validate_milestone", projectDir, args.milestoneId);
const { executeValidateMilestone } = await getWorkflowToolExecutors();
const { projectDir: _projectDir, ...params } = args;
return runSerializedWorkflowOperation(() => executeValidateMilestone(params, projectDir));
@ -440,6 +539,7 @@ async function handleReassessRoadmap(
projectDir: string,
args: z.infer<typeof reassessRoadmapSchema>,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_reassess_roadmap", projectDir, args.milestoneId);
const { executeReassessRoadmap } = await getWorkflowToolExecutors();
const { projectDir: _projectDir, ...params } = args;
return runSerializedWorkflowOperation(() => executeReassessRoadmap(params, projectDir));
@ -449,6 +549,7 @@ async function handleSaveGateResult(
projectDir: string,
args: z.infer<typeof saveGateResultSchema>,
): Promise<unknown> {
await enforceWorkflowWriteGate("gsd_save_gate_result", projectDir, args.milestoneId);
const { executeSaveGateResult } = await getWorkflowToolExecutors();
const { projectDir: _projectDir, ...params } = args;
return runSerializedWorkflowOperation(() => executeSaveGateResult(params, projectDir));
@ -699,6 +800,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(planMilestoneSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_plan_milestone", projectDir, params.milestoneId);
const { executePlanMilestone } = await getWorkflowToolExecutors();
return runSerializedWorkflowOperation(() => executePlanMilestone(params, projectDir));
},
@ -711,6 +813,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(planSliceSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_plan_slice", projectDir, params.milestoneId);
const { executePlanSlice } = await getWorkflowToolExecutors();
return runSerializedWorkflowOperation(() => executePlanSlice(params, projectDir));
},
@ -833,6 +936,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(summarySaveSchema, args);
const { projectDir, milestone_id, slice_id, task_id, artifact_type, content } = parsed;
await enforceWorkflowWriteGate("gsd_summary_save", projectDir, milestone_id);
const executors = await getWorkflowToolExecutors();
const supportedArtifactTypes = getSupportedSummaryArtifactTypes(executors);
if (!supportedArtifactTypes.includes(artifact_type)) {
@ -874,6 +978,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
milestoneStatusParams,
async (args: Record<string, unknown>) => {
const { projectDir, milestoneId } = parseWorkflowArgs(milestoneStatusSchema, args);
await enforceWorkflowWriteGate("gsd_milestone_status", projectDir, milestoneId);
const { executeMilestoneStatus } = await getWorkflowToolExecutors();
return runSerializedWorkflowOperation(() => executeMilestoneStatus({ milestoneId }, projectDir));
},

View file

@ -153,6 +153,7 @@ describe("stream-adapter — session persistence (#2859)", () => {
args: ["packages/mcp-server/dist/cli.js"],
env: {
GSD_CLI_PATH: "/tmp/gsd",
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: "/tmp/project",
},
cwd: "/tmp/project",
@ -230,6 +231,7 @@ describe("stream-adapter — session persistence (#2859)", () => {
args: [realpathSync(resolve(repoDir, "packages", "mcp-server", "dist", "cli.js"))],
env: {
GSD_CLI_PATH: "/tmp/gsd",
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolvedRepoDir,
},
cwd: resolvedRepoDir,

View file

@ -1,3 +1,6 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const MILESTONE_CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/;
const CONTEXT_MILESTONE_RE = /(?:^|[/\\])(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/i;
const DEPTH_VERIFICATION_MILESTONE_RE = /depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i;
@ -65,6 +68,69 @@ const GATE_SAFE_TOOLS = new Set([
"search_and_read",
]);
export interface WriteGateSnapshot {
verifiedDepthMilestones: string[];
activeQueuePhase: boolean;
pendingGateId: string | null;
}
function shouldPersistWriteGateSnapshot(env: NodeJS.ProcessEnv = process.env): boolean {
return env.GSD_PERSIST_WRITE_GATE_STATE === "1";
}
function writeGateSnapshotPath(basePath: string = process.cwd()): string {
return join(basePath, ".gsd", "runtime", "write-gate-state.json");
}
function currentWriteGateSnapshot(): WriteGateSnapshot {
return {
verifiedDepthMilestones: [...verifiedDepthMilestones].sort(),
activeQueuePhase,
pendingGateId,
};
}
function persistWriteGateSnapshot(basePath: string = process.cwd()): void {
if (!shouldPersistWriteGateSnapshot()) return;
const path = writeGateSnapshotPath(basePath);
mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
const tempPath = `${path}.tmp`;
writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(), null, 2), "utf-8");
renameSync(tempPath, path);
}
function clearPersistedWriteGateSnapshot(basePath: string = process.cwd()): void {
if (!shouldPersistWriteGateSnapshot()) return;
const path = writeGateSnapshotPath(basePath);
try {
unlinkSync(path);
} catch {
// swallow
}
}
function normalizeWriteGateSnapshot(value: unknown): WriteGateSnapshot {
const record = value && typeof value === "object" ? value as Record<string, unknown> : {};
const verified = Array.isArray(record.verifiedDepthMilestones)
? record.verifiedDepthMilestones.filter((item): item is string => typeof item === "string")
: [];
return {
verifiedDepthMilestones: [...new Set(verified)].sort(),
activeQueuePhase: record.activeQueuePhase === true,
pendingGateId: typeof record.pendingGateId === "string" ? record.pendingGateId : null,
};
}
export function loadWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
const path = writeGateSnapshotPath(basePath);
if (!existsSync(path)) return currentWriteGateSnapshot();
try {
return normalizeWriteGateSnapshot(JSON.parse(readFileSync(path, "utf-8")));
} catch {
return currentWriteGateSnapshot();
}
}
export function isDepthVerified(): boolean {
return verifiedDepthMilestones.size > 0;
}
@ -77,28 +143,40 @@ export function isMilestoneDepthVerified(milestoneId: string | null | undefined)
return verifiedDepthMilestones.has(milestoneId);
}
export function isMilestoneDepthVerifiedInSnapshot(
snapshot: WriteGateSnapshot,
milestoneId: string | null | undefined,
): boolean {
if (!milestoneId) return false;
return snapshot.verifiedDepthMilestones.includes(milestoneId);
}
export function isQueuePhaseActive(): boolean {
return activeQueuePhase;
}
export function setQueuePhaseActive(active: boolean): void {
activeQueuePhase = active;
persistWriteGateSnapshot();
}
export function resetWriteGateState(): void {
verifiedDepthMilestones.clear();
pendingGateId = null;
persistWriteGateSnapshot();
}
export function clearDiscussionFlowState(): void {
verifiedDepthMilestones.clear();
activeQueuePhase = false;
pendingGateId = null;
clearPersistedWriteGateSnapshot();
}
export function markDepthVerified(milestoneId?: string | null): void {
export function markDepthVerified(milestoneId?: string | null, basePath: string = process.cwd()): void {
if (!milestoneId) return;
verifiedDepthMilestones.add(milestoneId);
persistWriteGateSnapshot(basePath);
}
/**
@ -130,6 +208,7 @@ function extractContextMilestoneId(inputPath: string): string | null {
*/
export function setPendingGate(gateId: string): void {
pendingGateId = gateId;
persistWriteGateSnapshot();
}
/**
@ -137,6 +216,7 @@ export function setPendingGate(gateId: string): void {
*/
export function clearPendingGate(): void {
pendingGateId = null;
persistWriteGateSnapshot();
}
/**
@ -154,11 +234,20 @@ export function getPendingGate(): string | null {
* Read-only tools and ask_user_questions itself are always allowed.
*/
export function shouldBlockPendingGate(
toolName: string,
milestoneId: string | null,
queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
return shouldBlockPendingGateInSnapshot(currentWriteGateSnapshot(), toolName, milestoneId, queuePhaseActive);
}
export function shouldBlockPendingGateInSnapshot(
snapshot: WriteGateSnapshot,
toolName: string,
_milestoneId: string | null,
_queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
if (!pendingGateId) return { block: false };
if (!snapshot.pendingGateId) return { block: false };
if (GATE_SAFE_TOOLS.has(toolName)) return { block: false };
@ -168,7 +257,7 @@ export function shouldBlockPendingGate(
return {
block: true,
reason: [
`HARD BLOCK: Discussion gate "${pendingGateId}" has not been confirmed by the user.`,
`HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`,
`You MUST re-call ask_user_questions with the gate question before making any other tool calls.`,
`If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`,
`did not match a provided option, you MUST re-ask — never rationalize past the block.`,
@ -182,11 +271,20 @@ export function shouldBlockPendingGate(
* Read-only bash commands are allowed; mutating commands are blocked.
*/
export function shouldBlockPendingGateBash(
command: string,
milestoneId: string | null,
queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
return shouldBlockPendingGateBashInSnapshot(currentWriteGateSnapshot(), command, milestoneId, queuePhaseActive);
}
export function shouldBlockPendingGateBashInSnapshot(
snapshot: WriteGateSnapshot,
command: string,
_milestoneId: string | null,
_queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
if (!pendingGateId) return { block: false };
if (!snapshot.pendingGateId) return { block: false };
// Allow read-only bash commands
if (BASH_READ_ONLY_RE.test(command)) return { block: false };
@ -194,7 +292,7 @@ export function shouldBlockPendingGateBash(
return {
block: true,
reason: [
`HARD BLOCK: Discussion gate "${pendingGateId}" has not been confirmed by the user.`,
`HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`,
`You MUST re-call ask_user_questions with the gate question before running mutating commands.`,
`If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`,
`did not match a provided option, you MUST re-ask — never rationalize past the block.`,
@ -275,6 +373,15 @@ export function shouldBlockContextArtifactSave(
artifactType: string,
milestoneId: string | null,
sliceId?: string | null,
): { block: boolean; reason?: string } {
return shouldBlockContextArtifactSaveInSnapshot(currentWriteGateSnapshot(), artifactType, milestoneId, sliceId);
}
export function shouldBlockContextArtifactSaveInSnapshot(
snapshot: WriteGateSnapshot,
artifactType: string,
milestoneId: string | null,
sliceId?: string | null,
): { block: boolean; reason?: string } {
if (artifactType !== "CONTEXT") return { block: false };
if (sliceId) return { block: false };
@ -287,7 +394,7 @@ export function shouldBlockContextArtifactSave(
].join(" "),
};
}
if (isMilestoneDepthVerified(milestoneId)) return { block: false };
if (isMilestoneDepthVerifiedInSnapshot(snapshot, milestoneId)) return { block: false };
return {
block: true,
@ -317,6 +424,15 @@ export function shouldBlockQueueExecution(
toolName: string,
input: string,
queuePhaseActive: boolean,
): { block: boolean; reason?: string } {
return shouldBlockQueueExecutionInSnapshot(currentWriteGateSnapshot(), toolName, input, queuePhaseActive);
}
export function shouldBlockQueueExecutionInSnapshot(
snapshot: WriteGateSnapshot,
toolName: string,
input: string,
queuePhaseActive: boolean = snapshot.activeQueuePhase,
): { block: boolean; reason?: string } {
if (!queuePhaseActive) return { block: false };

View file

@ -46,6 +46,7 @@ test("detectWorkflowMcpLaunchConfig prefers explicit env override", () => {
env: {
FOO: "bar",
GSD_CLI_PATH: "/tmp/gsd",
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: "/tmp/project",
},
});
@ -62,6 +63,7 @@ test("buildWorkflowMcpServers mirrors explicit launch config", () => {
command: "node",
args: ["dist/cli.js"],
env: {
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: "/tmp/project",
},
},

View file

@ -1,6 +1,6 @@
import { ensureDbOpen } from "../bootstrap/dynamic-tools.js";
import { sanitizeCompleteMilestoneParams } from "../bootstrap/sanitize-complete-milestone.js";
import { shouldBlockContextArtifactSave } from "../bootstrap/write-gate.js";
import { loadWriteGateSnapshot, shouldBlockContextArtifactSaveInSnapshot } from "../bootstrap/write-gate.js";
import {
getMilestone,
getSliceStatusSummary,
@ -65,7 +65,8 @@ export async function executeSummarySave(
details: { operation: "save_summary", error: "invalid_artifact_type" },
};
}
const contextGuard = shouldBlockContextArtifactSave(
const contextGuard = shouldBlockContextArtifactSaveInSnapshot(
loadWriteGateSnapshot(basePath),
params.artifact_type,
params.milestone_id ?? null,
params.slice_id ?? null,

View file

@ -85,6 +85,7 @@ export function detectWorkflowMcpLaunchConfig(
const launchEnv = {
...(explicitEnv ?? {}),
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolve(workflowProjectRoot),
};
return {
@ -105,6 +106,7 @@ export function detectWorkflowMcpLaunchConfig(
cwd: projectRoot,
env: {
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot),
},
};
@ -117,6 +119,7 @@ export function detectWorkflowMcpLaunchConfig(
command: binPath,
env: {
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot),
},
};