diff --git a/packages/pi-ai/src/models.generated.ts b/packages/pi-ai/src/models.generated.ts index b1fcbd816..fe3112ede 100644 --- a/packages/pi-ai/src/models.generated.ts +++ b/packages/pi-ai/src/models.generated.ts @@ -6413,195 +6413,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 4096, - }, - "aion-labs/aion-1.0": { - id: "aion-labs/aion-1.0", - name: "AionLabs: Aion-1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 4, - output: 8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 32768, - }, - "aion-labs/aion-1.0-mini": { - id: "aion-labs/aion-1.0-mini", - name: "AionLabs: Aion-1.0-Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.7, - output: 1.4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 32768, - }, - "aion-labs/aion-2.0": { - id: "aion-labs/aion-2.0", - name: "AionLabs: Aion-2.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.8, - output: 1.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 32768, - }, - "aion-labs/aion-rp-llama-3.1-8b": { - id: "aion-labs/aion-rp-llama-3.1-8b", - name: "AionLabs: Aion-RP 1.0 (8B)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.8, - output: 1.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 32768, - }, - "alfredpros/codellama-7b-instruct-solidity": { - id: "alfredpros/codellama-7b-instruct-solidity", - name: "AlfredPros: CodeLLaMa 7B Instruct Solidity", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.8, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4096, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "alibaba/tongyi-deepresearch-30b-a3b": { id: "alibaba/tongyi-deepresearch-30b-a3b", name: "Tongyi DeepResearch 30B A3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.09, - output: 0.45, - cacheRead: 0, + output: 0.44999999999999996, + cacheRead: 0.09, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, - "allenai/molmo-2-8b": { - id: "allenai/molmo-2-8b", - name: "AllenAI: Molmo2 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 36864, - maxOutput: 36864, - }, - "allenai/olmo-2-0325-32b-instruct": { - id: "allenai/olmo-2-0325-32b-instruct", - name: "AllenAI: Olmo 2 32B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, - "allenai/olmo-3-32b-think": { - id: "allenai/olmo-3-32b-think", - name: "AllenAI: Olmo 3 32B Think", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 65536, - }, - "allenai/olmo-3-7b-instruct": { - id: "allenai/olmo-3-7b-instruct", - name: "AllenAI: Olmo 3 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 65536, - }, - "allenai/olmo-3-7b-think": { - id: "allenai/olmo-3-7b-think", - name: "AllenAI: Olmo 3 7B Think", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.12, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 65536, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "allenai/olmo-3.1-32b-instruct": { id: "allenai/olmo-3.1-32b-instruct", name: "AllenAI: Olmo 3.1 32B Instruct", @@ -6611,55 +6441,21 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 0.6, cacheRead: 0, cacheWrite: 0, }, contextWindow: 65536, - maxOutput: 16384, - }, - "allenai/olmo-3.1-32b-think": { - id: "allenai/olmo-3.1-32b-think", - name: "AllenAI: Olmo 3.1 32B Think", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 65536, - }, - "alpindale/goliath-120b": { - id: "alpindale/goliath-120b", - name: "Goliath 120B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.75, - output: 7.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 6144, - maxOutput: 1024, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "amazon/nova-2-lite-v1": { id: "amazon/nova-2-lite-v1", name: "Amazon: Nova 2 Lite", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.3, @@ -6668,8 +6464,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 65535, - }, + maxTokens: 65535, + } satisfies Model<"openai-completions">, "amazon/nova-lite-v1": { id: "amazon/nova-lite-v1", name: "Amazon: Nova Lite 1.0", @@ -6685,8 +6481,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 300000, - maxOutput: 5120, - }, + maxTokens: 5120, + } satisfies Model<"openai-completions">, "amazon/nova-micro-v1": { id: "amazon/nova-micro-v1", name: "Amazon: Nova Micro 1.0", @@ -6696,14 +6492,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.04, + input: 0.035, output: 0.14, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 5120, - }, + maxTokens: 5120, + } satisfies Model<"openai-completions">, "amazon/nova-premier-v1": { id: "amazon/nova-premier-v1", name: "Amazon: Nova Premier 1.0", @@ -6715,12 +6511,12 @@ export const MODELS = { cost: { input: 2.5, output: 12.5, - cacheRead: 0, + cacheRead: 0.625, cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 32000, - }, + maxTokens: 32000, + } satisfies Model<"openai-completions">, "amazon/nova-pro-v1": { id: "amazon/nova-pro-v1", name: "Amazon: Nova Pro 1.0", @@ -6730,31 +6526,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.8, - output: 3.2, + input: 0.7999999999999999, + output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 300000, - maxOutput: 5120, - }, - "anthracite-org/magnum-v4-72b": { - id: "anthracite-org/magnum-v4-72b", - name: "Magnum v4 72B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16384, - maxOutput: 2048, - }, + maxTokens: 5120, + } satisfies Model<"openai-completions">, "anthropic/claude-3-haiku": { id: "anthropic/claude-3-haiku", name: "Anthropic: Claude 3 Haiku", @@ -6766,12 +6545,12 @@ export const MODELS = { cost: { input: 0.25, output: 1.25, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.03, + cacheWrite: 0.3, }, contextWindow: 200000, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "anthropic/claude-3.5-haiku": { id: "anthropic/claude-3.5-haiku", name: "Anthropic: Claude 3.5 Haiku", @@ -6781,14 +6560,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.8, + input: 0.7999999999999999, output: 4, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.08, + cacheWrite: 1, }, contextWindow: 200000, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "anthropic/claude-3.5-sonnet": { id: "anthropic/claude-3.5-sonnet", name: "Anthropic: Claude 3.5 Sonnet", @@ -6800,29 +6579,29 @@ export const MODELS = { cost: { input: 6, output: 30, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.6, + cacheWrite: 7.5, }, contextWindow: 200000, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "anthropic/claude-3.7-sonnet": { id: "anthropic/claude-3.7-sonnet", name: "Anthropic: Claude 3.7 Sonnet", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "anthropic/claude-3.7-sonnet:thinking": { id: "anthropic/claude-3.7-sonnet:thinking", name: "Anthropic: Claude 3.7 Sonnet (thinking)", @@ -6834,199 +6613,148 @@ export const MODELS = { cost: { input: 3, output: 15, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "anthropic/claude-haiku-4.5": { id: "anthropic/claude-haiku-4.5", name: "Anthropic: Claude Haiku 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1, output: 5, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.09999999999999999, + cacheWrite: 1.25, }, contextWindow: 200000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "anthropic/claude-opus-4": { id: "anthropic/claude-opus-4", name: "Anthropic: Claude Opus 4", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 15, output: 75, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 1.5, + cacheWrite: 18.75, }, contextWindow: 200000, - maxOutput: 32000, - }, + maxTokens: 32000, + } satisfies Model<"openai-completions">, "anthropic/claude-opus-4.1": { id: "anthropic/claude-opus-4.1", name: "Anthropic: Claude Opus 4.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 15, output: 75, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 1.5, + cacheWrite: 18.75, }, contextWindow: 200000, - maxOutput: 32000, - }, + maxTokens: 32000, + } satisfies Model<"openai-completions">, "anthropic/claude-opus-4.5": { id: "anthropic/claude-opus-4.5", name: "Anthropic: Claude Opus 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 5, output: 25, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 200000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "anthropic/claude-opus-4.6": { id: "anthropic/claude-opus-4.6", name: "Anthropic: Claude Opus 4.6", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 5, output: 25, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 1000000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4": { id: "anthropic/claude-sonnet-4", name: "Anthropic: Claude Sonnet 4", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4.5": { id: "anthropic/claude-sonnet-4.5", name: "Anthropic: Claude Sonnet 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 1000000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4.6": { id: "anthropic/claude-sonnet-4.6", name: "Anthropic: Claude Sonnet 4.6", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 1000000, - maxOutput: 128000, - }, - "arcee-ai/coder-large": { - id: "arcee-ai/coder-large", - name: "Arcee AI: Coder Large", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 0.8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "arcee-ai/maestro-reasoning": { - id: "arcee-ai/maestro-reasoning", - name: "Arcee AI: Maestro Reasoning", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.9, - output: 3.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 32000, - }, - "arcee-ai/spotlight": { - id: "arcee-ai/spotlight", - name: "Arcee AI: Spotlight", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.18, - output: 0.18, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 65537, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "arcee-ai/trinity-large-preview:free": { id: "arcee-ai/trinity-large-preview:free", name: "Arcee AI: Trinity Large Preview (free)", @@ -7042,32 +6770,32 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "arcee-ai/trinity-mini": { id: "arcee-ai/trinity-mini", name: "Arcee AI: Trinity Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.05, + input: 0.045, output: 0.15, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "arcee-ai/trinity-mini:free": { id: "arcee-ai/trinity-mini:free", name: "Arcee AI: Trinity Mini (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -7076,8 +6804,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "arcee-ai/virtuoso-large": { id: "arcee-ai/virtuoso-large", name: "Arcee AI: Virtuoso Large", @@ -7093,8 +6821,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "auto": { + id: "auto", + name: "Auto", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, "baidu/ernie-4.5-21b-a3b": { id: "baidu/ernie-4.5-21b-a3b", name: "Baidu: ERNIE 4.5 21B A3B", @@ -7110,49 +6855,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 120000, - maxOutput: 8000, - }, - "baidu/ernie-4.5-21b-a3b-thinking": { - id: "baidu/ernie-4.5-21b-a3b-thinking", - name: "Baidu: ERNIE 4.5 21B A3B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.07, - output: 0.28, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 65536, - }, - "baidu/ernie-4.5-300b-a47b": { - id: "baidu/ernie-4.5-300b-a47b", - name: "Baidu: ERNIE 4.5 300B A47B ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.28, - output: 1.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 123000, - maxOutput: 12000, - }, + maxTokens: 8000, + } satisfies Model<"openai-completions">, "baidu/ernie-4.5-vl-28b-a3b": { id: "baidu/ernie-4.5-vl-28b-a3b", name: "Baidu: ERNIE 4.5 VL 28B A3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.14, @@ -7161,32 +6872,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 30000, - maxOutput: 8000, - }, - "baidu/ernie-4.5-vl-424b-a47b": { - id: "baidu/ernie-4.5-vl-424b-a47b", - name: "Baidu: ERNIE 4.5 VL 424B A47B ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.42, - output: 1.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 123000, - maxOutput: 16000, - }, + maxTokens: 8000, + } satisfies Model<"openai-completions">, "bytedance-seed/seed-1.6": { id: "bytedance-seed/seed-1.6", name: "ByteDance Seed: Seed 1.6", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.25, @@ -7195,110 +6889,42 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "bytedance-seed/seed-1.6-flash": { id: "bytedance-seed/seed-1.6-flash", name: "ByteDance Seed: Seed 1.6 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.08, + input: 0.075, output: 0.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 32768, - }, - "bytedance-seed/seed-2.0-lite": { - id: "bytedance-seed/seed-2.0-lite", - name: "ByteDance Seed: Seed-2.0-Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxOutput: 131072, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "bytedance-seed/seed-2.0-mini": { id: "bytedance-seed/seed-2.0-mini", name: "ByteDance Seed: Seed-2.0-Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, + input: 0.09999999999999999, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 131072, - }, - "bytedance/ui-tars-1.5-7b": { - id: "bytedance/ui-tars-1.5-7b", - name: "ByteDance: UI-TARS 7B ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 2048, - }, - "cognitivecomputations/dolphin-mistral-24b-venice-edition:free": { - id: "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", - name: "Venice: Uncensored (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "cohere/command-a": { - id: "cohere/command-a", - name: "Cohere: Command A", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxOutput: 8192, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "cohere/command-r-08-2024": { id: "cohere/command-r-08-2024", name: "Cohere: Command R (08-2024)", @@ -7314,8 +6940,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 4000, - }, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "cohere/command-r-plus-08-2024": { id: "cohere/command-r-plus-08-2024", name: "Cohere: Command R+ (08-2024)", @@ -7331,42 +6957,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 4000, - }, - "cohere/command-r7b-12-2024": { - id: "cohere/command-r7b-12-2024", - name: "Cohere: Command R7B (12-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.04, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 4000, - }, - "deepcogito/cogito-v2.1-671b": { - id: "deepcogito/cogito-v2.1-671b", - name: "Deep Cogito: Cogito v2.1 671B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.25, - output: 1.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "deepseek/deepseek-chat": { id: "deepseek/deepseek-chat", name: "DeepSeek: DeepSeek V3", @@ -7377,37 +6969,37 @@ export const MODELS = { input: ["text"], cost: { input: 0.32, - output: 0.89, + output: 0.8899999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 163840, - }, + maxTokens: 163840, + } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3-0324": { id: "deepseek/deepseek-chat-v3-0324", name: "DeepSeek: DeepSeek V3 0324", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 0.77, - cacheRead: 0, + cacheRead: 0.13, cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 16384, - }, + maxTokens: 163840, + } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3.1": { id: "deepseek/deepseek-chat-v3.1", name: "DeepSeek: DeepSeek V3.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.15, @@ -7416,15 +7008,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 7168, - }, + maxTokens: 7168, + } satisfies Model<"openai-completions">, "deepseek/deepseek-r1": { id: "deepseek/deepseek-r1", name: "DeepSeek: R1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.7, @@ -7433,100 +7025,83 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 64000, - maxOutput: 16000, - }, + maxTokens: 16000, + } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-0528": { id: "deepseek/deepseek-r1-0528", name: "DeepSeek: R1 0528", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.45, - output: 2.15, - cacheRead: 0, + input: 0.44999999999999996, + output: 2.1500000000000004, + cacheRead: 0.22499999999999998, cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 65536, - }, - "deepseek/deepseek-r1-distill-llama-70b": { - id: "deepseek/deepseek-r1-distill-llama-70b", - name: "DeepSeek: R1 Distill Llama 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.7, - output: 0.8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "deepseek/deepseek-r1-distill-qwen-32b": { - id: "deepseek/deepseek-r1-distill-qwen-32b", - name: "DeepSeek: R1 Distill Qwen 32B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.29, - output: 0.29, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 32768, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.1-terminus": { id: "deepseek/deepseek-v3.1-terminus", name: "DeepSeek: DeepSeek V3.1 Terminus", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.21, - output: 0.78, - cacheRead: 0, + output: 0.7899999999999999, + cacheRead: 0.1300000002, cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 65536, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.1-terminus:exacto": { + id: "deepseek/deepseek-v3.1-terminus:exacto", + name: "DeepSeek: DeepSeek V3.1 Terminus (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.21, + output: 0.7899999999999999, + cacheRead: 0.16799999999999998, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2": { id: "deepseek/deepseek-v3.2", name: "DeepSeek: DeepSeek V3.2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.26, - output: 0.38, + input: 0.25, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 16384, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2-exp": { id: "deepseek/deepseek-v3.2-exp", name: "DeepSeek: DeepSeek V3.2 Exp", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.27, @@ -7535,42 +7110,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 65536, - }, - "deepseek/deepseek-v3.2-speciale": { - id: "deepseek/deepseek-v3.2-speciale", - name: "DeepSeek: DeepSeek V3.2 Speciale", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.4, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxOutput: 163840, - }, - "eleutherai/llemma_7b": { - id: "eleutherai/llemma_7b", - name: "EleutherAI: Llemma 7b", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.8, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4096, - maxOutput: 4096, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "essentialai/rnj-1-instruct": { id: "essentialai/rnj-1-instruct", name: "EssentialAI: Rnj 1 Instruct", @@ -7586,8 +7127,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-001": { id: "google/gemini-2.0-flash-001", name: "Google: Gemini 2.0 Flash", @@ -7597,14 +7138,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, - cacheRead: 0, - cacheWrite: 0, + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-lite-001": { id: "google/gemini-2.0-flash-lite-001", name: "Google: Gemini 2.0 Flash Lite", @@ -7614,320 +7155,201 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.08, + input: 0.075, output: 0.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1048576, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "google/gemini-2.5-flash": { id: "google/gemini-2.5-flash", name: "Google: Gemini 2.5 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.3, output: 2.5, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.03, + cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, - maxOutput: 65535, - }, - "google/gemini-2.5-flash-image": { - id: "google/gemini-2.5-flash-image", - name: "Google: Nano Banana (Gemini 2.5 Flash Image)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 32768, - }, + maxTokens: 65535, + } satisfies Model<"openai-completions">, "google/gemini-2.5-flash-lite": { id: "google/gemini-2.5-flash-lite", name: "Google: Gemini 2.5 Flash Lite", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, - cacheRead: 0, - cacheWrite: 0, + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, - maxOutput: 65535, - }, + maxTokens: 65535, + } satisfies Model<"openai-completions">, "google/gemini-2.5-flash-lite-preview-09-2025": { id: "google/gemini-2.5-flash-lite-preview-09-2025", name: "Google: Gemini 2.5 Flash Lite Preview 09-2025", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, - cacheRead: 0, - cacheWrite: 0, + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-2.5-pro": { id: "google/gemini-2.5-pro", name: "Google: Gemini 2.5 Pro", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.125, + cacheWrite: 0.375, }, contextWindow: 1048576, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-2.5-pro-preview": { id: "google/gemini-2.5-pro-preview", name: "Google: Gemini 2.5 Pro Preview 06-05", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.125, + cacheWrite: 0.375, }, contextWindow: 1048576, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-2.5-pro-preview-05-06": { id: "google/gemini-2.5-pro-preview-05-06", name: "Google: Gemini 2.5 Pro Preview 05-06", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.125, + cacheWrite: 0.375, }, contextWindow: 1048576, - maxOutput: 65535, - }, + maxTokens: 65535, + } satisfies Model<"openai-completions">, "google/gemini-3-flash-preview": { id: "google/gemini-3-flash-preview", name: "Google: Gemini 3 Flash Preview", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.5, output: 3, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.049999999999999996, + cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, - maxOutput: 65536, - }, - "google/gemini-3-pro-image-preview": { - id: "google/gemini-3-pro-image-preview", - name: "Google: Nano Banana Pro (Gemini 3 Pro Image Preview)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 12, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 32768, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-3-pro-preview": { id: "google/gemini-3-pro-preview", name: "Google: Gemini 3 Pro Preview", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 2, output: 12, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, }, contextWindow: 1048576, - maxOutput: 65536, - }, - "google/gemini-3.1-flash-image-preview": { - id: "google/gemini-3.1-flash-image-preview", - name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.5, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-3.1-flash-lite-preview": { id: "google/gemini-3.1-flash-lite-preview", name: "Google: Gemini 3.1 Flash Lite Preview", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.25, output: 1.5, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.024999999999999998, + cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-3.1-pro-preview": { id: "google/gemini-3.1-pro-preview", name: "Google: Gemini 3.1 Pro Preview", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 2, output: 12, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, }, contextWindow: 1048576, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemini-3.1-pro-preview-customtools": { id: "google/gemini-3.1-pro-preview-customtools", name: "Google: Gemini 3.1 Pro Preview Custom Tools", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 2, output: 12, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, }, contextWindow: 1048576, - maxOutput: 65536, - }, - "google/gemma-2-27b-it": { - id: "google/gemma-2-27b-it", - name: "Google: Gemma 2 27B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.65, - output: 0.65, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 2048, - }, - "google/gemma-2-9b-it": { - id: "google/gemma-2-9b-it", - name: "Google: Gemma 2 9B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.09, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 8192, - }, - "google/gemma-3-12b-it": { - id: "google/gemma-3-12b-it", - name: "Google: Gemma 3 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.04, - output: 0.13, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "google/gemma-3-12b-it:free": { - id: "google/gemma-3-12b-it:free", - name: "Google: Gemma 3 12B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 8192, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemma-3-27b-it": { id: "google/gemma-3-27b-it", name: "Google: Gemma 3 27B", @@ -7937,14 +7359,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.08, - output: 0.16, - cacheRead: 0, + input: 0.04, + output: 0.15, + cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 131072, - maxOutput: 16384, - }, + contextWindow: 128000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemma-3-27b-it:free": { id: "google/gemma-3-27b-it:free", name: "Google: Gemma 3 27B (free)", @@ -7960,127 +7382,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 8192, - }, - "google/gemma-3-4b-it": { - id: "google/gemma-3-4b-it", - name: "Google: Gemma 3 4B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.04, - output: 0.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "google/gemma-3-4b-it:free": { - id: "google/gemma-3-4b-it:free", - name: "Google: Gemma 3 4B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 8192, - }, - "google/gemma-3n-e2b-it:free": { - id: "google/gemma-3n-e2b-it:free", - name: "Google: Gemma 3n 2B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 2048, - }, - "google/gemma-3n-e4b-it": { - id: "google/gemma-3n-e4b-it", - name: "Google: Gemma 3n 4B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "google/gemma-3n-e4b-it:free": { - id: "google/gemma-3n-e4b-it:free", - name: "Google: Gemma 3n 4B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 2048, - }, - "gryphe/mythomax-l2-13b": { - id: "gryphe/mythomax-l2-13b", - name: "MythoMax 13B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.06, - output: 0.06, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4096, - maxOutput: 4096, - }, - "ibm-granite/granite-4.0-h-micro": { - id: "ibm-granite/granite-4.0-h-micro", - name: "IBM: Granite 4.0 Micro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131000, - maxOutput: 16384, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "inception/mercury": { id: "inception/mercury", name: "Inception: Mercury", @@ -8092,29 +7395,29 @@ export const MODELS = { cost: { input: 0.25, output: 0.75, - cacheRead: 0, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 32000, - }, + maxTokens: 32000, + } satisfies Model<"openai-completions">, "inception/mercury-2": { id: "inception/mercury-2", name: "Inception: Mercury 2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.25, output: 0.75, - cacheRead: 0, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 50000, - }, + maxTokens: 50000, + } satisfies Model<"openai-completions">, "inception/mercury-coder": { id: "inception/mercury-coder", name: "Inception: Mercury Coder", @@ -8126,46 +7429,12 @@ export const MODELS = { cost: { input: 0.25, output: 0.75, - cacheRead: 0, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 32000, - }, - "inflection/inflection-3-pi": { - id: "inflection/inflection-3-pi", - name: "Inflection: Inflection 3 Pi", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8000, - maxOutput: 1024, - }, - "inflection/inflection-3-productivity": { - id: "inflection/inflection-3-productivity", - name: "Inflection: Inflection 3 Productivity", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8000, - maxOutput: 1024, - }, + maxTokens: 32000, + } satisfies Model<"openai-completions">, "kwaipilot/kat-coder-pro": { id: "kwaipilot/kat-coder-pro", name: "Kwaipilot: KAT-Coder-Pro V1", @@ -8175,116 +7444,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.21, - output: 0.83, - cacheRead: 0, + input: 0.207, + output: 0.828, + cacheRead: 0.0414, cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 128000, - }, - "liquid/lfm-2-24b-a2b": { - id: "liquid/lfm-2-24b-a2b", - name: "LiquidAI: LFM2-24B-A2B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.12, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "liquid/lfm-2.2-6b": { - id: "liquid/lfm-2.2-6b", - name: "LiquidAI: LFM2-2.6B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.01, - output: 0.02, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "liquid/lfm-2.5-1.2b-instruct:free": { - id: "liquid/lfm-2.5-1.2b-instruct:free", - name: "LiquidAI: LFM2.5-1.2B-Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "liquid/lfm-2.5-1.2b-thinking:free": { - id: "liquid/lfm-2.5-1.2b-thinking:free", - name: "LiquidAI: LFM2.5-1.2B-Thinking (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "liquid/lfm2-8b-a1b": { - id: "liquid/lfm2-8b-a1b", - name: "LiquidAI: LFM2-8B-A1B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.01, - output: 0.02, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "mancer/weaver": { - id: "mancer/weaver", - name: "Mancer: Weaver (alpha)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.75, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8000, - maxOutput: 2000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "meituan/longcat-flash-chat": { id: "meituan/longcat-flash-chat", name: "Meituan: LongCat Flash Chat", @@ -8294,31 +7461,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.2, - output: 0.8, - cacheRead: 0, + input: 0.19999999999999998, + output: 0.7999999999999999, + cacheRead: 0.19999999999999998, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.51, - output: 0.74, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 8000, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", name: "Meta: Llama 3 8B Instruct", @@ -8334,11 +7484,11 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 8192, - maxOutput: 16384, - }, - "meta-llama/llama-3.1-405b": { - id: "meta-llama/llama-3.1-405b", - name: "Meta: Llama 3.1 405B (base)", + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -8350,9 +7500,9 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 32768, - maxOutput: 32768, - }, + contextWindow: 131000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -8362,14 +7512,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.4, - output: 0.4, + input: 0.39999999999999997, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-8b-instruct": { id: "meta-llama/llama-3.1-8b-instruct", name: "Meta: Llama 3.1 8B Instruct", @@ -8380,81 +7530,13 @@ export const MODELS = { input: ["text"], cost: { input: 0.02, - output: 0.05, + output: 0.049999999999999996, cacheRead: 0, cacheWrite: 0, }, contextWindow: 16384, - maxOutput: 16384, - }, - "meta-llama/llama-3.2-11b-vision-instruct": { - id: "meta-llama/llama-3.2-11b-vision-instruct", - name: "Meta: Llama 3.2 11B Vision Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.05, - output: 0.05, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "meta-llama/llama-3.2-1b-instruct": { - id: "meta-llama/llama-3.2-1b-instruct", - name: "Meta: Llama 3.2 1B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 60000, - maxOutput: 16384, - }, - "meta-llama/llama-3.2-3b-instruct": { - id: "meta-llama/llama-3.2-3b-instruct", - name: "Meta: Llama 3.2 3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.34, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 80000, - maxOutput: 16384, - }, - "meta-llama/llama-3.2-3b-instruct:free": { - id: "meta-llama/llama-3.2-3b-instruct:free", - name: "Meta: Llama 3.2 3B Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct": { id: "meta-llama/llama-3.3-70b-instruct", name: "Meta: Llama 3.3 70B Instruct", @@ -8464,14 +7546,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, + input: 0.09999999999999999, output: 0.32, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct:free": { id: "meta-llama/llama-3.3-70b-instruct:free", name: "Meta: Llama 3.3 70B Instruct (free)", @@ -8486,9 +7568,9 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 65536, - maxOutput: 16384, - }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "meta-llama/llama-4-maverick": { id: "meta-llama/llama-4-maverick", name: "Meta: Llama 4 Maverick", @@ -8504,8 +7586,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1048576, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-4-scout": { id: "meta-llama/llama-4-scout", name: "Meta: Llama 4 Scout", @@ -8521,212 +7603,76 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 327680, - maxOutput: 16384, - }, - "meta-llama/llama-guard-3-8b": { - id: "meta-llama/llama-guard-3-8b", - name: "Llama Guard 3 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.06, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "meta-llama/llama-guard-4-12b": { - id: "meta-llama/llama-guard-4-12b", - name: "Meta: Llama Guard 4 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.18, - output: 0.18, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxOutput: 16384, - }, - "microsoft/phi-4": { - id: "microsoft/phi-4", - name: "Microsoft: Phi 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.07, - output: 0.14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16384, - maxOutput: 16384, - }, - "microsoft/wizardlm-2-8x22b": { - id: "microsoft/wizardlm-2-8x22b", - name: "WizardLM-2 8x22B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.62, - output: 0.62, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65535, - maxOutput: 8000, - }, - "minimax/minimax-01": { - id: "minimax/minimax-01", - name: "MiniMax: MiniMax-01", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 1.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000192, - maxOutput: 1000192, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "minimax/minimax-m1": { id: "minimax/minimax-m1", name: "MiniMax: MiniMax M1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.4, + input: 0.39999999999999997, output: 2.2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 40000, - }, + maxTokens: 40000, + } satisfies Model<"openai-completions">, "minimax/minimax-m2": { id: "minimax/minimax-m2", name: "MiniMax: MiniMax M2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.26, + input: 0.255, output: 1, - cacheRead: 0, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 196608, - maxOutput: 196608, - }, - "minimax/minimax-m2-her": { - id: "minimax/minimax-m2-her", - name: "MiniMax: MiniMax M2-her", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxOutput: 2048, - }, + maxTokens: 196608, + } satisfies Model<"openai-completions">, "minimax/minimax-m2.1": { id: "minimax/minimax-m2.1", name: "MiniMax: MiniMax M2.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.27, output: 0.95, - cacheRead: 0, + cacheRead: 0.0290000007, cacheWrite: 0, }, contextWindow: 196608, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "minimax/minimax-m2.5": { id: "minimax/minimax-m2.5", name: "MiniMax: MiniMax M2.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.2, + input: 0.295, output: 1.2, - cacheRead: 0, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 196608, - maxOutput: 196608, - }, - "minimax/minimax-m2.5:free": { - id: "minimax/minimax-m2.5:free", - name: "MiniMax: MiniMax M2.5 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 196608, - maxOutput: 196608, - }, - "minimax/minimax-m2.7": { - id: "minimax/minimax-m2.7", - name: "MiniMax: MiniMax M2.7", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 204800, - maxOutput: 131072, - }, + maxTokens: 196608, + } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", name: "Mistral: Codestral 2508", @@ -8737,13 +7683,13 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.9, + output: 0.8999999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/devstral-2512": { id: "mistralai/devstral-2512", name: "Mistral: Devstral 2 2512", @@ -8753,14 +7699,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.4, + input: 0.39999999999999997, output: 2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/devstral-medium": { id: "mistralai/devstral-medium", name: "Mistral: Devstral Medium", @@ -8770,14 +7716,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.4, + input: 0.39999999999999997, output: 2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/devstral-small": { id: "mistralai/devstral-small", name: "Mistral: Devstral Small 1.1", @@ -8787,14 +7733,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, + input: 0.09999999999999999, output: 0.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/ministral-14b-2512": { id: "mistralai/ministral-14b-2512", name: "Mistral: Ministral 3 14B 2512", @@ -8804,14 +7750,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.2, - output: 0.2, + input: 0.19999999999999998, + output: 0.19999999999999998, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/ministral-3b-2512": { id: "mistralai/ministral-3b-2512", name: "Mistral: Ministral 3 3B 2512", @@ -8821,14 +7767,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.1, - output: 0.1, + input: 0.09999999999999999, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/ministral-8b-2512": { id: "mistralai/ministral-8b-2512", name: "Mistral: Ministral 3 8B 2512", @@ -8844,25 +7790,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, - "mistralai/mistral-7b-instruct-v0.1": { - id: "mistralai/mistral-7b-instruct-v0.1", - name: "Mistral: Mistral 7B Instruct v0.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.11, - output: 0.19, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 2824, - maxOutput: 2824, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-large": { id: "mistralai/mistral-large", name: "Mistral Large", @@ -8878,8 +7807,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-large-2407": { id: "mistralai/mistral-large-2407", name: "Mistral Large 2407", @@ -8895,8 +7824,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-large-2411": { id: "mistralai/mistral-large-2411", name: "Mistral Large 2411", @@ -8912,8 +7841,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-large-2512": { id: "mistralai/mistral-large-2512", name: "Mistral: Mistral Large 3 2512", @@ -8929,8 +7858,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-medium-3": { id: "mistralai/mistral-medium-3", name: "Mistral: Mistral Medium 3", @@ -8940,14 +7869,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.4, + input: 0.39999999999999997, output: 2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-medium-3.1": { id: "mistralai/mistral-medium-3.1", name: "Mistral: Mistral Medium 3.1", @@ -8957,14 +7886,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.4, + input: 0.39999999999999997, output: 2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -8980,8 +7909,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-saba": { id: "mistralai/mistral-saba", name: "Mistral: Saba", @@ -8991,14 +7920,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 0.6, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-small-24b-instruct-2501": { id: "mistralai/mistral-small-24b-instruct-2501", name: "Mistral: Mistral Small 3", @@ -9008,48 +7937,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.05, + input: 0.049999999999999996, output: 0.08, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, - "mistralai/mistral-small-2603": { - id: "mistralai/mistral-small-2603", - name: "Mistral: Mistral Small 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxOutput: 16384, - }, - "mistralai/mistral-small-3.1-24b-instruct": { - id: "mistralai/mistral-small-3.1-24b-instruct", - name: "Mistral: Mistral Small 3.1 24B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.35, - output: 0.56, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-small-3.1-24b-instruct:free": { id: "mistralai/mistral-small-3.1-24b-instruct:free", name: "Mistral: Mistral Small 3.1 24B (free)", @@ -9065,8 +7960,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-small-3.2-24b-instruct": { id: "mistralai/mistral-small-3.2-24b-instruct", name: "Mistral: Mistral Small 3.2 24B", @@ -9076,14 +7971,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.08, - output: 0.2, - cacheRead: 0, + input: 0.06, + output: 0.18, + cacheRead: 0.03, cacheWrite: 0, }, - contextWindow: 128000, - maxOutput: 16384, - }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "mistralai/mistral-small-creative": { id: "mistralai/mistral-small-creative", name: "Mistral: Mistral Small Creative", @@ -9093,14 +7988,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, + input: 0.09999999999999999, output: 0.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -9116,8 +8011,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 65536, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x7b-instruct": { id: "mistralai/mixtral-8x7b-instruct", name: "Mistral: Mixtral 8x7B Instruct", @@ -9133,8 +8028,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/pixtral-large-2411": { id: "mistralai/pixtral-large-2411", name: "Mistral: Pixtral Large 2411", @@ -9150,8 +8045,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/voxtral-small-24b-2507": { id: "mistralai/voxtral-small-24b-2507", name: "Mistral: Voxtral Small 24B 2507", @@ -9161,14 +8056,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, + input: 0.09999999999999999, output: 0.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "moonshotai/kimi-k2": { id: "moonshotai/kimi-k2", name: "MoonshotAI: Kimi K2 0711", @@ -9184,8 +8079,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "moonshotai/kimi-k2-0905": { id: "moonshotai/kimi-k2-0905", name: "MoonshotAI: Kimi K2 0905", @@ -9195,14 +8090,31 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.4, + input: 0.39999999999999997, output: 2, - cacheRead: 0, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-0905:exacto": { + id: "moonshotai/kimi-k2-0905:exacto", + name: "MoonshotAI: Kimi K2 0905 (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "moonshotai/kimi-k2-thinking": { id: "moonshotai/kimi-k2-thinking", name: "MoonshotAI: Kimi K2 Thinking", @@ -9214,63 +8126,29 @@ export const MODELS = { cost: { input: 0.47, output: 2, - cacheRead: 0, + cacheRead: 0.14100000000000001, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.5": { id: "moonshotai/kimi-k2.5", name: "MoonshotAI: Kimi K2.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.45, - output: 2.2, - cacheRead: 0, + input: 0.41, + output: 2.06, + cacheRead: 0.07, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65535, - }, - "morph/morph-v3-fast": { - id: "morph/morph-v3-fast", - name: "Morph: Morph V3 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.8, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 81920, - maxOutput: 38000, - }, - "morph/morph-v3-large": { - id: "morph/morph-v3-large", - name: "Morph: Morph V3 Large", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.9, - output: 1.9, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxOutput: 131072, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nex-agi/deepseek-v3.1-nex-n1": { id: "nex-agi/deepseek-v3.1-nex-n1", name: "Nex AGI: DeepSeek V3.1 Nex N1", @@ -9286,110 +8164,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 163840, - }, - "nousresearch/hermes-2-pro-llama-3-8b": { - id: "nousresearch/hermes-2-pro-llama-3-8b", - name: "NousResearch: Hermes 2 Pro - Llama-3 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.14, - output: 0.14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 8192, - }, - "nousresearch/hermes-3-llama-3.1-405b": { - id: "nousresearch/hermes-3-llama-3.1-405b", - name: "Nous: Hermes 3 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "nousresearch/hermes-3-llama-3.1-405b:free": { - id: "nousresearch/hermes-3-llama-3.1-405b:free", - name: "Nous: Hermes 3 405B Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "nousresearch/hermes-3-llama-3.1-70b": { - id: "nousresearch/hermes-3-llama-3.1-70b", - name: "Nous: Hermes 3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "nousresearch/hermes-4-405b": { - id: "nousresearch/hermes-4-405b", - name: "Nous: Hermes 4 405B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "nousresearch/hermes-4-70b": { - id: "nousresearch/hermes-4-70b", - name: "Nous: Hermes 4 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.13, - output: 0.4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 163840, + } satisfies Model<"openai-completions">, "nvidia/llama-3.1-nemotron-70b-instruct": { id: "nvidia/llama-3.1-nemotron-70b-instruct", name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", @@ -9405,49 +8181,49 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "nvidia/llama-3.3-nemotron-super-49b-v1.5": { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.1, - output: 0.4, + input: 0.09999999999999999, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nvidia/nemotron-3-nano-30b-a3b": { id: "nvidia/nemotron-3-nano-30b-a3b", name: "NVIDIA: Nemotron 3 Nano 30B A3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.05, - output: 0.2, + input: 0.049999999999999996, + output: 0.19999999999999998, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nvidia/nemotron-3-nano-30b-a3b:free": { id: "nvidia/nemotron-3-nano-30b-a3b:free", name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -9456,66 +8232,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 16384, - }, - "nvidia/nemotron-3-super-120b-a12b": { - id: "nvidia/nemotron-3-super-120b-a12b", - name: "NVIDIA: Nemotron 3 Super", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxOutput: 16384, - }, - "nvidia/nemotron-3-super-120b-a12b:free": { - id: "nvidia/nemotron-3-super-120b-a12b:free", - name: "NVIDIA: Nemotron 3 Super (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxOutput: 262144, - }, - "nvidia/nemotron-nano-12b-v2-vl": { - id: "nvidia/nemotron-nano-12b-v2-vl", - name: "NVIDIA: Nemotron Nano 12B 2 VL", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nvidia/nemotron-nano-12b-v2-vl:free": { id: "nvidia/nemotron-nano-12b-v2-vl:free", name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0, @@ -9524,15 +8249,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "nvidia/nemotron-nano-9b-v2": { id: "nvidia/nemotron-nano-9b-v2", name: "NVIDIA: Nemotron Nano 9B V2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.04, @@ -9541,15 +8266,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nvidia/nemotron-nano-9b-v2:free": { id: "nvidia/nemotron-nano-9b-v2:free", name: "NVIDIA: Nemotron Nano 9B V2 (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -9558,8 +8283,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -9575,8 +8300,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 16385, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo-0613": { id: "openai/gpt-3.5-turbo-0613", name: "OpenAI: GPT-3.5 Turbo (older v0613)", @@ -9592,8 +8317,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 4095, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo-16k": { id: "openai/gpt-3.5-turbo-16k", name: "OpenAI: GPT-3.5 Turbo 16k", @@ -9609,25 +8334,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 16385, - maxOutput: 4096, - }, - "openai/gpt-3.5-turbo-instruct": { - id: "openai/gpt-3.5-turbo-instruct", - name: "OpenAI: GPT-3.5 Turbo Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -9643,8 +8351,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 8191, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4-0314": { id: "openai/gpt-4-0314", name: "OpenAI: GPT-4 (older v0314)", @@ -9660,8 +8368,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 8191, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4-1106-preview": { id: "openai/gpt-4-1106-preview", name: "OpenAI: GPT-4 Turbo (older v1106)", @@ -9677,8 +8385,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4-turbo": { id: "openai/gpt-4-turbo", name: "OpenAI: GPT-4 Turbo", @@ -9694,8 +8402,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -9711,8 +8419,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4.1": { id: "openai/gpt-4.1", name: "OpenAI: GPT-4.1", @@ -9724,12 +8432,12 @@ export const MODELS = { cost: { input: 2, output: 8, - cacheRead: 0, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 1047576, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "openai/gpt-4.1-mini": { id: "openai/gpt-4.1-mini", name: "OpenAI: GPT-4.1 Mini", @@ -9739,14 +8447,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.4, - output: 1.6, - cacheRead: 0, + input: 0.39999999999999997, + output: 1.5999999999999999, + cacheRead: 0.09999999999999999, cacheWrite: 0, }, contextWindow: 1047576, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "openai/gpt-4.1-nano": { id: "openai/gpt-4.1-nano", name: "OpenAI: GPT-4.1 Nano", @@ -9756,14 +8464,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, - cacheRead: 0, + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 1047576, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -9775,12 +8483,12 @@ export const MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-4o-2024-05-13": { id: "openai/gpt-4o-2024-05-13", name: "OpenAI: GPT-4o (2024-05-13)", @@ -9796,8 +8504,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 4096, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o-2024-08-06": { id: "openai/gpt-4o-2024-08-06", name: "OpenAI: GPT-4o (2024-08-06)", @@ -9809,12 +8517,12 @@ export const MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-4o-2024-11-20": { id: "openai/gpt-4o-2024-11-20", name: "OpenAI: GPT-4o (2024-11-20)", @@ -9826,12 +8534,12 @@ export const MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-4o-audio-preview": { id: "openai/gpt-4o-audio-preview", name: "OpenAI: GPT-4o Audio", @@ -9847,8 +8555,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-4o-mini": { id: "openai/gpt-4o-mini", name: "OpenAI: GPT-4o-mini", @@ -9860,12 +8568,12 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-4o-mini-2024-07-18": { id: "openai/gpt-4o-mini-2024-07-18", name: "OpenAI: GPT-4o-mini (2024-07-18)", @@ -9877,46 +8585,12 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, - "openai/gpt-4o-mini-search-preview": { - id: "openai/gpt-4o-mini-search-preview", - name: "OpenAI: GPT-4o-mini Search Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, - "openai/gpt-4o-search-preview": { - id: "openai/gpt-4o-search-preview", - name: "OpenAI: GPT-4o Search Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-4o:extended": { id: "openai/gpt-4o:extended", name: "OpenAI: GPT-4o (extended)", @@ -9932,134 +8606,117 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 64000, - }, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "openai/gpt-5": { id: "openai/gpt-5", name: "OpenAI: GPT-5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, - "openai/gpt-5-chat": { - id: "openai/gpt-5-chat", - name: "OpenAI: GPT-5 Chat", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5-codex": { id: "openai/gpt-5-codex", name: "OpenAI: GPT-5 Codex", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5-image": { id: "openai/gpt-5-image", name: "OpenAI: GPT-5 Image", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 10, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5-image-mini": { id: "openai/gpt-5-image-mini", name: "OpenAI: GPT-5 Image Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 2.5, output: 2, - cacheRead: 0, + cacheRead: 0.25, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5-mini": { id: "openai/gpt-5-mini", name: "OpenAI: GPT-5 Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.25, output: 2, - cacheRead: 0, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5-nano": { id: "openai/gpt-5-nano", name: "OpenAI: GPT-5 Nano", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.05, - output: 0.4, - cacheRead: 0, + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.005, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5-pro": { id: "openai/gpt-5-pro", name: "OpenAI: GPT-5 Pro", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 15, @@ -10068,25 +8725,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.1": { id: "openai/gpt-5.1", name: "OpenAI: GPT-5.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.1-chat": { id: "openai/gpt-5.1-chat", name: "OpenAI: GPT-5.1 Chat", @@ -10098,80 +8755,80 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex": { id: "openai/gpt-5.1-codex", name: "OpenAI: GPT-5.1-Codex", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex-max": { id: "openai/gpt-5.1-codex-max", name: "OpenAI: GPT-5.1-Codex-Max", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex-mini": { id: "openai/gpt-5.1-codex-mini", name: "OpenAI: GPT-5.1-Codex-Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.25, output: 2, - cacheRead: 0, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/gpt-5.2": { id: "openai/gpt-5.2", name: "OpenAI: GPT-5.2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.75, output: 14, - cacheRead: 0, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.2-chat": { id: "openai/gpt-5.2-chat", name: "OpenAI: GPT-5.2 Chat", @@ -10183,36 +8840,36 @@ export const MODELS = { cost: { input: 1.75, output: 14, - cacheRead: 0, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-5.2-codex": { id: "openai/gpt-5.2-codex", name: "OpenAI: GPT-5.2-Codex", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.75, output: 14, - cacheRead: 0, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.2-pro": { id: "openai/gpt-5.2-pro", name: "OpenAI: GPT-5.2 Pro", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 21, @@ -10221,8 +8878,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.3-chat": { id: "openai/gpt-5.3-chat", name: "OpenAI: GPT-5.3 Chat", @@ -10234,87 +8891,53 @@ export const MODELS = { cost: { input: 1.75, output: 14, - cacheRead: 0, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "openai/gpt-5.3-codex": { id: "openai/gpt-5.3-codex", name: "OpenAI: GPT-5.3-Codex", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.75, output: 14, - cacheRead: 0, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.4": { id: "openai/gpt-5.4", name: "OpenAI: GPT-5.4", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 2.5, output: 15, - cacheRead: 0, + cacheRead: 0.25, cacheWrite: 0, }, contextWindow: 1050000, - maxOutput: 128000, - }, - "openai/gpt-5.4-mini": { - id: "openai/gpt-5.4-mini", - name: "OpenAI: GPT-5.4 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.75, - output: 4.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxOutput: 128000, - }, - "openai/gpt-5.4-nano": { - id: "openai/gpt-5.4-nano", - name: "OpenAI: GPT-5.4 Nano", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 1.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-5.4-pro": { id: "openai/gpt-5.4-pro", name: "OpenAI: GPT-5.4 Pro", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 30, @@ -10323,66 +8946,49 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1050000, - maxOutput: 128000, - }, - "openai/gpt-audio": { - id: "openai/gpt-audio", - name: "OpenAI: GPT Audio", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, - "openai/gpt-audio-mini": { - id: "openai/gpt-audio-mini", - name: "OpenAI: GPT Audio Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 2.4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.04, + input: 0.039, output: 0.19, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:exacto": { + id: "openai/gpt-oss-120b:exacto", + name: "OpenAI: gpt-oss-120b (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.19, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-oss-120b:free": { id: "openai/gpt-oss-120b:free", name: "OpenAI: gpt-oss-120b (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -10391,15 +8997,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", name: "OpenAI: gpt-oss-20b", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.03, @@ -10408,15 +9014,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-oss-20b:free": { id: "openai/gpt-oss-20b:free", name: "OpenAI: gpt-oss-20b (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -10425,59 +9031,42 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "openai/gpt-oss-safeguard-20b": { id: "openai/gpt-oss-safeguard-20b", name: "OpenAI: gpt-oss-safeguard-20b", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.08, + input: 0.075, output: 0.3, - cacheRead: 0, + cacheRead: 0.037, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "openai/o1": { id: "openai/o1", name: "OpenAI: o1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { input: 15, output: 60, - cacheRead: 0, + cacheRead: 7.5, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, - "openai/o1-pro": { - id: "openai/o1-pro", - name: "OpenAI: o1-pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 150, - output: 600, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o3": { id: "openai/o3", name: "OpenAI: o3", @@ -10489,12 +9078,12 @@ export const MODELS = { cost: { input: 2, output: 8, - cacheRead: 0, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o3-deep-research": { id: "openai/o3-deep-research", name: "OpenAI: o3 Deep Research", @@ -10506,46 +9095,46 @@ export const MODELS = { cost: { input: 10, output: 40, - cacheRead: 0, + cacheRead: 2.5, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o3-mini": { id: "openai/o3-mini", name: "OpenAI: o3 Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { input: 1.1, output: 4.4, - cacheRead: 0, + cacheRead: 0.55, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o3-mini-high": { id: "openai/o3-mini-high", name: "OpenAI: o3 Mini High", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { input: 1.1, output: 4.4, - cacheRead: 0, + cacheRead: 0.55, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o3-pro": { id: "openai/o3-pro", name: "OpenAI: o3 Pro", @@ -10561,8 +9150,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o4-mini": { id: "openai/o4-mini", name: "OpenAI: o4 Mini", @@ -10574,12 +9163,12 @@ export const MODELS = { cost: { input: 1.1, output: 4.4, - cacheRead: 0, + cacheRead: 0.275, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o4-mini-deep-research": { id: "openai/o4-mini-deep-research", name: "OpenAI: o4 Mini Deep Research", @@ -10591,12 +9180,12 @@ export const MODELS = { cost: { input: 2, output: 8, - cacheRead: 0, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openai/o4-mini-high": { id: "openai/o4-mini-high", name: "OpenAI: o4 Mini High", @@ -10608,19 +9197,19 @@ export const MODELS = { cost: { input: 1.1, output: 4.4, - cacheRead: 0, + cacheRead: 0.275, cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 100000, - }, + maxTokens: 100000, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "Auto Router", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: -1000000, @@ -10629,32 +9218,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 2000000, - maxOutput: 16384, - }, - "openrouter/bodybuilder": { - id: "openrouter/bodybuilder", - name: "Body Builder (beta)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: -1000000, - output: -1000000, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/free": { id: "openrouter/free", name: "Free Models Router", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0, @@ -10663,110 +9235,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 200000, - maxOutput: 16384, - }, - "perplexity/sonar": { - id: "perplexity/sonar", - name: "Perplexity: Sonar", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 127072, - maxOutput: 16384, - }, - "perplexity/sonar-deep-research": { - id: "perplexity/sonar-deep-research", - name: "Perplexity: Sonar Deep Research", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, - "perplexity/sonar-pro": { - id: "perplexity/sonar-pro", - name: "Perplexity: Sonar Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxOutput: 8000, - }, - "perplexity/sonar-pro-search": { - id: "perplexity/sonar-pro-search", - name: "Perplexity: Sonar Pro Search", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxOutput: 8000, - }, - "perplexity/sonar-reasoning-pro": { - id: "perplexity/sonar-reasoning-pro", - name: "Perplexity: Sonar Reasoning Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "prime-intellect/intellect-3": { id: "prime-intellect/intellect-3", name: "Prime Intellect: INTELLECT-3", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 1.1, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "qwen/qwen-2.5-72b-instruct": { id: "qwen/qwen-2.5-72b-instruct", name: "Qwen2.5 72B Instruct", @@ -10782,8 +9269,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "qwen/qwen-2.5-7b-instruct": { id: "qwen/qwen-2.5-7b-instruct", name: "Qwen: Qwen2.5 7B Instruct", @@ -10794,47 +9281,13 @@ export const MODELS = { input: ["text"], cost: { input: 0.04, - output: 0.1, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 16384, - }, - "qwen/qwen-2.5-coder-32b-instruct": { - id: "qwen/qwen-2.5-coder-32b-instruct", - name: "Qwen2.5 Coder 32B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.66, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "qwen/qwen-2.5-vl-7b-instruct": { - id: "qwen/qwen-2.5-vl-7b-instruct", - name: "Qwen: Qwen2.5-VL 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen-max": { id: "qwen/qwen-max", name: "Qwen: Qwen-Max ", @@ -10846,12 +9299,12 @@ export const MODELS = { cost: { input: 1.04, output: 4.16, - cacheRead: 0, + cacheRead: 0.20800000000000002, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "qwen/qwen-plus": { id: "qwen/qwen-plus", name: "Qwen: Qwen-Plus", @@ -10861,14 +9314,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.26, - output: 0.78, - cacheRead: 0, + input: 0.39999999999999997, + output: 1.2, + cacheRead: 0.08, cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen-plus-2025-07-28": { id: "qwen/qwen-plus-2025-07-28", name: "Qwen: Qwen Plus 0728", @@ -10884,8 +9337,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen-plus-2025-07-28:thinking": { id: "qwen/qwen-plus-2025-07-28:thinking", name: "Qwen: Qwen Plus 0728 (thinking)", @@ -10901,8 +9354,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen-turbo": { id: "qwen/qwen-turbo", name: "Qwen: Qwen-Turbo", @@ -10912,14 +9365,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.03, + input: 0.0325, output: 0.13, - cacheRead: 0, + cacheRead: 0.006500000000000001, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "qwen/qwen-vl-max": { id: "qwen/qwen-vl-max", name: "Qwen: Qwen VL Max", @@ -10929,89 +9382,21 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.52, - output: 2.08, + input: 0.7999999999999999, + output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, - "qwen/qwen-vl-plus": { - id: "qwen/qwen-vl-plus", - name: "Qwen: Qwen VL Plus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.14, - output: 0.41, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 8192, - }, - "qwen/qwen2.5-coder-7b-instruct": { - id: "qwen/qwen2.5-coder-7b-instruct", - name: "Qwen: Qwen2.5 Coder 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.09, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 16384, - }, - "qwen/qwen2.5-vl-32b-instruct": { - id: "qwen/qwen2.5-vl-32b-instruct", - name: "Qwen: Qwen2.5 VL 32B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxOutput: 16384, - }, - "qwen/qwen2.5-vl-72b-instruct": { - id: "qwen/qwen2.5-vl-72b-instruct", - name: "Qwen: Qwen2.5 VL 72B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 0.8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-14b": { id: "qwen/qwen3-14b", name: "Qwen: Qwen3 14B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.06, @@ -11020,42 +9405,42 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 40960, - maxOutput: 40960, - }, + maxTokens: 40960, + } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b": { id: "qwen/qwen3-235b-a22b", name: "Qwen: Qwen3 235B A22B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.45, - output: 1.82, + input: 0.45499999999999996, + output: 1.8199999999999998, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b-2507": { id: "qwen/qwen3-235b-a22b-2507", name: "Qwen: Qwen3 235B A22B Instruct 2507", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.07, - output: 0.1, + input: 0.071, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b-thinking-2507": { id: "qwen/qwen3-235b-a22b-thinking-2507", name: "Qwen: Qwen3 235B A22B Thinking 2507", @@ -11065,21 +9450,21 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.15, - output: 1.5, - cacheRead: 0, + input: 0.11, + output: 0.6, + cacheRead: 0.055, cacheWrite: 0, }, - contextWindow: 131072, - maxOutput: 16384, - }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { id: "qwen/qwen3-30b-a3b", name: "Qwen: Qwen3 30B A3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.08, @@ -11088,8 +9473,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 40960, - maxOutput: 40960, - }, + maxTokens: 40960, + } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { id: "qwen/qwen3-30b-a3b-instruct-2507", name: "Qwen: Qwen3 30B A3B Instruct 2507", @@ -11105,8 +9490,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 262144, - }, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-thinking-2507": { id: "qwen/qwen3-30b-a3b-thinking-2507", name: "Qwen: Qwen3 30B A3B Thinking 2507", @@ -11116,38 +9501,38 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.4, + input: 0.051, + output: 0.33999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxOutput: 131072, - }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-32b": { id: "qwen/qwen3-32b", name: "Qwen: Qwen3 32B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.08, output: 0.24, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 40960, - maxOutput: 40960, - }, + maxTokens: 40960, + } satisfies Model<"openai-completions">, "qwen/qwen3-4b:free": { id: "qwen/qwen3-4b:free", name: "Qwen: Qwen3 4B (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -11156,25 +9541,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 40960, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-8b": { id: "qwen/qwen3-8b", name: "Qwen: Qwen3 8B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.05, - output: 0.4, - cacheRead: 0, + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 40960, - maxOutput: 8192, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "qwen/qwen3-coder": { id: "qwen/qwen3-coder", name: "Qwen: Qwen3 Coder 480B A35B", @@ -11186,12 +9571,12 @@ export const MODELS = { cost: { input: 0.22, output: 1, - cacheRead: 0, + cacheRead: 0.022, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-coder-30b-a3b-instruct": { id: "qwen/qwen3-coder-30b-a3b-instruct", name: "Qwen: Qwen3 Coder 30B A3B Instruct", @@ -11207,8 +9592,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 160000, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-coder-flash": { id: "qwen/qwen3-coder-flash", name: "Qwen: Qwen3 Coder Flash", @@ -11218,14 +9603,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.2, - output: 0.98, - cacheRead: 0, + input: 0.195, + output: 0.975, + cacheRead: 0.039, cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3-coder-next": { id: "qwen/qwen3-coder-next", name: "Qwen: Qwen3 Coder Next", @@ -11237,12 +9622,12 @@ export const MODELS = { cost: { input: 0.12, output: 0.75, - cacheRead: 0, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3-coder-plus": { id: "qwen/qwen3-coder-plus", name: "Qwen: Qwen3 Coder Plus", @@ -11254,12 +9639,29 @@ export const MODELS = { cost: { input: 0.65, output: 3.25, - cacheRead: 0, + cacheRead: 0.13, cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder:exacto": { + id: "qwen/qwen3-coder:exacto", + name: "Qwen: Qwen3 Coder 480B A35B (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.22, + output: 1.7999999999999998, + cacheRead: 0.022, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3-coder:free": { id: "qwen/qwen3-coder:free", name: "Qwen: Qwen3 Coder 480B A35B (free)", @@ -11275,8 +9677,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262000, - maxOutput: 262000, - }, + maxTokens: 262000, + } satisfies Model<"openai-completions">, "qwen/qwen3-max": { id: "qwen/qwen3-max", name: "Qwen: Qwen3 Max", @@ -11286,14 +9688,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.78, - output: 3.9, - cacheRead: 0, + input: 1.2, + output: 6, + cacheRead: 0.24, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-max-thinking": { id: "qwen/qwen3-max-thinking", name: "Qwen: Qwen3 Max Thinking", @@ -11309,8 +9711,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-next-80b-a3b-instruct": { id: "qwen/qwen3-next-80b-a3b-instruct", name: "Qwen: Qwen3 Next 80B A3B Instruct", @@ -11326,8 +9728,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-next-80b-a3b-instruct:free": { id: "qwen/qwen3-next-80b-a3b-instruct:free", name: "Qwen: Qwen3 Next 80B A3B Instruct (free)", @@ -11343,8 +9745,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-next-80b-a3b-thinking": { id: "qwen/qwen3-next-80b-a3b-thinking", name: "Qwen: Qwen3 Next 80B A3B Thinking", @@ -11354,14 +9756,14 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.1, - output: 0.78, + input: 0.15, + output: 1.2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxOutput: 32768, - }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-instruct": { id: "qwen/qwen3-vl-235b-a22b-instruct", name: "Qwen: Qwen3 VL 235B A22B Instruct", @@ -11371,14 +9773,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 0.88, - cacheRead: 0, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-thinking": { id: "qwen/qwen3-vl-235b-a22b-thinking", name: "Qwen: Qwen3 VL 235B A22B Thinking", @@ -11388,14 +9790,14 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.26, - output: 2.6, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-30b-a3b-instruct": { id: "qwen/qwen3-vl-30b-a3b-instruct", name: "Qwen: Qwen3 VL 30B A3B Instruct", @@ -11411,8 +9813,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-30b-a3b-thinking": { id: "qwen/qwen3-vl-30b-a3b-thinking", name: "Qwen: Qwen3 VL 30B A3B Thinking", @@ -11422,14 +9824,14 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.13, - output: 1.56, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-32b-instruct": { id: "qwen/qwen3-vl-32b-instruct", name: "Qwen: Qwen3 VL 32B Instruct", @@ -11439,14 +9841,14 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.1, - output: 0.42, + input: 0.10400000000000001, + output: 0.41600000000000004, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-instruct": { id: "qwen/qwen3-vl-8b-instruct", name: "Qwen: Qwen3 VL 8B Instruct", @@ -11462,8 +9864,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-thinking": { id: "qwen/qwen3-vl-8b-thinking", name: "Qwen: Qwen3 VL 8B Thinking", @@ -11473,21 +9875,21 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.12, - output: 1.37, + input: 0.117, + output: 1.365, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen3.5-122b-a10b": { id: "qwen/qwen3.5-122b-a10b", name: "Qwen: Qwen3.5-122B-A10B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.26, @@ -11496,49 +9898,49 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3.5-27b": { id: "qwen/qwen3.5-27b", name: "Qwen: Qwen3.5-27B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.2, + input: 0.195, output: 1.56, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3.5-35b-a3b": { id: "qwen/qwen3.5-35b-a3b", name: "Qwen: Qwen3.5-35B-A3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.16, + input: 0.1625, output: 1.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", name: "Qwen: Qwen3.5 397B A17B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.39, @@ -11547,49 +9949,32 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65536, - }, - "qwen/qwen3.5-9b": { - id: "qwen/qwen3.5-9b", - name: "Qwen: Qwen3.5-9B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.05, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxOutput: 16384, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3.5-flash-02-23": { id: "qwen/qwen3.5-flash-02-23", name: "Qwen: Qwen3.5-Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.07, - output: 0.26, + input: 0.09999999999999999, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwen3.5-plus-02-15": { id: "qwen/qwen3.5-plus-02-15", name: "Qwen: Qwen3.5 Plus 2026-02-15", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.26, @@ -11598,42 +9983,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1000000, - maxOutput: 65536, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "qwen/qwq-32b": { id: "qwen/qwq-32b", name: "Qwen: QwQ 32B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.15, - output: 0.58, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxOutput: 131072, - }, - "relace/relace-apply-3": { - id: "relace/relace-apply-3", - name: "Relace: Relace Apply 3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.85, - output: 1.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxOutput: 128000, - }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "relace/relace-search": { id: "relace/relace-search", name: "Relace: Relace Search", @@ -11649,15 +10017,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 128000, - }, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "sao10k/l3-euryale-70b": { id: "sao10k/l3-euryale-70b", name: "Sao10k: Llama 3 Euryale 70B v2.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { input: 1.48, @@ -11666,66 +10034,15 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 8192, - maxOutput: 8192, - }, - "sao10k/l3-lunaris-8b": { - id: "sao10k/l3-lunaris-8b", - name: "Sao10K: Llama 3 8B Lunaris", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.04, - output: 0.05, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxOutput: 8192, - }, - "sao10k/l3.1-70b-hanami-x1": { - id: "sao10k/l3.1-70b-hanami-x1", - name: "Sao10K: Llama 3.1 70B Hanami x1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 3, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16000, - maxOutput: 16000, - }, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.85, - output: 0.85, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "sao10k/l3.3-euryale-70b": { - id: "sao10k/l3.3-euryale-70b", - name: "Sao10K: Llama 3.3 Euryale 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { input: 0.65, @@ -11733,33 +10050,33 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxOutput: 16384, - }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "stepfun/step-3.5-flash": { id: "stepfun/step-3.5-flash", name: "StepFun: Step 3.5 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.1, + input: 0.09999999999999999, output: 0.3, - cacheRead: 0, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 256000, - }, + maxTokens: 256000, + } satisfies Model<"openai-completions">, "stepfun/step-3.5-flash:free": { id: "stepfun/step-3.5-flash:free", name: "StepFun: Step 3.5 Flash (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -11768,59 +10085,8 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 256000, - }, - "switchpoint/router": { - id: "switchpoint/router", - name: "Switchpoint Router", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.85, - output: 3.4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 16384, - }, - "tencent/hunyuan-a13b-instruct": { - id: "tencent/hunyuan-a13b-instruct", - name: "Tencent: Hunyuan A13B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.14, - output: 0.57, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 131072, - }, - "thedrummer/cydonia-24b-v4.1": { - id: "thedrummer/cydonia-24b-v4.1", - name: "TheDrummer: Cydonia 24B V4.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxOutput: 131072, - }, + maxTokens: 256000, + } satisfies Model<"openai-completions">, "thedrummer/rocinante-12b": { id: "thedrummer/rocinante-12b", name: "TheDrummer: Rocinante 12B", @@ -11830,31 +10096,14 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.17, + input: 0.16999999999999998, output: 0.43, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 32768, - }, - "thedrummer/skyfall-36b-v2": { - id: "thedrummer/skyfall-36b-v2", - name: "TheDrummer: Skyfall 36B V2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.55, - output: 0.8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "thedrummer/unslopnemo-12b": { id: "thedrummer/unslopnemo-12b", name: "TheDrummer: UnslopNemo 12B", @@ -11864,82 +10113,48 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.4, - output: 0.4, + input: 0.39999999999999997, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxOutput: 32768, - }, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "tngtech/deepseek-r1t2-chimera": { id: "tngtech/deepseek-r1t2-chimera", name: "TNG: DeepSeek R1T2 Chimera", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.25, output: 0.85, - cacheRead: 0, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 163840, - maxOutput: 163840, - }, - "undi95/remm-slerp-l2-13b": { - id: "undi95/remm-slerp-l2-13b", - name: "ReMM SLERP 13B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.45, - output: 0.65, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 6144, - maxOutput: 4096, - }, + maxTokens: 163840, + } satisfies Model<"openai-completions">, "upstage/solar-pro-3": { id: "upstage/solar-pro-3", name: "Upstage: Solar Pro 3", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, - "writer/palmyra-x5": { - id: "writer/palmyra-x5", - name: "Writer: Palmyra X5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1040000, - maxOutput: 8192, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-3": { id: "x-ai/grok-3", name: "xAI: Grok 3", @@ -11951,12 +10166,12 @@ export const MODELS = { cost: { input: 3, output: 15, - cacheRead: 0, + cacheRead: 0.75, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-3-beta": { id: "x-ai/grok-3-beta", name: "xAI: Grok 3 Beta", @@ -11968,199 +10183,131 @@ export const MODELS = { cost: { input: 3, output: 15, - cacheRead: 0, + cacheRead: 0.75, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-3-mini": { id: "x-ai/grok-3-mini", name: "xAI: Grok 3 Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.3, output: 0.5, - cacheRead: 0, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-3-mini-beta": { id: "x-ai/grok-3-mini-beta", name: "xAI: Grok 3 Mini Beta", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.3, output: 0.5, - cacheRead: 0, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-4": { id: "x-ai/grok-4", name: "xAI: Grok 4", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, - cacheRead: 0, + cacheRead: 0.75, cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-4-fast": { id: "x-ai/grok-4-fast", name: "xAI: Grok 4 Fast", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 0.5, - cacheRead: 0, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 2000000, - maxOutput: 30000, - }, + maxTokens: 30000, + } satisfies Model<"openai-completions">, "x-ai/grok-4.1-fast": { id: "x-ai/grok-4.1-fast", name: "xAI: Grok 4.1 Fast", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 0.5, - cacheRead: 0, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 2000000, - maxOutput: 30000, - }, - "x-ai/grok-4.20-beta": { - id: "x-ai/grok-4.20-beta", - name: "xAI: Grok 4.20 Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxOutput: 16384, - }, - "x-ai/grok-4.20-multi-agent-beta": { - id: "x-ai/grok-4.20-multi-agent-beta", - name: "xAI: Grok 4.20 Multi-Agent Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxOutput: 16384, - }, + maxTokens: 30000, + } satisfies Model<"openai-completions">, "x-ai/grok-code-fast-1": { id: "x-ai/grok-code-fast-1", name: "xAI: Grok Code Fast 1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.2, + input: 0.19999999999999998, output: 1.5, - cacheRead: 0, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 256000, - maxOutput: 10000, - }, + maxTokens: 10000, + } satisfies Model<"openai-completions">, "xiaomi/mimo-v2-flash": { id: "xiaomi/mimo-v2-flash", name: "Xiaomi: MiMo-V2-Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.09, output: 0.29, - cacheRead: 0, + cacheRead: 0.045, cacheWrite: 0, }, contextWindow: 262144, - maxOutput: 65536, - }, - "xiaomi/mimo-v2-omni": { - id: "xiaomi/mimo-v2-omni", - name: "Xiaomi: MiMo-V2-Omni", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxOutput: 65536, - }, - "xiaomi/mimo-v2-pro": { - id: "xiaomi/mimo-v2-pro", - name: "Xiaomi: MiMo-V2-Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxOutput: 131072, - }, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "z-ai/glm-4-32b": { id: "z-ai/glm-4-32b", name: "Z.ai: GLM 4 32B ", @@ -12170,55 +10317,55 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, - output: 0.1, + input: 0.09999999999999999, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "z-ai/glm-4.5": { id: "z-ai/glm-4.5", name: "Z.ai: GLM 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.6, output: 2.2, - cacheRead: 0, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 98304, - }, + maxTokens: 98304, + } satisfies Model<"openai-completions">, "z-ai/glm-4.5-air": { id: "z-ai/glm-4.5-air", name: "Z.ai: GLM 4.5 Air", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.13, output: 0.85, - cacheRead: 0, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 98304, - }, + maxTokens: 98304, + } satisfies Model<"openai-completions">, "z-ai/glm-4.5-air:free": { id: "z-ai/glm-4.5-air:free", name: "Z.ai: GLM 4.5 Air (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, @@ -12227,32 +10374,32 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 96000, - }, + maxTokens: 96000, + } satisfies Model<"openai-completions">, "z-ai/glm-4.5v": { id: "z-ai/glm-4.5v", name: "Z.ai: GLM 4.5V", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.6, - output: 1.8, - cacheRead: 0, + output: 1.7999999999999998, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 65536, - maxOutput: 16384, - }, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "z-ai/glm-4.6": { id: "z-ai/glm-4.6", name: "Z.ai: GLM 4.6", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.39, @@ -12261,93 +10408,93 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 204800, - maxOutput: 204800, - }, + maxTokens: 204800, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6:exacto": { + id: "z-ai/glm-4.6:exacto", + name: "Z.ai: GLM 4.6 (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.44, + output: 1.76, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "z-ai/glm-4.6v": { id: "z-ai/glm-4.6v", name: "Z.ai: GLM 4.6V", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.3, - output: 0.9, + output: 0.8999999999999999, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxOutput: 131072, - }, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "z-ai/glm-4.7": { id: "z-ai/glm-4.7", name: "Z.ai: GLM 4.7", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.39, - output: 1.75, - cacheRead: 0, + input: 0.38, + output: 1.9800000000000002, + cacheRead: 0.19, cacheWrite: 0, }, contextWindow: 202752, - maxOutput: 65535, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "z-ai/glm-4.7-flash": { id: "z-ai/glm-4.7-flash", name: "Z.ai: GLM 4.7 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.06, - output: 0.4, - cacheRead: 0, + output: 0.39999999999999997, + cacheRead: 0.0100000002, cacheWrite: 0, }, contextWindow: 202752, - maxOutput: 16384, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "z-ai/glm-5": { id: "z-ai/glm-5", name: "Z.ai: GLM 5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.72, - output: 2.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 80000, - maxOutput: 131072, - }, - "z-ai/glm-5-turbo": { - id: "z-ai/glm-5-turbo", - name: "Z.ai: GLM 5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.96, - output: 3.2, - cacheRead: 0, + input: 0.6, + output: 1.9, + cacheRead: 0.119, cacheWrite: 0, }, contextWindow: 202752, - maxOutput: 131072, - }, + maxTokens: 4096, + } satisfies Model<"openai-completions">, }, "vercel-ai-gateway": { "alibaba/qwen-3-14b": { diff --git a/packages/pi-tui/src/utils.ts b/packages/pi-tui/src/utils.ts index 180221db4..430710aed 100644 --- a/packages/pi-tui/src/utils.ts +++ b/packages/pi-tui/src/utils.ts @@ -85,12 +85,7 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt * Delegates to the native Rust implementation. */ export function visibleWidth(str: string): number { - try { - return nativeVisibleWidth(str); - } catch { - // JS fallback — strip ANSI codes and return length (#1418) - return str.replace(/\x1b\[[0-9;]*m/g, "").length; - } + return nativeVisibleWidth(str); } /** @@ -102,28 +97,7 @@ export function visibleWidth(str: string): number { * @returns Array of wrapped lines (NOT padded to width) */ export function wrapTextWithAnsi(text: string, width: number): string[] { - try { - return nativeWrapTextWithAnsi(text, width); - } catch { - // JS fallback when native addon is unavailable (e.g., glibc mismatch on older Linux) (#1418) - const lines: string[] = []; - for (const line of text.split("\n")) { - if (line.length <= width) { - lines.push(line); - } else { - // Simple word-wrap without ANSI awareness - let remaining = line; - while (remaining.length > width) { - const breakAt = remaining.lastIndexOf(" ", width); - const splitPoint = breakAt > 0 ? breakAt : width; - lines.push(remaining.slice(0, splitPoint)); - remaining = remaining.slice(splitPoint).trimStart(); - } - if (remaining) lines.push(remaining); - } - } - return lines; - } + return nativeWrapTextWithAnsi(text, width); } /** diff --git a/scripts/generate-openrouter-models.mjs b/scripts/generate-openrouter-models.mjs deleted file mode 100644 index 34c9f23f9..000000000 --- a/scripts/generate-openrouter-models.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -/** - * Generate OpenRouter model entries for models.generated.ts - * - * Fetches the full model list from OpenRouter's API and generates - * TypeScript model entries matching the existing registry format. - * - * Usage: node scripts/generate-openrouter-models.mjs > /tmp/openrouter-models.ts - * - * The output is a partial TypeScript object that can be merged into - * packages/pi-ai/src/models.generated.ts under the "openrouter" key. - */ - -const API_URL = "https://openrouter.ai/api/v1/models"; - -async function fetchModels() { - const resp = await fetch(API_URL); - if (!resp.ok) throw new Error(`API returned ${resp.status}`); - const data = await resp.json(); - return data.data || []; -} - -function inferApi(model) { - // Models that support the responses API - if (model.id.startsWith("openai/") || model.id.startsWith("anthropic/")) { - return "openai-completions"; - } - return "openai-completions"; -} - -function inferReasoning(model) { - const id = model.id.toLowerCase(); - return id.includes("o1") || id.includes("o3") || id.includes("o4") || - id.includes("reasoning") || id.includes("think"); -} - -function inferInput(model) { - const arch = model.architecture || {}; - const modality = (arch.input_modalities || []).join(",").toLowerCase(); - if (modality.includes("image")) return '["text", "image"]'; - return '["text"]'; -} - -function formatCost(pricing) { - if (!pricing) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - // OpenRouter pricing is per-token in dollars; our format is per-million-tokens - const toPerMillion = (v) => Math.round(parseFloat(v || "0") * 1_000_000 * 100) / 100; - return { - input: toPerMillion(pricing.prompt), - output: toPerMillion(pricing.completion), - cacheRead: 0, - cacheWrite: 0, - }; -} - -async function main() { - const models = await fetchModels(); - - console.log('\t"openrouter": {'); - - for (const m of models.sort((a, b) => a.id.localeCompare(b.id))) { - const cost = formatCost(m.pricing); - const contextWindow = m.context_length || 128000; - const maxOutput = m.top_provider?.max_completion_tokens || Math.min(contextWindow, 16384); - const reasoning = inferReasoning(m); - const input = inferInput(m); - - console.log(`\t\t"${m.id}": {`); - console.log(`\t\t\tid: "${m.id}",`); - console.log(`\t\t\tname: ${JSON.stringify(m.name || m.id)},`); - console.log(`\t\t\tapi: "${inferApi(m)}",`); - console.log(`\t\t\tprovider: "openrouter",`); - console.log(`\t\t\tbaseUrl: "https://openrouter.ai/api/v1",`); - console.log(`\t\t\treasoning: ${reasoning},`); - console.log(`\t\t\tinput: ${input},`); - console.log(`\t\t\tcost: {`); - console.log(`\t\t\t\tinput: ${cost.input},`); - console.log(`\t\t\t\toutput: ${cost.output},`); - console.log(`\t\t\t\tcacheRead: ${cost.cacheRead},`); - console.log(`\t\t\t\tcacheWrite: ${cost.cacheWrite},`); - console.log(`\t\t\t},`); - console.log(`\t\t\tcontextWindow: ${contextWindow},`); - console.log(`\t\t\tmaxOutput: ${maxOutput},`); - console.log(`\t\t},`); - } - - console.log("\t},"); -} - -main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/src/bundled-resource-path.ts b/src/bundled-resource-path.ts new file mode 100644 index 000000000..1b36a0f94 --- /dev/null +++ b/src/bundled-resource-path.ts @@ -0,0 +1,18 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Resolve bundled raw resource files from the package root. + * + * Both `src/*.ts` and compiled `dist/*.js` entry points need to load the same + * raw `.ts` resource modules via jiti. Those modules are shipped under + * `src/resources/**`, not next to the compiled entry point. + */ +export function resolveBundledSourceResource( + importUrl: string, + ...segments: string[] +): string { + const moduleDir = dirname(fileURLToPath(importUrl)); + const packageRoot = resolve(moduleDir, ".."); + return join(packageRoot, "src", "resources", ...segments); +} diff --git a/src/headless-query.ts b/src/headless-query.ts index 1bb371008..b3faf9a8c 100644 --- a/src/headless-query.ts +++ b/src/headless-query.ts @@ -16,17 +16,18 @@ import { createJiti } from '@mariozechner/jiti' import { fileURLToPath } from 'node:url' -import { dirname, join } from 'node:path' import type { GSDState } from './resources/extensions/gsd/types.js' +import { resolveBundledSourceResource } from './bundled-resource-path.js' -const __dirname = dirname(fileURLToPath(import.meta.url)) const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false }) +const gsdExtensionPath = (...segments: string[]) => + resolveBundledSourceResource(import.meta.url, 'extensions', 'gsd', ...segments) async function loadExtensionModules() { - const stateModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/state.ts'), {}) as any - const dispatchModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/auto-dispatch.ts'), {}) as any - const sessionModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/session-status-io.ts'), {}) as any - const prefsModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/preferences.ts'), {}) as any + const stateModule = await jiti.import(gsdExtensionPath('state.ts'), {}) as any + const dispatchModule = await jiti.import(gsdExtensionPath('auto-dispatch.ts'), {}) as any + const sessionModule = await jiti.import(gsdExtensionPath('session-status-io.ts'), {}) as any + const prefsModule = await jiti.import(gsdExtensionPath('preferences.ts'), {}) as any return { deriveState: stateModule.deriveState as (basePath: string) => Promise, resolveDispatch: dispatchModule.resolveDispatch as (opts: any) => Promise, diff --git a/src/resources/extensions/gsd/auto-constants.ts b/src/resources/extensions/gsd/auto-constants.ts deleted file mode 100644 index 931d183db..000000000 --- a/src/resources/extensions/gsd/auto-constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Shared constants for auto-mode modules (auto.ts, auto-post-unit.ts, etc.). - */ - -/** Throttle STATE.md rebuilds — at most once per 30 seconds. */ -export const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 1c65b3cbd..3f2ed8a52 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -11,7 +11,6 @@ import type { GSDState } from "./types.js"; import { getCurrentBranch } from "./worktree.js"; import { getActiveHook } from "./post-unit-hooks.js"; import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js"; -import { getHealthTrend, getConsecutiveErrorUnits } from "./doctor-proactive.js"; import { resolveMilestoneFile, resolveSliceFile, @@ -20,7 +19,6 @@ import { parseRoadmap, parsePlan } from "./files.js"; import { readFileSync, existsSync } from "node:fs"; import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; import { makeUI, GLYPH, INDENT } from "../shared/mod.js"; -import { parseUnitId } from "./unit-id.js"; // ─── Dashboard Data ─────────────────────────────────────────────────────────── @@ -49,34 +47,40 @@ export interface AutoDashboardData { // ─── Unit Description Helpers ───────────────────────────────────────────────── -/** Canonical verb and phase label for each known unit type. */ -const UNIT_TYPE_INFO: Record = { - "research-milestone": { verb: "researching", phaseLabel: "RESEARCH" }, - "research-slice": { verb: "researching", phaseLabel: "RESEARCH" }, - "plan-milestone": { verb: "planning", phaseLabel: "PLAN" }, - "plan-slice": { verb: "planning", phaseLabel: "PLAN" }, - "execute-task": { verb: "executing", phaseLabel: "EXECUTE" }, - "complete-slice": { verb: "completing", phaseLabel: "COMPLETE" }, - "replan-slice": { verb: "replanning", phaseLabel: "REPLAN" }, - "rewrite-docs": { verb: "rewriting", phaseLabel: "REWRITE" }, - "reassess-roadmap": { verb: "reassessing", phaseLabel: "REASSESS" }, - "run-uat": { verb: "running UAT", phaseLabel: "UAT" }, -}; - export function unitVerb(unitType: string): string { if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`; - return UNIT_TYPE_INFO[unitType]?.verb ?? unitType; + switch (unitType) { + case "research-milestone": + case "research-slice": return "researching"; + case "plan-milestone": + case "plan-slice": return "planning"; + case "execute-task": return "executing"; + case "complete-slice": return "completing"; + case "replan-slice": return "replanning"; + case "rewrite-docs": return "rewriting"; + case "reassess-roadmap": return "reassessing"; + case "run-uat": return "running UAT"; + default: return unitType; + } } export function unitPhaseLabel(unitType: string): string { if (unitType.startsWith("hook/")) return "HOOK"; - return UNIT_TYPE_INFO[unitType]?.phaseLabel ?? unitType.toUpperCase(); + switch (unitType) { + case "research-milestone": return "RESEARCH"; + case "research-slice": return "RESEARCH"; + case "plan-milestone": return "PLAN"; + case "plan-slice": return "PLAN"; + case "execute-task": return "EXECUTE"; + case "complete-slice": return "COMPLETE"; + case "replan-slice": return "REPLAN"; + case "rewrite-docs": return "REWRITE"; + case "reassess-roadmap": return "REASSESS"; + case "run-uat": return "UAT"; + default: return unitType.toUpperCase(); + } } -/** - * Describe the expected next step after the current unit completes. - * Unit types here mirror the keys in UNIT_TYPE_INFO above. - */ function peekNext(unitType: string, state: GSDState): string { // Show active hook info in progress display const activeHookState = getActiveHook(); @@ -305,16 +309,6 @@ export function updateProgressWidget( } if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`; - // Set a string-array fallback first — this is the only version RPC mode will - // see, since the factory widget set below is not supported in RPC mode. - const progressText = buildProgressTextLines( - verb, phaseLabel, unitId, mid, slice, task, next, - accessors, tierBadge, widgetPwd, - ); - ctx.ui.setWidget("gsd-progress", progressText); - - // Set the factory-based widget — in TUI mode this replaces the string-array - // version with a dynamic, animated widget. In RPC mode this call is a no-op. ctx.ui.setWidget("gsd-progress", (tui, theme) => { let pulseBright = true; let cachedLines: string[] | undefined; @@ -372,11 +366,7 @@ export function updateProgressWidget( lines.push(""); - const isHook = unitType.startsWith("hook/"); - const hookParsed = isHook ? parseUnitId(unitId) : undefined; - const target = isHook - ? (hookParsed!.task ?? hookParsed!.slice ?? unitId) - : (task ? `${task.id}: ${task.title}` : unitId); + const target = task ? `${task.id}: ${task.title}` : unitId; const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : ""; const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`; @@ -396,10 +386,7 @@ export function updateProgressWidget( let meta = theme.fg("dim", `${done}/${total} slices`); if (activeSliceTasks && activeSliceTasks.total > 0) { - // For hooks, show the trigger task number (done), not the next task (done + 1) - const taskNum = isHook - ? Math.max(activeSliceTasks.done, 1) - : Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); + const taskNum = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`); } @@ -467,7 +454,6 @@ export function updateProgressWidget( sp.push(`\u26A1${hitRate}%`); } if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`); - else if (autoTotals?.apiRequests) sp.push(`${autoTotals.apiRequests} reqs`); const cxDisplay = cxPct === "?" ? `?/${formatWidgetTokens(cxWindow)}` @@ -526,95 +512,6 @@ export function updateProgressWidget( }); } -// ─── Text Fallback for RPC Mode ─────────────────────────────────────────── - -/** - * Build a compact string-array representation of the progress widget. - * Used as a fallback when the factory-based widget cannot render (RPC mode). - */ -// ─── Model Health Indicator ─────────────────────────────────────────────────── - -/** - * Compute a traffic-light health indicator from observable signals. - * 🟢 progressing well — no errors, trend stable/improving - * 🟡 struggling — some errors or degrading trend - * 🔴 stuck — consecutive errors, likely needs attention - */ -export function getModelHealthIndicator(): { emoji: string; label: string } { - const trend = getHealthTrend(); - const consecutiveErrors = getConsecutiveErrorUnits(); - - if (consecutiveErrors >= 3) { - return { emoji: "🔴", label: "stuck" }; - } - if (consecutiveErrors >= 1 || trend === "degrading") { - return { emoji: "🟡", label: "struggling" }; - } - if (trend === "improving") { - return { emoji: "🟢", label: "progressing well" }; - } - // stable or unknown - return { emoji: "🟢", label: "progressing" }; -} - -function buildProgressTextLines( - verb: string, - phaseLabel: string, - unitId: string, - mid: { id: string; title: string } | null, - slice: { id: string; title: string } | null, - task: { id: string; title: string } | null, - next: string, - accessors: WidgetStateAccessors, - tierBadge: string | undefined, - widgetPwd: string, -): string[] { - const mode = accessors.isStepMode() ? "step" : "auto"; - const elapsed = formatAutoElapsed(accessors.getAutoStartTime()); - const tierStr = tierBadge ? ` [${tierBadge}]` : ""; - - const lines: string[] = []; - lines.push(`[GSD ${mode}] ${verb} ${unitId}${tierStr}${elapsed ? ` — ${elapsed}` : ""}`); - - if (mid) lines.push(` Milestone: ${mid.id} — ${mid.title}`); - if (slice) lines.push(` Slice: ${slice.id} — ${slice.title}`); - if (task) lines.push(` Task: ${task.id} — ${task.title}`); - - // Progress bar - const sp = cachedSliceProgress; - if (sp && sp.total > 0) { - const pct = Math.round((sp.done / sp.total) * 100); - const taskInfo = sp.activeSliceTasks - ? ` (tasks: ${sp.activeSliceTasks.done}/${sp.activeSliceTasks.total})` - : ""; - lines.push(` Progress: ${sp.done}/${sp.total} slices (${pct}%)${taskInfo}`); - } - - // Cost / tokens - const ledger = getLedger(); - const totals = ledger ? getProjectTotals(ledger.units) : null; - if (totals) { - const parts: string[] = []; - if (totals.tokens.input || totals.tokens.output) { - parts.push(`tokens: ${formatWidgetTokens(totals.tokens.input)}↑ ${formatWidgetTokens(totals.tokens.output)}↓`); - } - if (totals.cost > 0) { - parts.push(`cost: ${formatCost(totals.cost)}`); - } - if (parts.length > 0) lines.push(` ${parts.join(" — ")}`); - } - - if (next) lines.push(` Next: ${next}`); - - // Model health indicator - const health = getModelHealthIndicator(); - lines.push(` Health: ${health.emoji} ${health.label}`); - - lines.push(` ${widgetPwd}`); - - return lines; -} - // ─── Right-align Helper ─────────────────────────────────────────────────────── /** Right-align helper: build a line with left content and right content. */ diff --git a/src/resources/extensions/gsd/auto-direct-dispatch.ts b/src/resources/extensions/gsd/auto-direct-dispatch.ts index 2888acb9e..1aac353db 100644 --- a/src/resources/extensions/gsd/auto-direct-dispatch.ts +++ b/src/resources/extensions/gsd/auto-direct-dispatch.ts @@ -182,10 +182,15 @@ export async function dispatchDirectPhase( ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning"); return; } + const uatContent = await loadFile(uatFile); + if (!uatContent) { + ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning"); + return; + } const uatPath = relSliceFile(base, mid, sid, "UAT"); unitType = "run-uat"; unitId = `${mid}/${sid}`; - prompt = await buildRunUatPrompt(mid, sid, uatPath, base); + prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base); break; } diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 2e9e69778..599b84db3 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -11,10 +11,15 @@ import type { GSDState } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; -import { loadFile, loadActiveOverrides, parseRoadmap } from "./files.js"; +import type { UatType } from "./files.js"; +import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; import { - resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, - relSliceFile, buildMilestoneFileName, + resolveMilestoneFile, + resolveMilestonePath, + resolveSliceFile, + resolveTaskFile, + relSliceFile, + buildMilestoneFileName, } from "./paths.js"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -38,7 +43,13 @@ import { // ─── Types ──────────────────────────────────────────────────────────────── export type DispatchAction = - | { action: "dispatch"; unitType: string; unitId: string; prompt: string } + | { + action: "dispatch"; + unitType: string; + unitId: string; + prompt: string; + pauseAfterDispatch?: boolean; + } | { action: "stop"; reason: string; level: "info" | "warning" | "error" } | { action: "skip" }; @@ -57,6 +68,14 @@ interface DispatchRule { match: (ctx: DispatchContext) => Promise; } +function missingSliceStop(mid: string, phase: string): DispatchAction { + return { + action: "stop", + reason: `${mid}: phase "${phase}" has no active slice — run /gsd doctor.`, + level: "error", + }; +} + // ─── Rewrite Circuit Breaker ────────────────────────────────────────────── const MAX_REWRITE_ATTEMPTS = 3; @@ -65,28 +84,6 @@ export function resetRewriteCircuitBreaker(): void { rewriteAttemptCount = 0; } -/** - * Guard for accessing activeSlice/activeTask in dispatch rules. - * Returns a stop action if the expected ref is null (corrupt state). - */ -function requireSlice(state: GSDState): { sid: string; sTitle: string } | DispatchAction { - if (!state.activeSlice) { - return { action: "stop", reason: `Phase "${state.phase}" but no active slice — run /gsd doctor.`, level: "error" }; - } - return { sid: state.activeSlice.id, sTitle: state.activeSlice.title }; -} - -function requireTask(state: GSDState): { sid: string; sTitle: string; tid: string; tTitle: string } | DispatchAction { - if (!state.activeSlice || !state.activeTask) { - return { action: "stop", reason: `Phase "${state.phase}" but no active slice/task — run /gsd doctor.`, level: "error" }; - } - return { sid: state.activeSlice.id, sTitle: state.activeSlice.title, tid: state.activeTask.id, tTitle: state.activeTask.title }; -} - -function isStopAction(v: unknown): v is DispatchAction { - return typeof v === "object" && v !== null && "action" in v; -} - // ─── Rules ──────────────────────────────────────────────────────────────── const DISPATCH_RULES: DispatchRule[] = [ @@ -107,7 +104,13 @@ const DISPATCH_RULES: DispatchRule[] = [ action: "dispatch", unitType: "rewrite-docs", unitId, - prompt: await buildRewriteDocsPrompt(mid, midTitle, state.activeSlice, basePath, pendingOverrides), + prompt: await buildRewriteDocsPrompt( + mid, + midTitle, + state.activeSlice, + basePath, + pendingOverrides, + ), }; }, }, @@ -115,74 +118,63 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "summarizing → complete-slice", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "summarizing") return null; - const sliceRef = requireSlice(state); - if (isStopAction(sliceRef)) return sliceRef as DispatchAction; - const { sid, sTitle } = sliceRef; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; return { action: "dispatch", unitType: "complete-slice", unitId: `${mid}/${sid}`, - prompt: await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, basePath), + prompt: await buildCompleteSlicePrompt( + mid, + midTitle, + sid, + sTitle, + basePath, + ), }; }, }, - { - name: "uat-verdict-gate (non-PASS blocks progression)", - match: async ({ mid, basePath, prefs }) => { - // Only applies when UAT dispatch is enabled - if (!prefs?.uat_dispatch) return null; - - const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - if (!roadmapContent) return null; - - const roadmap = parseRoadmap(roadmapContent); - for (const slice of roadmap.slices.filter(s => s.done)) { - const resultFile = resolveSliceFile(basePath, mid, slice.id, "UAT-RESULT"); - if (!resultFile) continue; - const content = await loadFile(resultFile); - if (!content) continue; - const verdictMatch = content.match(/verdict:\s*([\w-]+)/i); - const verdict = verdictMatch?.[1]?.toLowerCase(); - if (verdict && verdict !== "pass" && verdict !== "passed") { - return { - action: "stop" as const, - reason: `UAT verdict for ${slice.id} is "${verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /gsd auto after fixing.`, - level: "warning" as const, - }; - } - } - return null; - }, - }, { name: "run-uat (post-completion)", match: async ({ state, mid, basePath, prefs }) => { const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs); if (!needsRunUat) return null; - const { sliceId } = needsRunUat; + const { sliceId, uatType } = needsRunUat; + const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; + const uatContent = await loadFile(uatFile); return { action: "dispatch", unitType: "run-uat", unitId: `${mid}/${sliceId}`, prompt: await buildRunUatPrompt( - mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), basePath, + mid, + sliceId, + relSliceFile(basePath, mid, sliceId, "UAT"), + uatContent ?? "", + basePath, ), + pauseAfterDispatch: uatType !== "artifact-driven", }; }, }, { name: "reassess-roadmap (post-completion)", match: async ({ state, mid, midTitle, basePath, prefs }) => { - // Reassess is opt-in: only fire when explicitly enabled - if (!prefs?.phases?.reassess_after_slice) return null; + if (prefs?.phases?.skip_reassess || !prefs?.phases?.reassess_after_slice) + return null; const needsReassess = await checkNeedsReassessment(basePath, mid, state); if (!needsReassess) return null; return { action: "dispatch", unitType: "reassess-roadmap", unitId: `${mid}/${needsReassess.sliceId}`, - prompt: await buildReassessRoadmapPrompt(mid, midTitle, needsReassess.sliceId, basePath), + prompt: await buildReassessRoadmapPrompt( + mid, + midTitle, + needsReassess.sliceId, + basePath, + ), }; }, }, @@ -202,7 +194,7 @@ const DISPATCH_RULES: DispatchRule[] = [ match: async ({ state, mid, basePath }) => { if (state.phase !== "pre-planning") return null; const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); + const hasContext = !!(contextFile && (await loadFile(contextFile))); if (hasContext) return null; // fall through to next rule return { action: "stop", @@ -244,21 +236,32 @@ const DISPATCH_RULES: DispatchRule[] = [ match: async ({ state, mid, midTitle, basePath, prefs }) => { if (state.phase !== "planning") return null; // Phase skip: skip research when preference or profile says so - if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null; - const sliceRef = requireSlice(state); - if (isStopAction(sliceRef)) return sliceRef as DispatchAction; - const { sid, sTitle } = sliceRef; + if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) + return null; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); if (researchFile) return null; // has research, fall through // Skip slice research for S01 when milestone research already exists — // the milestone research already covers the same ground for the first slice. - const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); + const milestoneResearchFile = resolveMilestoneFile( + basePath, + mid, + "RESEARCH", + ); if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice return { action: "dispatch", unitType: "research-slice", unitId: `${mid}/${sid}`, - prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath), + prompt: await buildResearchSlicePrompt( + mid, + midTitle, + sid, + sTitle, + basePath, + ), }; }, }, @@ -266,14 +269,20 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "planning → plan-slice", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "planning") return null; - const sliceRef = requireSlice(state); - if (isStopAction(sliceRef)) return sliceRef as DispatchAction; - const { sid, sTitle } = sliceRef; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; return { action: "dispatch", unitType: "plan-slice", unitId: `${mid}/${sid}`, - prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath), + prompt: await buildPlanSlicePrompt( + mid, + midTitle, + sid, + sTitle, + basePath, + ), }; }, }, @@ -281,14 +290,20 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "replanning-slice → replan-slice", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "replanning-slice") return null; - const sliceRef = requireSlice(state); - if (isStopAction(sliceRef)) return sliceRef as DispatchAction; - const { sid, sTitle } = sliceRef; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; return { action: "dispatch", unitType: "replan-slice", unitId: `${mid}/${sid}`, - prompt: await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, basePath), + prompt: await buildReplanSlicePrompt( + mid, + midTitle, + sid, + sTitle, + basePath, + ), }; }, }, @@ -296,9 +311,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "executing → execute-task (recover missing task plan → plan-slice)", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "executing" || !state.activeTask) return null; - const sliceRef = requireSlice(state); - if (isStopAction(sliceRef)) return sliceRef as DispatchAction; - const { sid, sTitle } = sliceRef; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; const tid = state.activeTask.id; // Guard: if the slice plan exists but the individual task plan files are @@ -312,7 +327,13 @@ const DISPATCH_RULES: DispatchRule[] = [ action: "dispatch", unitType: "plan-slice", unitId: `${mid}/${sid}`, - prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath), + prompt: await buildPlanSlicePrompt( + mid, + midTitle, + sid, + sTitle, + basePath, + ), }; } @@ -323,9 +344,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "executing → execute-task", match: async ({ state, mid, basePath }) => { if (state.phase !== "executing" || !state.activeTask) return null; - const sliceRef = requireSlice(state); - if (isStopAction(sliceRef)) return sliceRef as DispatchAction; - const { sid, sTitle } = sliceRef; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; const tid = state.activeTask.id; const tTitle = state.activeTask.title; @@ -333,7 +354,14 @@ const DISPATCH_RULES: DispatchRule[] = [ action: "dispatch", unitType: "execute-task", unitId: `${mid}/${sid}/${tid}`, - prompt: await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath), + prompt: await buildExecuteTaskPrompt( + mid, + sid, + sTitle, + tid, + tTitle, + basePath, + ), }; }, }, @@ -346,7 +374,10 @@ const DISPATCH_RULES: DispatchRule[] = [ const mDir = resolveMilestonePath(basePath, mid); if (mDir) { if (!existsSync(mDir)) mkdirSync(mDir, { recursive: true }); - const validationPath = join(mDir, buildMilestoneFileName(mid, "VALIDATION")); + const validationPath = join( + mDir, + buildMilestoneFileName(mid, "VALIDATION"), + ); const content = [ "---", "verdict: pass", @@ -381,6 +412,17 @@ const DISPATCH_RULES: DispatchRule[] = [ }; }, }, + { + name: "complete → stop", + match: async ({ state }) => { + if (state.phase !== "complete") return null; + return { + action: "stop", + reason: "All milestones complete.", + level: "info", + }; + }, + }, ]; // ─── Resolver ───────────────────────────────────────────────────────────── @@ -389,7 +431,9 @@ const DISPATCH_RULES: DispatchRule[] = [ * Evaluate dispatch rules in order. Returns the first matching action, * or a "stop" action if no rule matches (unhandled phase). */ -export async function resolveDispatch(ctx: DispatchContext): Promise { +export async function resolveDispatch( + ctx: DispatchContext, +): Promise { for (const rule of DISPATCH_RULES) { const result = await rule.match(ctx); if (result) return result; @@ -405,5 +449,5 @@ export async function resolveDispatch(ctx: DispatchContext): Promise r.name); + return DISPATCH_RULES.map((r) => r.name); } diff --git a/src/resources/extensions/gsd/auto-idempotency.ts b/src/resources/extensions/gsd/auto-idempotency.ts deleted file mode 100644 index 8edc001b9..000000000 --- a/src/resources/extensions/gsd/auto-idempotency.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Idempotency checks for auto-mode unit dispatch. - * - * Handles completed-key membership, artifact cross-validation, - * consecutive skip counting, phantom skip loop detection, key eviction, - * and fallback persistence. - * - * Extracted from dispatchNextUnit() in auto.ts. Pure decision logic - * with set mutations — does NOT call dispatchNextUnit or stopAuto. - */ - -import { invalidateAllCaches } from "./cache.js"; -import { - verifyExpectedArtifact, - persistCompletedKey, - removePersistedKey, -} from "./auto-recovery.js"; -import { resolveMilestoneFile } from "./paths.js"; -import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js"; -import type { AutoSession } from "./auto/session.js"; -import { parseUnitId } from "./unit-id.js"; - -export interface IdempotencyContext { - s: AutoSession; - unitType: string; - unitId: string; - basePath: string; - /** Notification callback */ - notify: (message: string, level: "info" | "warning" | "error") => void; -} - -export type IdempotencyResult = - | { action: "skip"; reason: string } - | { action: "rerun"; reason: string } - | { action: "proceed" } - | { action: "stop"; reason: string }; - -/** - * Check whether a unit should be skipped (already completed), rerun - * (stale completion record), or dispatched normally. - * - * Mutates s.completedKeySet, s.unitConsecutiveSkips, s.unitLifetimeDispatches, - * and s.recentlyEvictedKeys as needed. - */ -export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult { - const { s, unitType, unitId, basePath, notify } = ictx; - const idempotencyKey = `${unitType}/${unitId}`; - - // ── Primary path: key exists in completed set ── - if (s.completedKeySet.has(idempotencyKey)) { - const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath); - if (artifactExists) { - // Guard against infinite skip loops - const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; - s.unitConsecutiveSkips.set(idempotencyKey, skipCount); - if (skipCount > MAX_CONSECUTIVE_SKIPS) { - // Cross-check: verify the unit's milestone is still active (#790) - const skippedMid = parseUnitId(unitId).milestone; - const skippedMilestoneComplete = skippedMid - ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY") - : false; - if (skippedMilestoneComplete) { - s.unitConsecutiveSkips.delete(idempotencyKey); - invalidateAllCaches(); - notify( - `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`, - "info", - ); - return { action: "skip", reason: "phantom-loop-cleared" }; - } - s.unitConsecutiveSkips.delete(idempotencyKey); - s.completedKeySet.delete(idempotencyKey); - s.recentlyEvictedKeys.add(idempotencyKey); - removePersistedKey(basePath, idempotencyKey); - invalidateAllCaches(); - notify( - `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`, - "warning", - ); - return { action: "skip", reason: "evicted" }; - } - // Count toward lifetime cap - const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; - s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip); - if (lifeSkip > MAX_LIFETIME_DISPATCHES) { - return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` }; - } - notify( - `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`, - "info", - ); - return { action: "skip", reason: "completed" }; - } else { - // Stale completion record — artifact missing. Remove and re-run. - s.completedKeySet.delete(idempotencyKey); - removePersistedKey(basePath, idempotencyKey); - notify( - `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`, - "warning", - ); - return { action: "rerun", reason: "stale-key" }; - } - } - - // ── Fallback: key missing but artifact exists ── - if (verifyExpectedArtifact(unitType, unitId, basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) { - persistCompletedKey(basePath, idempotencyKey); - s.completedKeySet.add(idempotencyKey); - invalidateAllCaches(); - // Same consecutive-skip guard as the primary path - const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; - s.unitConsecutiveSkips.set(idempotencyKey, skipCount2); - if (skipCount2 > MAX_CONSECUTIVE_SKIPS) { - const skippedMid2 = parseUnitId(unitId).milestone; - const skippedMilestoneComplete2 = skippedMid2 - ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY") - : false; - if (skippedMilestoneComplete2) { - s.unitConsecutiveSkips.delete(idempotencyKey); - invalidateAllCaches(); - notify( - `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`, - "info", - ); - return { action: "skip", reason: "phantom-loop-cleared" }; - } - s.unitConsecutiveSkips.delete(idempotencyKey); - s.completedKeySet.delete(idempotencyKey); - removePersistedKey(basePath, idempotencyKey); - invalidateAllCaches(); - notify( - `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`, - "warning", - ); - return { action: "skip", reason: "evicted" }; - } - // Count toward lifetime cap - const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; - s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2); - if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) { - return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` }; - } - notify( - `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`, - "info", - ); - return { action: "skip", reason: "fallback-persisted" }; - } - - return { action: "proceed" }; -} diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts new file mode 100644 index 000000000..74616f3f3 --- /dev/null +++ b/src/resources/extensions/gsd/auto-loop.ts @@ -0,0 +1,1665 @@ +/** + * auto-loop.ts — Linear loop execution backbone for auto-mode. + * + * Replaces the recursive dispatchNextUnit → handleAgentEnd → dispatchNextUnit + * pattern with a while loop. The agent_end event resolves a promise instead + * of recursing. + * + * MAINTENANCE RULE: The only module-level mutable state here is `_activeSession`, + * used by the agent_end bridge. Promise state itself lives on AutoSession so + * concurrent auto sessions cannot corrupt each other. + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; + +import type { AutoSession } from "./auto/session.js"; +import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js"; +import type { GSDPreferences } from "./preferences.js"; +import type { GSDState } from "./types.js"; +import type { CloseoutOptions } from "./auto-unit-closeout.js"; +import type { PostUnitContext } from "./auto-post-unit.js"; +import type { + VerificationContext, + VerificationResult, +} from "./auto-verification.js"; +import type { DispatchAction } from "./auto-dispatch.js"; +import type { WorktreeResolver } from "./worktree-resolver.js"; +import { debugLog } from "./debug-logger.js"; + +/** + * Maximum total loop iterations before forced stop. Prevents runaway loops + * when units alternate IDs (bypassing the same-unit stuck detector). + * A milestone with 20 slices × 5 tasks × 3 phases ≈ 300 units. 500 gives + * generous headroom including retries and sidecar work. + */ +const MAX_LOOP_ITERATIONS = 500; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** + * Minimal shape of the event parameter from pi.on("agent_end", ...). + * The full event has more fields, but the loop only needs messages. + */ +export interface AgentEndEvent { + messages: unknown[]; +} + +/** + * Result of a single unit execution (one iteration of the loop). + */ +export interface UnitResult { + status: "completed" | "cancelled" | "error"; + event?: AgentEndEvent; +} + +// ─── Session-scoped promise state ─────────────────────────────────────────── +// +// pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level) +// so concurrent sessions cannot corrupt each other's promises. + +/** + * The singleton session reference used by resolveAgentEnd. Set by autoLoop + * on entry so that the agent_end handler in index.ts can resolve the correct + * session's promise without needing a direct reference to `s`. + */ +let _activeSession: AutoSession | null = null; + +// ─── resolveAgentEnd ───────────────────────────────────────────────────────── + +/** + * Called from the agent_end event handler in index.ts to resolve the + * in-flight unit promise. One-shot: the resolver is nulled before calling + * to prevent double-resolution from model fallback retries. + * + * If no pendingResolve exists (event arrived between loop iterations), + * the event is queued on the session so the next runUnit can drain it. + */ +export function resolveAgentEnd(event: AgentEndEvent): void { + const s = _activeSession; + if (!s) { + debugLog("resolveAgentEnd", { + status: "no-active-session", + warning: "agent_end with no active loop session", + }); + return; + } + + if (s.pendingResolve) { + debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true }); + const r = s.pendingResolve; + s.pendingResolve = null; + r({ status: "completed", event }); + } else { + // Queue the event so the next runUnit picks it up immediately + debugLog("resolveAgentEnd", { + status: "queued", + queueLength: s.pendingAgentEndQueue.length + 1, + warning: + "agent_end arrived between loop iterations — queued for next runUnit", + }); + s.pendingAgentEndQueue.push(event); + } +} + +export function isSessionSwitchInFlight(): boolean { + return _activeSession?.sessionSwitchInFlight ?? false; +} + +// ─── resetPendingResolve (test helper) ─────────────────────────────────────── + +/** + * Reset session promise state. Only exported for test cleanup — production code + * should never call this. + */ +export function _resetPendingResolve(): void { + if (_activeSession) { + _activeSession.pendingResolve = null; + _activeSession.pendingAgentEndQueue = []; + } + _activeSession = null; +} + +/** + * Set the active session for resolveAgentEnd. Only exported for test setup — + * production code sets this via autoLoop entry. + */ +export function _setActiveSession(session: AutoSession | null): void { + _activeSession = session; +} + +// ─── runUnit ───────────────────────────────────────────────────────────────── + +/** + * Execute a single unit: create a new session, send the prompt, and await + * the agent_end promise. Returns a UnitResult describing what happened. + * + * The promise is one-shot: resolveAgentEnd() is the only way to resolve it. + * On session creation failure or timeout, returns { status: 'cancelled' } + * without awaiting the promise. + */ +export async function runUnit( + ctx: ExtensionContext, + pi: ExtensionAPI, + s: AutoSession, + unitType: string, + unitId: string, + prompt: string, + _prefs: GSDPreferences | undefined, +): Promise { + debugLog("runUnit", { phase: "start", unitType, unitId }); + + // ── Drain queued events from error-recovery retries ── + // If an agent_end arrived between iterations (e.g. from a model fallback + // sendMessage retry), consume it immediately instead of creating a new promise. + // Cap queue to 3 entries to prevent unbounded growth from stale events. + if (s.pendingAgentEndQueue.length > 3) { + debugLog("runUnit", { + phase: "queue-overflow", + dropped: s.pendingAgentEndQueue.length - 1, + unitType, + unitId, + }); + s.pendingAgentEndQueue = [ + s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1]!, + ]; + } + if (s.pendingAgentEndQueue.length > 0) { + const queued = s.pendingAgentEndQueue.shift()!; + debugLog("runUnit", { + phase: "drained-queued-event", + unitType, + unitId, + queueRemaining: s.pendingAgentEndQueue.length, + }); + return { status: "completed", event: queued }; + } + + // ── Session creation with timeout ── + debugLog("runUnit", { phase: "session-create", unitType, unitId }); + + let sessionResult: { cancelled: boolean }; + let sessionTimeoutHandle: ReturnType | undefined; + s.sessionSwitchInFlight = true; + try { + const sessionPromise = s.cmdCtx!.newSession().finally(() => { + s.sessionSwitchInFlight = false; + }); + const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => { + sessionTimeoutHandle = setTimeout( + () => resolve({ cancelled: true }), + NEW_SESSION_TIMEOUT_MS, + ); + }); + sessionResult = await Promise.race([sessionPromise, timeoutPromise]); + } catch (sessionErr) { + if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); + const msg = + sessionErr instanceof Error ? sessionErr.message : String(sessionErr); + debugLog("runUnit", { + phase: "session-error", + unitType, + unitId, + error: msg, + }); + return { status: "cancelled" }; + } + if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); + + if (sessionResult.cancelled) { + debugLog("runUnit-session-timeout", { unitType, unitId }); + return { status: "cancelled" }; + } + + if (!s.active) { + return { status: "cancelled" }; + } + + // ── Create the agent_end promise (session-scoped) ── + // This happens after newSession completes so session-switch agent_end events + // from the previous session cannot resolve the new unit. + const unitPromise = new Promise((resolve) => { + s.pendingResolve = resolve; + }); + + // ── Send the prompt ── + debugLog("runUnit", { phase: "send-message", unitType, unitId }); + + pi.sendMessage( + { customType: "gsd-auto", content: prompt, display: s.verbose }, + { triggerTurn: true }, + ); + + // ── Await agent_end ── + debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId }); + const result = await unitPromise; + debugLog("runUnit", { + phase: "agent-end-received", + unitType, + unitId, + status: result.status, + }); + + return result; +} + +// ─── LoopDeps ──────────────────────────────────────────────────────────────── + +/** + * Dependencies injected by the caller (auto.ts startAuto) so autoLoop + * can access private functions from auto.ts without exporting them. + */ +export interface LoopDeps { + lockBase: () => string; + buildSnapshotOpts: ( + unitType: string, + unitId: string, + ) => CloseoutOptions & Record; + stopAuto: ( + ctx?: ExtensionContext, + pi?: ExtensionAPI, + reason?: string, + ) => Promise; + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; + clearUnitTimeout: () => void; + updateProgressWidget: ( + ctx: ExtensionContext, + unitType: string, + unitId: string, + state: GSDState, + ) => void; + + // State and cache functions + invalidateAllCaches: () => void; + deriveState: (basePath: string) => Promise; + loadEffectiveGSDPreferences: () => + | { preferences?: GSDPreferences } + | undefined; + + // Pre-dispatch health gate + preDispatchHealthGate: ( + basePath: string, + ) => Promise<{ proceed: boolean; reason?: string; fixesApplied: string[] }>; + + // Worktree sync + syncProjectRootToWorktree: ( + originalBase: string, + basePath: string, + milestoneId: string | null, + ) => void; + + // Resource version guard + checkResourcesStale: (version: string | null) => string | null; + + // Session lock + validateSessionLock: (basePath: string) => boolean; + updateSessionLock: ( + basePath: string, + unitType: string, + unitId: string, + completedUnits: number, + sessionFile?: string, + ) => void; + handleLostSessionLock: (ctx?: ExtensionContext) => void; + + // Milestone transition functions + sendDesktopNotification: ( + title: string, + body: string, + kind: string, + category: string, + ) => void; + setActiveMilestoneId: (basePath: string, mid: string) => void; + pruneQueueOrder: (basePath: string, pendingIds: string[]) => void; + isInAutoWorktree: (basePath: string) => boolean; + shouldUseWorktreeIsolation: () => boolean; + mergeMilestoneToMain: ( + basePath: string, + milestoneId: string, + roadmapContent: string, + ) => { pushed: boolean }; + teardownAutoWorktree: (basePath: string, milestoneId: string) => void; + createAutoWorktree: (basePath: string, milestoneId: string) => string; + captureIntegrationBranch: ( + basePath: string, + mid: string, + opts?: { commitDocs?: boolean }, + ) => void; + getIsolationMode: () => string; + getCurrentBranch: (basePath: string) => string; + autoWorktreeBranch: (milestoneId: string) => string; + resolveMilestoneFile: ( + basePath: string, + milestoneId: string, + fileType: string, + ) => string | null; + reconcileMergeState: (basePath: string, ctx: ExtensionContext) => boolean; + + // Budget/context/secrets + getLedger: () => unknown; + getProjectTotals: (units: unknown) => { cost: number }; + formatCost: (cost: number) => string; + getBudgetAlertLevel: (pct: number) => number; + getNewBudgetAlertLevel: (lastLevel: number, pct: number) => number; + getBudgetEnforcementAction: (enforcement: string, pct: number) => string; + getManifestStatus: ( + basePath: string, + mid: string | undefined, + ) => Promise<{ pending: unknown[] } | null>; + collectSecretsFromManifest: ( + basePath: string, + mid: string | undefined, + ctx: ExtensionContext, + ) => Promise<{ + applied: unknown[]; + skipped: unknown[]; + existingSkipped: unknown[]; + } | null>; + + // Dispatch + resolveDispatch: (dctx: { + basePath: string; + mid: string; + midTitle: string; + state: GSDState; + prefs: GSDPreferences | undefined; + }) => Promise; + runPreDispatchHooks: ( + unitType: string, + unitId: string, + prompt: string, + basePath: string, + ) => { + firedHooks: string[]; + action: string; + prompt?: string; + unitType?: string; + }; + getPriorSliceCompletionBlocker: ( + basePath: string, + mainBranch: string, + unitType: string, + unitId: string, + ) => string | null; + getMainBranch: (basePath: string) => string; + collectObservabilityWarnings: ( + ctx: ExtensionContext, + basePath: string, + unitType: string, + unitId: string, + ) => Promise; + buildObservabilityRepairBlock: (issues: unknown[]) => string | null; + + // Unit closeout + runtime records + closeoutUnit: ( + ctx: ExtensionContext, + basePath: string, + unitType: string, + unitId: string, + startedAt: number, + opts?: CloseoutOptions & Record, + ) => Promise; + verifyExpectedArtifact: ( + unitType: string, + unitId: string, + basePath: string, + ) => boolean; + clearUnitRuntimeRecord: ( + basePath: string, + unitType: string, + unitId: string, + ) => void; + writeUnitRuntimeRecord: ( + basePath: string, + unitType: string, + unitId: string, + startedAt: number, + record: Record, + ) => void; + recordOutcome: (unitType: string, tier: string, success: boolean) => void; + writeLock: ( + lockBase: string, + unitType: string, + unitId: string, + completedCount: number, + sessionFile?: string, + ) => void; + captureAvailableSkills: () => void; + ensurePreconditions: ( + unitType: string, + unitId: string, + basePath: string, + state: GSDState, + ) => void; + updateSliceProgressCache: ( + basePath: string, + mid: string, + sliceId?: string, + ) => void; + + // Model selection + supervision + selectAndApplyModel: ( + ctx: ExtensionContext, + pi: ExtensionAPI, + unitType: string, + unitId: string, + basePath: string, + prefs: GSDPreferences | undefined, + verbose: boolean, + startModel: { provider: string; id: string } | null, + ) => Promise<{ routing: { tier: string; modelDowngraded: boolean } | null }>; + startUnitSupervision: (sctx: { + s: AutoSession; + ctx: ExtensionContext; + pi: ExtensionAPI; + unitType: string; + unitId: string; + prefs: GSDPreferences | undefined; + buildSnapshotOpts: () => CloseoutOptions & Record; + buildRecoveryContext: () => unknown; + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; + }) => void; + + // Prompt helpers + getDeepDiagnostic: (basePath: string) => string | null; + isDbAvailable: () => boolean; + reorderForCaching: (prompt: string) => string; + + // Filesystem + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; + atomicWriteSync: (path: string, content: string) => void; + + // Git + GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown; + + // WorktreeResolver + resolver: WorktreeResolver; + + // Post-unit processing + postUnitPreVerification: ( + pctx: PostUnitContext, + ) => Promise<"dispatched" | "continue">; + runPostUnitVerification: ( + vctx: VerificationContext, + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, + ) => Promise; + postUnitPostVerification: ( + pctx: PostUnitContext, + ) => Promise<"continue" | "step-wizard" | "stopped">; + + // Session manager + getSessionFile: (ctx: ExtensionContext) => string; +} + +// ─── autoLoop ──────────────────────────────────────────────────────────────── + +/** + * Main auto-mode execution loop. Iterates: derive → dispatch → guards → + * runUnit → finalize → repeat. Exits when s.active becomes false or a + * terminal condition is reached. + * + * This is the linear replacement for the recursive + * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain. + */ +export async function autoLoop( + ctx: ExtensionContext, + pi: ExtensionAPI, + s: AutoSession, + deps: LoopDeps, +): Promise { + debugLog("autoLoop", { phase: "enter" }); + _activeSession = s; + let iteration = 0; + let lastDerivedUnit = ""; + let sameUnitCount = 0; + + let consecutiveErrors = 0; + + while (s.active) { + iteration++; + debugLog("autoLoop", { phase: "loop-top", iteration }); + + if (iteration > MAX_LOOP_ITERATIONS) { + debugLog("autoLoop", { + phase: "exit", + reason: "max-iterations", + iteration, + }); + await deps.stopAuto( + ctx, + pi, + `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`, + ); + break; + } + + if (!s.cmdCtx) { + debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" }); + break; + } + + try { + // ── Blanket try/catch: one bad iteration must not kill the session + + if (deps.lockBase() && !deps.validateSessionLock(deps.lockBase())) { + deps.handleLostSessionLock(ctx); + debugLog("autoLoop", { phase: "exit", reason: "session-lock-lost" }); + break; + } + + // ── Phase 1: Pre-dispatch ─────────────────────────────────────────── + + // Resource version guard + const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart); + if (staleMsg) { + await deps.stopAuto(ctx, pi, staleMsg); + debugLog("autoLoop", { phase: "exit", reason: "resources-stale" }); + break; + } + + deps.invalidateAllCaches(); + s.lastPromptCharCount = undefined; + s.lastBaselineCharCount = undefined; + + // Pre-dispatch health gate + try { + const healthGate = await deps.preDispatchHealthGate(s.basePath); + if (healthGate.fixesApplied.length > 0) { + ctx.ui.notify( + `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, + "info", + ); + } + if (!healthGate.proceed) { + ctx.ui.notify( + healthGate.reason ?? "Pre-dispatch health check failed.", + "error", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" }); + break; + } + } catch { + // Non-fatal + } + + // Sync project root artifacts into worktree + if ( + s.originalBasePath && + s.basePath !== s.originalBasePath && + s.currentMilestoneId + ) { + deps.syncProjectRootToWorktree( + s.originalBasePath, + s.basePath, + s.currentMilestoneId, + ); + } + + // Derive state + let state = await deps.deriveState(s.basePath); + let mid = state.activeMilestone?.id; + let midTitle = state.activeMilestone?.title; + debugLog("autoLoop", { + phase: "state-derived", + iteration, + mid, + statePhase: state.phase, + }); + + // ── Milestone transition ──────────────────────────────────────────── + if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { + ctx.ui.notify( + `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, + "info", + ); + deps.sendDesktopNotification( + "GSD", + `Milestone ${s.currentMilestoneId} complete!`, + "success", + "milestone", + ); + + const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences; + if (vizPrefs?.auto_visualize) { + ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); + } + if (vizPrefs?.auto_report !== false) { + try { + const { loadVisualizerData } = await import("./visualizer-data.js"); + const { generateHtmlReport } = await import("./export-html.js"); + const { writeReportSnapshot } = await import("./reports.js"); + const { basename } = await import("node:path"); + const snapData = await loadVisualizerData(s.basePath); + const completedMs = snapData.milestones.find( + (m: { id: string }) => m.id === s.currentMilestoneId, + ); + const msTitle = completedMs?.title ?? s.currentMilestoneId; + const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; + const projName = basename(s.basePath); + const doneSlices = snapData.milestones.reduce( + (acc: number, m: { slices: { done: boolean }[] }) => + acc + + m.slices.filter((sl: { done: boolean }) => sl.done).length, + 0, + ); + const totalSlices = snapData.milestones.reduce( + (acc: number, m: { slices: unknown[] }) => acc + m.slices.length, + 0, + ); + const outPath = writeReportSnapshot({ + basePath: s.basePath, + html: generateHtmlReport(snapData, { + projectName: projName, + projectPath: s.basePath, + gsdVersion, + milestoneId: s.currentMilestoneId, + indexRelPath: "index.html", + }), + milestoneId: s.currentMilestoneId!, + milestoneTitle: msTitle, + kind: "milestone", + projectName: projName, + projectPath: s.basePath, + gsdVersion, + totalCost: snapData.totals?.cost ?? 0, + totalTokens: snapData.totals?.tokens.total ?? 0, + totalDuration: snapData.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: snapData.milestones.filter( + (m: { status: string }) => m.status === "complete", + ).length, + totalMilestones: snapData.milestones.length, + phase: snapData.phase, + }); + ctx.ui.notify( + `Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`, + "info", + ); + } catch (err) { + ctx.ui.notify( + `Report generation failed: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + } + + // Reset dispatch counters for new milestone + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.unitLifetimeDispatches.clear(); + lastDerivedUnit = ""; + sameUnitCount = 0; + + // Worktree lifecycle on milestone transition — merge current, enter next + deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui); + deps.invalidateAllCaches(); + + state = await deps.deriveState(s.basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + + if (mid) { + if (deps.getIsolationMode() !== "none") { + deps.captureIntegrationBranch(s.basePath, mid, { + commitDocs: + deps.loadEffectiveGSDPreferences()?.preferences?.git + ?.commit_docs, + }); + } + deps.resolver.enterMilestone(mid, ctx.ui); + } else { + // mid is undefined — no milestone to capture integration branch for + } + + const pendingIds = state.registry + .filter( + (m: { status: string }) => + m.status !== "complete" && m.status !== "parked", + ) + .map((m: { id: string }) => m.id); + deps.pruneQueueOrder(s.basePath, pendingIds); + } + + if (mid) { + s.currentMilestoneId = mid; + deps.setActiveMilestoneId(s.basePath, mid); + } + + // ── Terminal conditions ────────────────────────────────────────────── + + if (!mid) { + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + } + + const incomplete = state.registry.filter( + (m: { status: string }) => + m.status !== "complete" && m.status !== "parked", + ); + if (incomplete.length === 0) { + // All milestones complete — merge milestone branch before stopping + if (s.currentMilestoneId) { + deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); + } + deps.sendDesktopNotification( + "GSD", + "All milestones complete!", + "success", + "milestone", + ); + await deps.stopAuto(ctx, pi, "All milestones complete"); + } else if (state.phase === "blocked") { + const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; + await deps.stopAuto(ctx, pi, blockerMsg); + ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); + deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + } else { + const ids = incomplete.map((m: { id: string }) => m.id).join(", "); + const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${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 deps.stopAuto( + ctx, + pi, + `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`, + ); + } + debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); + break; + } + + if (!midTitle) { + midTitle = mid; + ctx.ui.notify( + `Milestone ${mid} has no title in roadmap — using ID as fallback.`, + "warning", + ); + } + + // Mid-merge safety check + if (deps.reconcileMergeState(s.basePath, ctx)) { + deps.invalidateAllCaches(); + state = await deps.deriveState(s.basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } + + if (!mid || !midTitle) { + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + } + const noMilestoneReason = !mid + ? "No active milestone after merge reconciliation" + : `Milestone ${mid} has no title after reconciliation`; + await deps.stopAuto(ctx, pi, noMilestoneReason); + debugLog("autoLoop", { + phase: "exit", + reason: "no-milestone-after-reconciliation", + }); + break; + } + + // Terminal: complete + if (state.phase === "complete") { + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + } + // Milestone merge on complete + if (s.currentMilestoneId) { + deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); + } + deps.sendDesktopNotification( + "GSD", + `Milestone ${mid} complete!`, + "success", + "milestone", + ); + await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`); + debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); + break; + } + + // Terminal: blocked + if (state.phase === "blocked") { + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + } + const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; + await deps.stopAuto(ctx, pi, blockerMsg); + ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); + deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + debugLog("autoLoop", { phase: "exit", reason: "blocked" }); + break; + } + + // ── Phase 2: Guards ───────────────────────────────────────────────── + + const prefs = deps.loadEffectiveGSDPreferences()?.preferences; + + // Budget ceiling guard + const budgetCeiling = prefs?.budget_ceiling; + if (budgetCeiling !== undefined && budgetCeiling > 0) { + const currentLedger = deps.getLedger() as { units: unknown } | null; + const totalCost = currentLedger + ? deps.getProjectTotals(currentLedger.units).cost + : 0; + const budgetPct = totalCost / budgetCeiling; + const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct); + const newBudgetAlertLevel = deps.getNewBudgetAlertLevel( + s.lastBudgetAlertLevel, + budgetPct, + ); + const enforcement = prefs?.budget_enforcement ?? "pause"; + const budgetEnforcementAction = deps.getBudgetEnforcementAction( + enforcement, + budgetPct, + ); + + if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") { + const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; + s.lastBudgetAlertLevel = + newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; + if (budgetEnforcementAction === "halt") { + deps.sendDesktopNotification("GSD", msg, "error", "budget"); + await deps.stopAuto(ctx, pi, "Budget ceiling reached"); + debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); + break; + } + if (budgetEnforcementAction === "pause") { + ctx.ui.notify( + `${msg} Pausing auto-mode — /gsd auto to override and continue.`, + "warning", + ); + deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); + break; + } + ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); + deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + } else if (newBudgetAlertLevel === 90) { + s.lastBudgetAlertLevel = + newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; + ctx.ui.notify( + `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "warning", + ); + deps.sendDesktopNotification( + "GSD", + `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "warning", + "budget", + ); + } else if (newBudgetAlertLevel === 80) { + s.lastBudgetAlertLevel = + newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; + ctx.ui.notify( + `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "warning", + ); + deps.sendDesktopNotification( + "GSD", + `Approaching budget ceiling — 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "warning", + "budget", + ); + } else if (newBudgetAlertLevel === 75) { + s.lastBudgetAlertLevel = + newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; + ctx.ui.notify( + `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "info", + ); + deps.sendDesktopNotification( + "GSD", + `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "info", + "budget", + ); + } else if (budgetAlertLevel === 0) { + s.lastBudgetAlertLevel = 0; + } + } else { + s.lastBudgetAlertLevel = 0; + } + + // Context window guard + const contextThreshold = prefs?.context_pause_threshold ?? 0; + 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", + ); + deps.sendDesktopNotification( + "GSD", + `Context ${contextUsage.percent}% — paused`, + "warning", + "attention", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "context-window" }); + break; + } + } + + // Secrets re-check gate + try { + const manifestStatus = await deps.getManifestStatus(s.basePath, mid); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await deps.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.`, + "info", + ); + } else { + ctx.ui.notify("Secrets collection skipped.", "info"); + } + } + } catch (err) { + ctx.ui.notify( + `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, + "warning", + ); + } + + // ── Phase 3: Dispatch resolution ──────────────────────────────────── + + debugLog("autoLoop", { phase: "dispatch-resolve", iteration }); + const dispatchResult = await deps.resolveDispatch({ + basePath: s.basePath, + mid, + midTitle: midTitle!, + state, + prefs, + }); + + if (dispatchResult.action === "stop") { + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + } + await deps.stopAuto(ctx, pi, dispatchResult.reason); + debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); + break; + } + + if (dispatchResult.action !== "dispatch") { + // Non-dispatch action (e.g. "skip") — re-derive state + await new Promise((r) => setImmediate(r)); + continue; + } + + let unitType = dispatchResult.unitType; + let unitId = dispatchResult.unitId; + let prompt = dispatchResult.prompt; + const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; + + // ── Same-unit stuck counter with graduated recovery ── + const derivedKey = `${unitType}/${unitId}`; + if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) { + sameUnitCount++; + debugLog("autoLoop", { + phase: "stuck-check", + unitType, + unitId, + sameUnitCount, + }); + + if (sameUnitCount === 3) { + // Level 1: try verifying the artifact — maybe it was written but not detected + const artifactExists = deps.verifyExpectedArtifact( + unitType, + unitId, + s.basePath, + ); + if (artifactExists) { + debugLog("autoLoop", { + phase: "stuck-recovery", + level: 1, + action: "artifact-found", + }); + ctx.ui.notify( + `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, + "info", + ); + deps.invalidateAllCaches(); + continue; + } + ctx.ui.notify( + `Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`, + "warning", + ); + deps.invalidateAllCaches(); + } else if (sameUnitCount === 5) { + // Level 2: hard stop — genuinely stuck + debugLog("autoLoop", { + phase: "stuck-detected", + unitType, + unitId, + sameUnitCount, + }); + await deps.stopAuto( + ctx, + pi, + `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`, + ); + ctx.ui.notify( + `Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`, + "error", + ); + break; + } + } else { + if (derivedKey !== lastDerivedUnit) { + debugLog("autoLoop", { + phase: "stuck-counter-reset", + from: lastDerivedUnit, + to: derivedKey, + }); + } + lastDerivedUnit = derivedKey; + sameUnitCount = 0; + } + + // Pre-dispatch hooks + const preDispatchResult = deps.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(", ")}`, + "info", + ); + } + if (preDispatchResult.action === "skip") { + ctx.ui.notify( + `Skipping ${unitType} ${unitId} (pre-dispatch hook).`, + "info", + ); + await new Promise((r) => setImmediate(r)); + continue; + } + if (preDispatchResult.action === "replace") { + prompt = preDispatchResult.prompt ?? prompt; + if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; + } else if (preDispatchResult.prompt) { + prompt = preDispatchResult.prompt; + } + + const priorSliceBlocker = deps.getPriorSliceCompletionBlocker( + s.basePath, + deps.getMainBranch(s.basePath), + unitType, + unitId, + ); + if (priorSliceBlocker) { + await deps.stopAuto(ctx, pi, priorSliceBlocker); + debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" }); + break; + } + + const observabilityIssues = await deps.collectObservabilityWarnings( + ctx, + s.basePath, + unitType, + unitId, + ); + + // ── Phase 4: Unit execution ───────────────────────────────────────── + + debugLog("autoLoop", { + phase: "unit-execution", + iteration, + unitType, + unitId, + }); + + // Closeout previous unit + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + + if (s.currentUnitRouting) { + const isRetry = + s.currentUnit.type === unitType && s.currentUnit.id === unitId; + deps.recordOutcome( + s.currentUnit.type, + s.currentUnitRouting.tier as "light" | "standard" | "heavy", + !isRetry, + ); + } + + const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`; + const incomingKey = `${unitType}/${unitId}`; + const isHookUnit = s.currentUnit.type.startsWith("hook/"); + const artifactVerified = + isHookUnit || + deps.verifyExpectedArtifact( + s.currentUnit.type, + s.currentUnit.id, + s.basePath, + ); + if (closeoutKey !== incomingKey && artifactVerified) { + s.completedUnits.push({ + type: s.currentUnit.type, + id: s.currentUnit.id, + startedAt: s.currentUnit.startedAt, + finishedAt: Date.now(), + }); + if (s.completedUnits.length > 200) { + s.completedUnits = s.completedUnits.slice(-200); + } + deps.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}`, + ); + } + } + + s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + deps.captureAvailableSkills(); + deps.writeUnitRuntimeRecord( + s.basePath, + unitType, + unitId, + s.currentUnit.startedAt, + { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: s.currentUnit.startedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }, + ); + + // Status bar + progress widget + ctx.ui.setStatus("gsd-auto", "auto"); + if (mid) + deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); + deps.updateProgressWidget(ctx, unitType, unitId, state); + + deps.ensurePreconditions(unitType, unitId, s.basePath, state); + + // Prompt injection + const MAX_RECOVERY_CHARS = 50_000; + let finalPrompt = prompt; + + 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 (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}`; + s.pendingCrashRecovery = null; + } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { + const diagnostic = deps.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]" + : diagnostic; + finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; + } + } + + const repairBlock = + deps.buildObservabilityRepairBlock(observabilityIssues); + if (repairBlock) { + finalPrompt = `${finalPrompt}${repairBlock}`; + } + + // Prompt char measurement + s.lastPromptCharCount = finalPrompt.length; + s.lastBaselineCharCount = undefined; + if (deps.isDbAvailable()) { + try { + const { inlineGsdRootFile } = await import("./auto-prompts.js"); + const [decisionsContent, requirementsContent, projectContent] = + await Promise.all([ + inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"), + inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"), + inlineGsdRootFile(s.basePath, "project.md", "Project"), + ]); + s.lastBaselineCharCount = + (decisionsContent?.length ?? 0) + + (requirementsContent?.length ?? 0) + + (projectContent?.length ?? 0); + } catch { + // Non-fatal + } + } + + // Cache-optimize prompt section ordering + try { + finalPrompt = deps.reorderForCaching(finalPrompt); + } catch (reorderErr) { + const msg = + reorderErr instanceof Error ? reorderErr.message : String(reorderErr); + process.stderr.write( + `[gsd] prompt reorder failed (non-fatal): ${msg}\n`, + ); + } + + // Select and apply model + const modelResult = await deps.selectAndApplyModel( + ctx, + pi, + unitType, + unitId, + s.basePath, + prefs, + s.verbose, + s.autoModeStartModel, + ); + s.currentUnitRouting = + modelResult.routing as AutoSession["currentUnitRouting"]; + + // Start unit supervision + deps.clearUnitTimeout(); + deps.startUnitSupervision({ + s, + ctx, + pi, + unitType, + unitId, + prefs, + buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId), + buildRecoveryContext: () => ({}), + pauseAuto: deps.pauseAuto, + }); + + // Session + send + await + const sessionFile = deps.getSessionFile(ctx); + deps.updateSessionLock( + deps.lockBase(), + unitType, + unitId, + s.completedUnits.length, + sessionFile, + ); + deps.writeLock( + deps.lockBase(), + unitType, + unitId, + s.completedUnits.length, + sessionFile, + ); + + debugLog("autoLoop", { + phase: "runUnit-start", + iteration, + unitType, + unitId, + }); + const unitResult = await runUnit( + ctx, + pi, + s, + unitType, + unitId, + finalPrompt, + prefs, + ); + debugLog("autoLoop", { + phase: "runUnit-end", + iteration, + unitType, + unitId, + status: unitResult.status, + }); + + if (unitResult.status === "cancelled") { + ctx.ui.notify( + `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, + "warning", + ); + await deps.stopAuto(ctx, pi, "Session creation failed"); + debugLog("autoLoop", { phase: "exit", reason: "session-failed" }); + break; + } + + // ── Phase 5: Finalize ─────────────────────────────────────────────── + + debugLog("autoLoop", { phase: "finalize", iteration }); + + // Clear unit timeout (unit completed) + deps.clearUnitTimeout(); + + // Post-unit context for pre/post verification + const postUnitCtx: PostUnitContext = { + s, + ctx, + pi, + buildSnapshotOpts: deps.buildSnapshotOpts, + lockBase: deps.lockBase, + stopAuto: deps.stopAuto, + pauseAuto: deps.pauseAuto, + updateProgressWidget: deps.updateProgressWidget, + }; + + // Pre-verification processing (commit, doctor, state rebuild, etc.) + const preResult = await deps.postUnitPreVerification(postUnitCtx); + if (preResult === "dispatched") { + debugLog("autoLoop", { + phase: "exit", + reason: "pre-verification-dispatched", + }); + break; + } + + if (pauseAfterUatDispatch) { + ctx.ui.notify( + "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", + "info", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "uat-pause" }); + break; + } + + // Verification gate — the loop handles retries via s.pendingVerificationRetry + const verificationResult = await deps.runPostUnitVerification( + { s, ctx, pi }, + deps.pauseAuto, + ); + + if (verificationResult === "pause") { + debugLog("autoLoop", { phase: "exit", reason: "verification-pause" }); + break; + } + + if (verificationResult === "retry") { + // s.pendingVerificationRetry was set by runPostUnitVerification. + // Continue the loop — next iteration will inject the retry context into the prompt. + debugLog("autoLoop", { phase: "verification-retry", iteration }); + continue; + } + + // Post-verification processing (DB dual-write, hooks, triage, quick-tasks) + const postResult = await deps.postUnitPostVerification(postUnitCtx); + + if (postResult === "stopped") { + debugLog("autoLoop", { + phase: "exit", + reason: "post-verification-stopped", + }); + break; + } + + if (postResult === "step-wizard") { + // Step mode — exit the loop (caller handles wizard) + debugLog("autoLoop", { phase: "exit", reason: "step-wizard" }); + break; + } + + // ── Sidecar drain: dispatch enqueued hooks/triage/quick-tasks ── + let sidecarBroke = false; + while (s.sidecarQueue.length > 0 && s.active) { + const item = s.sidecarQueue.shift()!; + debugLog("autoLoop", { + phase: "sidecar-dequeue", + kind: item.kind, + unitType: item.unitType, + unitId: item.unitId, + }); + + // Set up as current unit + const sidecarStartedAt = Date.now(); + s.currentUnit = { + type: item.unitType, + id: item.unitId, + startedAt: sidecarStartedAt, + }; + deps.writeUnitRuntimeRecord( + s.basePath, + item.unitType, + item.unitId, + sidecarStartedAt, + { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: sidecarStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }, + ); + + // Model selection (handles hook model override) + await deps.selectAndApplyModel( + ctx, + pi, + item.unitType, + item.unitId, + s.basePath, + prefs, + s.verbose, + s.autoModeStartModel, + ); + + // Supervision + deps.clearUnitTimeout(); + deps.startUnitSupervision({ + s, + ctx, + pi, + unitType: item.unitType, + unitId: item.unitId, + prefs, + buildSnapshotOpts: () => + deps.buildSnapshotOpts(item.unitType, item.unitId), + buildRecoveryContext: () => ({}), + pauseAuto: deps.pauseAuto, + }); + + // Write lock + const sidecarSessionFile = deps.getSessionFile(ctx); + deps.writeLock( + deps.lockBase(), + item.unitType, + item.unitId, + s.completedUnits.length, + sidecarSessionFile, + ); + + // Execute via standard runUnit + const sidecarResult = await runUnit( + ctx, + pi, + s, + item.unitType, + item.unitId, + item.prompt, + prefs, + ); + deps.clearUnitTimeout(); + + if (sidecarResult.status === "cancelled") { + ctx.ui.notify( + `Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`, + "warning", + ); + await deps.stopAuto(ctx, pi, "Sidecar session creation failed"); + sidecarBroke = true; + break; + } + + // Run pre-verification for the sidecar unit + const sidecarPreResult = + await deps.postUnitPreVerification(postUnitCtx); + if (sidecarPreResult === "dispatched") { + // Pre-verification caused stop/pause + debugLog("autoLoop", { + phase: "exit", + reason: "sidecar-pre-verification-stop", + }); + sidecarBroke = true; + break; + } + + // Verification gate for non-hook sidecar units (triage, quick-tasks) + // Hook units are lightweight and don't need verification. + if (item.kind !== "hook") { + const sidecarVerification = await deps.runPostUnitVerification( + { s, ctx, pi }, + deps.pauseAuto, + ); + if (sidecarVerification === "pause") { + debugLog("autoLoop", { + phase: "exit", + reason: "sidecar-verification-pause", + }); + sidecarBroke = true; + break; + } + // "retry" for sidecars — skip retry, just continue (sidecar retries are not worth the complexity) + } + + // Post-verification (may enqueue more sidecar items) + const sidecarPostResult = + await deps.postUnitPostVerification(postUnitCtx); + if (sidecarPostResult === "stopped") { + debugLog("autoLoop", { phase: "exit", reason: "sidecar-stopped" }); + sidecarBroke = true; + break; + } + if (sidecarPostResult === "step-wizard") { + debugLog("autoLoop", { + phase: "exit", + reason: "sidecar-step-wizard", + }); + sidecarBroke = true; + break; + } + // "continue" — loop checks sidecarQueue again + } + + if (sidecarBroke) break; + + consecutiveErrors = 0; // Iteration completed successfully + debugLog("autoLoop", { phase: "iteration-complete", iteration }); + } catch (loopErr) { + // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── + consecutiveErrors++; + const msg = loopErr instanceof Error ? loopErr.message : String(loopErr); + debugLog("autoLoop", { + phase: "iteration-error", + iteration, + consecutiveErrors, + error: msg, + }); + + if (consecutiveErrors >= 3) { + // 3+ consecutive: hard stop — something is fundamentally broken + ctx.ui.notify( + `Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures. Last: ${msg}`, + "error", + ); + await deps.stopAuto( + ctx, + pi, + `${consecutiveErrors} consecutive iteration failures`, + ); + break; + } else if (consecutiveErrors === 2) { + // 2nd consecutive: try invalidating caches + re-deriving state + ctx.ui.notify( + `Iteration error (attempt ${consecutiveErrors}): ${msg}. Invalidating caches and retrying.`, + "warning", + ); + deps.invalidateAllCaches(); + } else { + // 1st error: log and retry — transient failures happen + ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning"); + } + } + } + + _activeSession = null; + debugLog("autoLoop", { phase: "exit", totalIterations: iteration }); +} diff --git a/src/resources/extensions/gsd/auto-observability.ts b/src/resources/extensions/gsd/auto-observability.ts index 0715a9ac4..ddcc0bf3d 100644 --- a/src/resources/extensions/gsd/auto-observability.ts +++ b/src/resources/extensions/gsd/auto-observability.ts @@ -12,7 +12,6 @@ import { formatValidationIssues, } from "./observability-validator.js"; import type { ValidationIssue } from "./observability-validator.js"; -import { parseUnitId } from "./unit-id.js"; export async function collectObservabilityWarnings( ctx: ExtensionContext, @@ -23,7 +22,10 @@ export async function collectObservabilityWarnings( // Hook units have custom artifacts — skip standard observability checks if (unitType.startsWith("hook/")) return []; - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; if (!mid || !sid) return []; diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index e4a2f4820..925f94591 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -11,7 +11,7 @@ * Extracted from handleAgentEnd() in auto.ts. */ -import type { ExtensionContext, ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent"; +import type { ExtensionContext, ExtensionAPI } from "@gsd/pi-coding-agent"; import { deriveState } from "./state.js"; import { loadFile, parseSummary, resolveAllOverrides } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; @@ -19,7 +19,6 @@ import { resolveSliceFile, resolveTaskFile, resolveMilestoneFile, - gsdRoot, } from "./paths.js"; import { invalidateAllCaches } from "./cache.js"; import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; @@ -29,30 +28,23 @@ import { } from "./worktree.js"; import { verifyExpectedArtifact, - persistCompletedKey, - removePersistedKey, } from "./auto-recovery.js"; import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.js"; -import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences } from "./preferences.js"; import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js"; -import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js"; import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js"; +import { syncStateToProjectRoot } from "./auto-worktree-sync.js"; import { resetRewriteCircuitBreaker } from "./auto-dispatch.js"; import { isDbAvailable } from "./gsd-db.js"; import { consumeSignal } from "./session-status-io.js"; import { checkPostUnitHooks, - getActiveHook, - resetHookState, isRetryPending, consumeRetryTrigger, persistHookState, } from "./post-unit-hooks.js"; -import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js"; -import { writeLock } from "./crash-recovery.js"; +import { hasPendingCaptures, loadPendingCaptures } from "./captures.js"; import { debugLog } from "./debug-logger.js"; import type { AutoSession } from "./auto/session.js"; -import type { WidgetStateAccessors, AutoDashboardData } from "./auto-dashboard.js"; import { updateProgressWidget as _updateProgressWidget, updateSliceProgressCache, @@ -60,32 +52,9 @@ import { hideFooter, } from "./auto-dashboard.js"; import { join } from "node:path"; -import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js"; -import { parseUnitId } from "./unit-id.js"; -/** - * Initialize a unit dispatch: stamp the current time, set `s.currentUnit`, - * and persist the initial runtime record. Returns `startedAt` for callers - * that need the timestamp. - */ -function dispatchUnit( - s: AutoSession, - basePath: string, - unitType: string, - unitId: string, -): number { - const startedAt = Date.now(); - s.currentUnit = { type: unitType, id: unitId, startedAt }; - writeUnitRuntimeRecord(basePath, unitType, unitId, startedAt, { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: startedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }); - return startedAt; -} +/** Throttle STATE.md rebuilds — at most once per 30 seconds */ +const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; export interface PostUnitContext { s: AutoSession; @@ -135,7 +104,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d let taskContext: TaskCommitContext | undefined; if (s.currentUnit.type === "execute-task") { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); + const parts = s.currentUnit.id.split("/"); + const [mid, sid, tid] = parts; if (mid && sid && tid) { const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); if (summaryPath) { @@ -167,8 +137,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d // Doctor: fix mechanical bookkeeping try { - const { milestone, slice } = parseUnitId(s.currentUnit.id); - const doctorScope = slice ? `${milestone}/${slice}` : milestone; + const scopeParts = s.currentUnit.id.split("/").slice(0, 2); + const doctorScope = scopeParts.join("/"); const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]); const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const; const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); @@ -176,17 +146,13 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info"); } - // Proactive health tracking — exclude completion-transition codes at task level - // since they are expected after the last task and resolved by complete-slice - const issuesForHealth = effectiveFixLevel === "task" - ? report.issues.filter(i => !COMPLETION_TRANSITION_CODES.has(i.code)) - : report.issues; - const summary = summarizeDoctorIssues(issuesForHealth); + // Proactive health tracking + const summary = summarizeDoctorIssues(report.issues); recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length); // Check if we should escalate to LLM-assisted heal if (summary.errors > 0) { - const unresolvedErrors = issuesForHealth + const unresolvedErrors = report.issues .filter(i => i.severity === "error" && !i.fixable) .map(i => ({ code: i.code, message: i.message, unitId: i.unitId })); const escalation = checkHealEscalation(summary.errors, unresolvedErrors); @@ -223,17 +189,23 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d } } - // Prune dead bg-shell processes and kill non-persistent live ones. - // Without killing live processes between units, dev servers spawned during - // one task keep ports bound, causing conflicts in subsequent tasks (#1209). + // Prune dead bg-shell processes try { - const { pruneDeadProcesses, killSessionProcesses } = await import("../bg-shell/process-manager.js"); + const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js"); pruneDeadProcesses(); - killSessionProcesses(); } catch { // Non-fatal } + // Sync worktree state back to project root + if (s.originalBasePath && s.originalBasePath !== s.basePath) { + try { + syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId); + } catch { + // Non-fatal + } + } + // Rewrite-docs completion if (s.currentUnit.type === "rewrite-docs") { try { @@ -286,17 +258,12 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d } } - // Artifact verification and completion persistence + // Artifact verification let triggerArtifactVerified = false; if (!s.currentUnit.type.startsWith("hook/")) { try { triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); if (triggerArtifactVerified) { - const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; - if (!s.completedKeySet.has(completionKey)) { - persistCompletedKey(s.basePath, completionKey); - s.completedKeySet.add(completionKey); - } invalidateAllCaches(); } } catch { @@ -324,13 +291,15 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d * Post-verification processing: DB dual-write, post-unit hooks, triage * capture dispatch, quick-task dispatch. * + * Sidecar work (hooks, triage, quick-tasks) is enqueued on `s.sidecarQueue` + * for the main loop to drain via `runUnit()`. + * * Returns: - * - "dispatched" — a hook/triage/quick-task was dispatched (sendMessage sent) - * - "continue" — proceed to normal dispatchNextUnit + * - "continue" — proceed to sidecar drain / normal dispatch * - "step-wizard" — step mode, show wizard instead * - "stopped" — stopAuto was called */ -export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"dispatched" | "continue" | "step-wizard" | "stopped"> { +export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"continue" | "step-wizard" | "stopped"> { const { s, ctx, pi, buildSnapshotOpts, lockBase, stopAuto, pauseAuto, updateProgressWidget } = pctx; // ── DB dual-write ── @@ -343,45 +312,6 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" } } - // ── Mechanical completion (ADR-003) ── - // After task execution, attempt mechanical slice and milestone completion - // instead of dispatching LLM sessions for complete-slice / validate-milestone. - if (s.currentUnit?.type === "execute-task" && !s.stepMode) { - try { - const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id); - if (mid && sid) { - const state = await deriveState(s.basePath); - if (state.phase === "summarizing" && state.activeSlice?.id === sid) { - const { mechanicalSliceCompletion } = await import("./mechanical-completion.js"); - const ok = await mechanicalSliceCompletion(s.basePath, mid, sid); - if (ok) { - invalidateAllCaches(); - autoCommitCurrentBranch(s.basePath, "mechanical-completion", `${mid}/${sid}`); - ctx.ui.notify(`Mechanical completion: ${sid} summary + roadmap updated.`, "info"); - - // Re-derive state — check if milestone is now ready for validation - invalidateAllCaches(); - const postSliceState = await deriveState(s.basePath); - if (postSliceState.phase === "validating-milestone" || postSliceState.phase === "completing-milestone") { - const { aggregateMilestoneVerification, generateMilestoneSummary } = await import("./mechanical-completion.js"); - const validation = await aggregateMilestoneVerification(s.basePath, mid); - if (validation.verdict !== "failed") { - await generateMilestoneSummary(s.basePath, mid); - invalidateAllCaches(); - autoCommitCurrentBranch(s.basePath, "mechanical-milestone-completion", mid); - ctx.ui.notify(`Mechanical completion: ${mid} validation + summary written.`, "info"); - } - } - } - // If !ok, summarizing phase persists → dispatch rule fires as LLM fallback - } - } - } catch (err) { - process.stderr.write(`gsd-mechanical: completion failed: ${(err as Error).message}\n`); - // Non-fatal — fall through to normal dispatch - } - } - // ── Post-unit hooks ── if (s.currentUnit && !s.stepMode) { const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); @@ -389,79 +319,36 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" if (s.currentUnit) { await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } - dispatchUnit(s, s.basePath, hookUnit.unitType, hookUnit.unitId); - - const state = await deriveState(s.basePath); - updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state); - const hookState = getActiveHook(); - ctx.ui.notify( - `Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`, - "info", - ); - - // Switch model if the hook specifies one - if (hookUnit.model) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = availableModels.find(m => - m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model, - ); - if (match) { - try { - await pi.setModel(match); - } catch { /* non-fatal */ } - } - } - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - resetHookState(); - await stopAuto(ctx, pi, "Hook session cancelled"); - return "stopped"; - } - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile); persistHookState(s.basePath); - // Start supervision timers for hook units - const supervisor = resolveAutoSupervisorConfig(); - const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - 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(), - }); - } - ctx.ui.notify( - `Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, - "warning", - ); - resetHookState(); - await pauseAuto(ctx, pi); - }, hookHardTimeoutMs); + s.sidecarQueue.push({ + kind: "hook", + unitType: hookUnit.unitType, + unitId: hookUnit.unitId, + prompt: hookUnit.prompt, + model: hookUnit.model, + }); - if (!s.active) return "stopped"; - pi.sendMessage( - { customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose }, - { triggerTurn: true }, - ); - return "dispatched"; + debugLog("postUnitPostVerification", { + phase: "sidecar-enqueue", + kind: "hook", + unitType: hookUnit.unitType, + unitId: hookUnit.unitId, + hookName: hookUnit.hookName, + }); + + return "continue"; } // Check if a hook requested a retry of the trigger unit if (isRetryPending()) { const trigger = consumeRetryTrigger(); if (trigger) { - const triggerKey = `${trigger.unitType}/${trigger.unitId}`; - s.completedKeySet.delete(triggerKey); - removePersistedKey(s.basePath, triggerKey); ctx.ui.notify( `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`, "info", ); - // Fall through to normal dispatch + // Fall through to normal dispatch — deriveState will re-derive the unit } } } @@ -500,46 +387,31 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" roadmapContext: roadmapContext || "(no active roadmap)", }); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); + } + + const triageUnitId = `${mid}/${sid}/triage`; + s.sidecarQueue.push({ + kind: "triage", + unitType: "triage-captures", + unitId: triageUnitId, + prompt, + }); + + debugLog("postUnitPostVerification", { + phase: "sidecar-enqueue", + kind: "triage", + unitId: triageUnitId, + pendingCount: pending.length, + }); + ctx.ui.notify( `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, "info", ); - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - - const triageUnitType = "triage-captures"; - const triageUnitId = `${mid}/${sid}/triage`; - dispatchUnit(s, s.basePath, triageUnitType, triageUnitId); - updateProgressWidget(ctx, triageUnitType, triageUnitId, state); - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return "stopped"; - } - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile); - - const supervisor = resolveAutoSupervisorConfig(); - const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - ctx.ui.notify( - `Triage unit exceeded timeout. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - }, triageTimeoutMs); - - if (!s.active) return "stopped"; - pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: s.verbose }, - { triggerTurn: true }, - ); - return "dispatched"; + return "continue"; } } } @@ -561,49 +433,34 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" const { markCaptureExecuted } = await import("./captures.js"); const prompt = buildQuickTaskPrompt(capture); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); + } + + markCaptureExecuted(s.basePath, capture.id); + + const qtUnitId = `${s.currentMilestoneId}/${capture.id}`; + s.sidecarQueue.push({ + kind: "quick-task", + unitType: "quick-task", + unitId: qtUnitId, + prompt, + captureId: capture.id, + }); + + debugLog("postUnitPostVerification", { + phase: "sidecar-enqueue", + kind: "quick-task", + unitId: qtUnitId, + captureId: capture.id, + }); + ctx.ui.notify( `Executing quick-task: ${capture.id} — "${capture.text}"`, "info", ); - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - - const qtUnitType = "quick-task"; - const qtUnitId = `${s.currentMilestoneId}/${capture.id}`; - dispatchUnit(s, s.basePath, qtUnitType, qtUnitId); - const state = await deriveState(s.basePath); - updateProgressWidget(ctx, qtUnitType, qtUnitId, state); - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return "stopped"; - } - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile); - - markCaptureExecuted(s.basePath, capture.id); - - const supervisor = resolveAutoSupervisorConfig(); - const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - ctx.ui.notify( - `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - }, qtTimeoutMs); - - if (!s.active) return "stopped"; - pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: s.verbose }, - { triggerTurn: true }, - ); - return "dispatched"; + return "continue"; } catch { // Non-fatal — proceed to normal dispatch } diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index c6871dff9..bf4221466 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -55,6 +55,55 @@ function formatExecutorConstraints(): string { ].join("\n"); } +function buildSourceFilePaths( + base: string, + mid: string, + sid?: string, +): string { + const paths: string[] = []; + + const projectPath = resolveGsdRootFile(base, "PROJECT"); + if (existsSync(projectPath)) { + paths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``); + } + + const requirementsPath = resolveGsdRootFile(base, "REQUIREMENTS"); + if (existsSync(requirementsPath)) { + paths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``); + } + + const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); + if (existsSync(decisionsPath)) { + paths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``); + } + + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + if (contextPath) { + paths.push(`- **Milestone Context**: \`${relMilestoneFile(base, mid, "CONTEXT")}\``); + } + + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (roadmapPath) { + paths.push(`- **Roadmap**: \`${relMilestoneFile(base, mid, "ROADMAP")}\``); + } + + if (sid) { + const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); + if (researchPath) { + paths.push(`- **Slice Research**: \`${relSliceFile(base, mid, sid, "RESEARCH")}\``); + } + } else { + const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); + if (researchPath) { + paths.push(`- **Milestone Research**: \`${relMilestoneFile(base, mid, "RESEARCH")}\``); + } + } + + return paths.length > 0 + ? paths.join("\n") + : "- Use `rg --files` and targeted reads to identify the relevant source files before planning."; +} + // ─── Inline Helpers ─────────────────────────────────────────────────────── /** @@ -188,38 +237,6 @@ export async function inlineGsdRootFile( // ─── DB-Aware Inline Helpers ────────────────────────────────────────────── -/** - * Shared DB-fallback pattern: attempt a DB query via the context-store, format - * the result, and fall back to the filesystem file when the DB is unavailable - * or the query yields no results. - * - * @param base Project root for filesystem fallback - * @param label Section heading (e.g. "Decisions") - * @param filename Filesystem fallback file (e.g. "decisions.md") - * @param queryDb Async callback receiving the dynamically-imported - * context-store module. Returns formatted markdown or null. - */ -async function inlineFromDbOrFile( - base: string, - label: string, - filename: string, - queryDb: (cs: typeof import("./context-store.js")) => string | null, -): Promise { - try { - const { isDbAvailable } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const contextStore = await import("./context-store.js"); - const content = queryDb(contextStore); - if (content) { - return `### ${label}\nSource: \`.gsd/${filename.toUpperCase().replace(/\.MD$/i, "")}.md\`\n\n${content}`; - } - } - } catch { - // DB not available — fall through to filesystem - } - return inlineGsdRootFile(base, filename, label); -} - /** * Inline decisions with optional milestone scoping from the DB. * Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty. @@ -228,13 +245,23 @@ export async function inlineDecisionsFromDb( base: string, milestoneId?: string, scope?: string, level?: InlineLevel, ): Promise { const inlineLevel = level ?? resolveInlineLevel(); - return inlineFromDbOrFile(base, "Decisions", "decisions.md", (cs) => { - const decisions = cs.queryDecisions({ milestoneId, scope }); - if (decisions.length === 0) return null; - return inlineLevel !== "full" - ? formatDecisionsCompact(decisions) - : cs.formatDecisionsForPrompt(decisions); - }); + try { + const { isDbAvailable } = await import("./gsd-db.js"); + if (isDbAvailable()) { + const { queryDecisions, formatDecisionsForPrompt } = await import("./context-store.js"); + const decisions = queryDecisions({ milestoneId, scope }); + if (decisions.length > 0) { + // Use compact format for non-full levels to save ~35% tokens + const formatted = inlineLevel !== "full" + ? formatDecisionsCompact(decisions) + : formatDecisionsForPrompt(decisions); + return `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`; + } + } + } catch { + // DB not available — fall through to filesystem + } + return inlineGsdRootFile(base, "decisions.md", "Decisions"); } /** @@ -245,13 +272,23 @@ export async function inlineRequirementsFromDb( base: string, sliceId?: string, level?: InlineLevel, ): Promise { const inlineLevel = level ?? resolveInlineLevel(); - return inlineFromDbOrFile(base, "Requirements", "requirements.md", (cs) => { - const requirements = cs.queryRequirements({ sliceId }); - if (requirements.length === 0) return null; - return inlineLevel !== "full" - ? formatRequirementsCompact(requirements) - : cs.formatRequirementsForPrompt(requirements); - }); + try { + const { isDbAvailable } = await import("./gsd-db.js"); + if (isDbAvailable()) { + const { queryRequirements, formatRequirementsForPrompt } = await import("./context-store.js"); + const requirements = queryRequirements({ sliceId }); + if (requirements.length > 0) { + // Use compact format for non-full levels to save ~40% tokens + const formatted = inlineLevel !== "full" + ? formatRequirementsCompact(requirements) + : formatRequirementsForPrompt(requirements); + return `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`; + } + } + } catch { + // DB not available — fall through to filesystem + } + return inlineGsdRootFile(base, "requirements.md", "Requirements"); } /** @@ -261,9 +298,19 @@ export async function inlineRequirementsFromDb( export async function inlineProjectFromDb( base: string, ): Promise { - return inlineFromDbOrFile(base, "Project", "project.md", (cs) => { - return cs.queryProject(); - }); + try { + const { isDbAvailable } = await import("./gsd-db.js"); + if (isDbAvailable()) { + const { queryProject } = await import("./context-store.js"); + const content = queryProject(); + if (content) { + return `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`; + } + } + } catch { + // DB not available — fall through to filesystem + } + return inlineGsdRootFile(base, "project.md", "Project"); } // ─── Skill Discovery ────────────────────────────────────────────────────── @@ -326,27 +373,6 @@ function oneLine(text: string): string { return text.replace(/\s+/g, " ").trim(); } -/** Build the standard inlined-context section used by all prompt builders. */ -function buildInlinedContextSection(inlined: string[]): string { - return `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; -} - -/** Build the formatted list of available GSD source files for planners to read on demand. */ -function buildSourceFileList(base: string, opts?: { includeProject?: boolean }): string { - const paths: string[] = []; - if (opts?.includeProject && existsSync(resolveGsdRootFile(base, "PROJECT"))) - paths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``); - if (existsSync(resolveGsdRootFile(base, "REQUIREMENTS"))) - paths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``); - if (existsSync(resolveGsdRootFile(base, "DECISIONS"))) - paths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``); - if (paths.length === 0) { - const types = opts?.includeProject ? "project/requirements/decisions" : "requirements/decisions"; - return `_No ${types} files found._`; - } - return paths.join("\n"); -} - // ─── Section Builders ────────────────────────────────────────────────────── export function buildResumeSection( @@ -492,17 +518,6 @@ export async function checkNeedsReassessment( if (hasAssessment) return null; - // Fallback: check the expected path directly via existsSync. - // resolveSliceFile relies on directory listing (readdirSync) which may not - // reflect a freshly written file in git worktree directories on some - // filesystems (observed on macOS APFS). A direct existsSync on the - // constructed path bypasses directory listing entirely. (#1112) - const sliceDir = resolveSlicePath(base, mid, lastCompleted.id); - if (sliceDir) { - const directPath = join(sliceDir, `${lastCompleted.id}-ASSESSMENT.md`); - if (existsSync(directPath)) return null; - } - // Also need a summary to reassess against const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY"); const hasSummary = !!(summaryFile && await loadFile(summaryFile)); @@ -553,21 +568,15 @@ export async function checkNeedsRunUat( const uatContent = await loadFile(uatFile); if (!uatContent) return null; - // If a UAT result already exists, the UAT unit has already run and must not - // be re-dispatched. PASS means progression can continue; any non-PASS verdict - // must be handled by the dispatch table's verdict gate, which stops progression - // with a human-action message instead of replaying the same run-uat unit. + // If UAT result already exists, skip (idempotent) const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); if (uatResultFile) { - const resultContent = await loadFile(uatResultFile); - if (resultContent) return null; + const hasResult = !!(await loadFile(uatResultFile)); + if (hasResult) return null; } - // Classify UAT type; skip non-artifact-driven types — auto-mode can only - // execute mechanical checks. Non-artifact UATs are tracked in the dashboard - // but don't block auto-mode progression. + // Classify UAT type; unknown type → treat as human-experience (human review) const uatType = extractUatType(uatContent) ?? "human-experience"; - if (uatType !== "artifact-driven") return null; return { sliceId: sid, uatType }; } @@ -590,7 +599,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string if (knowledgeInlineRM) inlined.push(knowledgeInlineRM); inlined.push(inlineTemplate("research", "Research")); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); return loadPrompt("research-milestone", { @@ -618,8 +627,14 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba const { inlinePriorMilestoneSummary } = await import("./files.js"); const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base); if (priorSummaryInline) inlined.push(priorSummaryInline); - const sourceFilePaths = buildSourceFileList(base, { includeProject: true }); - + if (inlineLevel !== "minimal") { + const projectInline = await inlineProjectFromDb(base); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineRequirementsFromDb(base, undefined, inlineLevel); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel); + if (decisionsInline) inlined.push(decisionsInline); + } const knowledgeInlinePM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); if (knowledgeInlinePM) inlined.push(knowledgeInlinePM); inlined.push(inlineTemplate("roadmap", "Roadmap")); @@ -634,22 +649,22 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba inlined.push(inlineTemplate("task-plan", "Task Plan")); } - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const outputRelPath = relMilestoneFile(base, mid, "ROADMAP"); + const researchOutputPath = join(base, relMilestoneFile(base, mid, "RESEARCH")); const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS")); - const researchOutputRelPath = relMilestoneFile(base, mid, "RESEARCH"); return loadPrompt("plan-milestone", { workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, milestonePath: relMilestonePath(base, mid), contextPath: contextRel, researchPath: researchRel, + researchOutputPath, outputPath: join(base, outputRelPath), secretsOutputPath, inlinedContext, - sourceFilePaths, - researchOutputPath: join(base, researchOutputRelPath), + sourceFilePaths: buildSourceFilePaths(base, mid), ...buildSkillDiscoveryVars(), }); } @@ -683,7 +698,7 @@ export async function buildResearchSlicePrompt( const overridesInline = formatOverridesSection(activeOverrides); if (overridesInline) inlined.unshift(overridesInline); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH"); return loadPrompt("research-slice", { @@ -713,8 +728,12 @@ export async function buildPlanSlicePrompt( inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research"); if (researchInline) inlined.push(researchInline); - const sliceSourceFilePaths = buildSourceFileList(base); - + if (inlineLevel !== "minimal") { + const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel); + if (decisionsInline) inlined.push(decisionsInline); + const requirementsInline = await inlineRequirementsFromDb(base, sid, inlineLevel); + if (requirementsInline) inlined.push(requirementsInline); + } const knowledgeInlinePS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); if (knowledgeInlinePS) inlined.push(knowledgeInlinePS); inlined.push(inlineTemplate("plan", "Slice Plan")); @@ -727,13 +746,17 @@ export async function buildPlanSlicePrompt( const planOverridesInline = formatOverridesSection(planActiveOverrides); if (planOverridesInline) inlined.unshift(planOverridesInline); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; // Build executor context constraints from the budget engine const executorContextConstraints = formatExecutorConstraints(); const outputRelPath = relSliceFile(base, mid, sid, "PLAN"); - const commitInstruction = "Do not commit planning artifacts — .gsd/ is managed externally."; + const prefs = loadEffectiveGSDPreferences(); + const commitDocsEnabled = prefs?.preferences?.git?.commit_docs !== false; + const commitInstruction = commitDocsEnabled + ? `Commit the plan files only: \`git add ${relSlicePath(base, mid, sid)}/ .gsd/DECISIONS.md .gitignore && git commit -m "docs(${sid}): add slice plan"\`. Do not stage .gsd/STATE.md or other runtime files — the system manages those.` + : "Do not commit — planning docs are not tracked in git for this project."; return loadPrompt("plan-slice", { workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, @@ -743,9 +766,9 @@ export async function buildPlanSlicePrompt( outputPath: join(base, outputRelPath), inlinedContext, dependencySummaries: depContent, + sourceFilePaths: buildSourceFilePaths(base, mid, sid), executorContextConstraints, commitInstruction, - sourceFilePaths: sliceSourceFilePaths, }); } @@ -902,7 +925,7 @@ export async function buildCompleteSlicePrompt( const completeOverridesInline = formatOverridesSection(completeActiveOverrides); if (completeOverridesInline) inlined.unshift(completeOverridesInline); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const sliceRel = relSlicePath(base, mid, sid); const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`); @@ -961,7 +984,7 @@ export async function buildCompleteMilestonePrompt( if (contextInline) inlined.push(contextInline); inlined.push(inlineTemplate("milestone-summary", "Milestone Summary")); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`); @@ -1032,7 +1055,7 @@ export async function buildValidateMilestonePrompt( const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); if (contextInline) inlined.push(contextInline); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const validationOutputPath = join(base, `${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`); const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`; @@ -1086,7 +1109,7 @@ export async function buildReplanSlicePrompt( const replanOverridesInline = formatOverridesSection(replanActiveOverrides); if (replanOverridesInline) inlined.unshift(replanOverridesInline); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`); @@ -1119,7 +1142,7 @@ export async function buildReplanSlicePrompt( } export async function buildRunUatPrompt( - mid: string, sliceId: string, uatPath: string, base: string, + mid: string, sliceId: string, uatPath: string, uatContent: string, base: string, ): Promise { const inlined: string[] = []; inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`)); @@ -1134,9 +1157,10 @@ export async function buildRunUatPrompt( const projectInline = await inlineProjectFromDb(base); if (projectInline) inlined.push(projectInline); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT")); + const uatType = extractUatType(uatContent) ?? "human-experience"; return loadPrompt("run-uat", { workingDirectory: base, @@ -1144,6 +1168,7 @@ export async function buildRunUatPrompt( sliceId, uatPath, uatResultPath, + uatType, inlinedContext, }); } @@ -1171,7 +1196,7 @@ export async function buildReassessRoadmapPrompt( const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); if (knowledgeInlineRA) inlined.push(knowledgeInlineRA); - const inlinedContext = buildInlinedContextSection(inlined); + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT")); @@ -1189,7 +1214,11 @@ export async function buildReassessRoadmapPrompt( // Non-fatal — captures module may not be available } - const reassessCommitInstruction = "Do not commit planning artifacts — .gsd/ is managed externally."; + const reassessPrefs = loadEffectiveGSDPreferences(); + const reassessCommitDocsEnabled = reassessPrefs?.preferences?.git?.commit_docs !== false; + const reassessCommitInstruction = reassessCommitDocsEnabled + ? `Commit: \`docs(${mid}): reassess roadmap after ${completedSliceId}\`. Stage only the .gsd/milestones/ files you changed — do not stage .gsd/STATE.md or other runtime files.` + : "Do not commit — planning docs are not tracked in git for this project."; return loadPrompt("reassess-roadmap", { workingDirectory: base, diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 82fc1067a..d6318dc7d 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -1,6 +1,6 @@ /** * Auto-mode Recovery — artifact resolution, verification, blocker placeholders, - * skip artifacts, completed-unit persistence, merge state reconciliation, + * skip artifacts, merge state reconciliation, * self-heal runtime records, and loop remediation steps. * * Pure functions that receive all needed state as parameters — no module-level @@ -8,10 +8,9 @@ */ import type { ExtensionContext } from "@gsd/pi-coding-agent"; -import { - clearUnitRuntimeRecord, -} from "./unit-runtime.js"; +import { clearUnitRuntimeRecord } from "./unit-runtime.js"; import { clearParseCache, parseRoadmap, parsePlan } from "./files.js"; +import { isValidationTerminal } from "./state.js"; import { nativeConflictFiles, nativeCommit, @@ -35,22 +34,29 @@ import { resolveMilestoneFile, clearPathCache, resolveGsdRootFile, - gsdRoot, } from "./paths.js"; -import { isValidationTerminal } from "./state.js"; -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; -import { atomicWriteSync } from "./atomic-write.js"; -import { loadJsonFileOrNull } from "./json-persistence.js"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, +} from "node:fs"; import { dirname, join } from "node:path"; -import { parseUnitId } from "./unit-id.js"; // ─── Artifact Resolution & Verification ─────────────────────────────────────── /** * Resolve the expected artifact for a unit to an absolute path. */ -export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); +export function resolveExpectedArtifactPath( + unitType: string, + unitId: string, + base: string, +): string | null { + const parts = unitId.split("/"); + const mid = parts[0]!; + const sid = parts[1]; switch (unitType) { case "research-milestone": { const dir = resolveMilestonePath(base, mid); @@ -77,8 +83,11 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null; } case "execute-task": { + const tid = parts[2]; const dir = resolveSlicePath(base, mid, sid!); - return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null; + return dir && tid + ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) + : null; } case "complete-slice": { const dir = resolveSlicePath(base, mid, sid!); @@ -112,7 +121,11 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba * the summary allowed the unit to be marked complete when the LLM * skipped writing the UAT file (see #176). */ -export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean { +export function verifyExpectedArtifact( + unitType: string, + unitId: string, + base: string, +): boolean { // Hook units have no standard artifact — always pass. Their lifecycle // is managed by the hook engine, not the artifact verification system. if (unitType.startsWith("hook/")) return true; @@ -138,19 +151,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s if (!absPath) return false; if (!existsSync(absPath)) return false; - // validate-milestone must have a VALIDATION file with a terminal verdict - // (pass, needs-attention, or needs-remediation). Without this check, a - // VALIDATION file with missing/malformed frontmatter or an unrecognized - // verdict is treated as "complete" by the artifact check but deriveState - // still returns phase:"validating-milestone" (because isValidationTerminal - // returns false), creating an infinite skip loop that hits the lifetime cap. if (unitType === "validate-milestone") { - try { - const validationContent = readFileSync(absPath, "utf-8"); - if (!isValidationTerminal(validationContent)) return false; - } catch { - return false; - } + const validationContent = readFileSync(absPath, "utf-8"); + if (!isValidationTerminal(validationContent)) return false; } // plan-slice must produce a plan with actual task entries, not just a scaffold. @@ -165,7 +168,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s // execute-task must also have its checkbox marked [x] in the slice plan if (unitType === "execute-task") { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; if (mid && sid && tid) { const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); if (planAbs && existsSync(planAbs)) { @@ -182,7 +188,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task // to dispatch with a missing task plan (see issue #739). if (unitType === "plan-slice") { - const { milestone: mid, slice: sid } = parseUnitId(unitId); + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; if (mid && sid) { try { const planContent = readFileSync(absPath, "utf-8"); @@ -206,8 +214,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s // state machine keeps returning the same complete-slice unit (roadmap still shows // the slice incomplete), so dispatchNextUnit recurses forever. if (unitType === "complete-slice") { - const { milestone: mid, slice: sid } = parseUnitId(unitId); - + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; if (mid && sid) { const dir = resolveSlicePath(base, mid, sid); if (dir) { @@ -221,7 +230,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s try { const roadmapContent = readFileSync(roadmapFile, "utf-8"); const roadmap = parseRoadmap(roadmapContent); - const slice = (roadmap.slices ?? []).find(s => s.id === sid); + const slice = roadmap.slices.find((s) => s.id === sid); if (slice && !slice.done) return false; } catch { // Corrupt/unparseable roadmap — fail verification so the unit @@ -240,7 +249,12 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s * Write a placeholder artifact so the pipeline can advance past a stuck unit. * Returns the relative path written, or null if the path couldn't be resolved. */ -export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null { +export function writeBlockerPlaceholder( + unitType: string, + unitId: string, + base: string, + reason: string, +): string | null { const absPath = resolveExpectedArtifactPath(unitType, unitId, base); if (!absPath) return null; const dir = dirname(absPath); @@ -259,8 +273,14 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base: return diagnoseExpectedArtifact(unitType, unitId, base); } -export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); +export function diagnoseExpectedArtifact( + unitType: string, + unitId: string, + base: string, +): string | null { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; switch (unitType) { case "research-milestone": return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`; @@ -271,6 +291,7 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: case "plan-slice": return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`; case "execute-task": { + const tid = parts[2]; return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`; } case "complete-slice": @@ -299,9 +320,13 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: * the [x] checkbox in the slice plan. Returns true if artifacts were written. */ export function skipExecuteTask( - base: string, mid: string, sid: string, tid: string, + base: string, + mid: string, + sid: string, + tid: string, status: { summaryExists: boolean; taskChecked: boolean }, - reason: string, maxAttempts: number, + reason: string, + maxAttempts: number, ): boolean { // Write a blocker task summary if missing. if (!status.summaryExists) { @@ -343,48 +368,6 @@ export function skipExecuteTask( return true; } -// ─── Disk-backed completed-unit helpers ─────────────────────────────────────── - -function isStringArray(data: unknown): data is string[] { - return Array.isArray(data) && data.every(item => typeof item === "string"); -} - -/** Path to the persisted completed-unit keys file. */ -export function completedKeysPath(base: string): string { - return join(gsdRoot(base), "completed-units.json"); -} - -/** Write a completed unit key to disk (read-modify-write append to set). */ -export function persistCompletedKey(base: string, key: string): void { - const file = completedKeysPath(base); - const keys = loadJsonFileOrNull(file, isStringArray) ?? []; - const keySet = new Set(keys); - if (!keySet.has(key)) { - keys.push(key); - atomicWriteSync(file, JSON.stringify(keys)); - } -} - -/** Remove a stale completed unit key from disk. */ -export function removePersistedKey(base: string, key: string): void { - const file = completedKeysPath(base); - const keys = loadJsonFileOrNull(file, isStringArray); - if (!keys) return; - const filtered = keys.filter(k => k !== key); - if (filtered.length !== keys.length) { - atomicWriteSync(file, JSON.stringify(filtered)); - } -} - -/** Load all completed unit keys from disk into the in-memory set. */ -export function loadPersistedKeys(base: string, target: Set): void { - const file = completedKeysPath(base); - const keys = loadJsonFileOrNull(file, isStringArray); - if (keys) { - for (const k of keys) target.add(k); - } -} - // ─── Merge State Reconciliation ─────────────────────────────────────────────── /** @@ -394,7 +377,10 @@ export function loadPersistedKeys(base: string, target: Set): void { * * Returns true if state was dirty and re-derivation is needed. */ -export function reconcileMergeState(basePath: string, ctx: ExtensionContext): boolean { +export function reconcileMergeState( + basePath: string, + ctx: ExtensionContext, +): boolean { const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD"); const squashMsgPath = join(basePath, ".git", "SQUASH_MSG"); const hasMergeHead = existsSync(mergeHeadPath); @@ -405,7 +391,7 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo if (conflictedFiles.length === 0) { // All conflicts resolved — finalize the merge/squash commit try { - nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder + nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder const mode = hasMergeHead ? "merge" : "squash commit"; ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info"); } catch { @@ -413,8 +399,8 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo } } else { // Still conflicted — try auto-resolving .gsd/ state file conflicts (#530) - const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/")); - const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/")); + const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/")); + const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/")); if (gsdConflicts.length > 0 && codeConflicts.length === 0) { // All conflicts are in .gsd/ state files — auto-resolve by accepting theirs @@ -427,7 +413,10 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo } if (resolved) { try { - nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts"); + nativeCommit( + basePath, + "chore: auto-resolve .gsd/ state file conflicts", + ); ctx.ui.notify( `Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`, "info", @@ -438,11 +427,23 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo } if (!resolved) { if (hasMergeHead) { - try { nativeMergeAbort(basePath); } catch { /* best-effort */ } + try { + nativeMergeAbort(basePath); + } catch { + /* best-effort */ + } } else if (hasSquashMsg) { - try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } + try { + unlinkSync(squashMsgPath); + } catch { + /* best-effort */ + } + } + try { + nativeResetHard(basePath); + } catch { + /* best-effort */ } - try { nativeResetHard(basePath); } catch { /* best-effort */ } ctx.ui.notify( "Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.", "warning", @@ -451,11 +452,23 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo } else { // Code conflicts present — abort and reset if (hasMergeHead) { - try { nativeMergeAbort(basePath); } catch { /* best-effort */ } + try { + nativeMergeAbort(basePath); + } catch { + /* best-effort */ + } } else if (hasSquashMsg) { - try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } + try { + unlinkSync(squashMsgPath); + } catch { + /* best-effort */ + } + } + try { + nativeResetHard(basePath); + } catch { + /* best-effort */ } - try { nativeResetHard(basePath); } catch { /* best-effort */ } ctx.ui.notify( "Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.", "warning", @@ -468,14 +481,14 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo // ─── Self-Heal Runtime Records ──────────────────────────────────────────────── /** - * Self-heal: scan runtime records in .gsd/ and clear any where the expected - * artifact already exists on disk. This repairs incomplete closeouts from - * prior crashes — preventing spurious re-dispatch of already-completed units. + * Self-heal: scan runtime records in .gsd/ and clear stale ones. + * Clears dispatched records older than 1 hour (process crashed before + * completing the unit). deriveState() handles re-derivation — no need + * for completion key persistence here. */ export async function selfHealRuntimeRecords( base: string, ctx: ExtensionContext, - completedKeySet: Set, ): Promise { try { const { listUnitRuntimeRecords } = await import("./unit-runtime.js"); @@ -485,26 +498,8 @@ export async function selfHealRuntimeRecords( const now = Date.now(); for (const record of records) { const { unitType, unitId } = record; - const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base); - // Case 1: Artifact exists — unit completed but closeout didn't finish. - // Use verifyExpectedArtifact (not just existsSync) so that execute-task - // also checks the plan checkbox is marked [x]. Without this, a task - // whose summary exists but checkbox is unchecked would be incorrectly - // marked as completed, causing deriveState to re-dispatch it endlessly. - if (artifactPath && existsSync(artifactPath) && verifyExpectedArtifact(unitType, unitId, base)) { - clearUnitRuntimeRecord(base, unitType, unitId); - // Also persist completion key if missing - const key = `${unitType}/${unitId}`; - if (!completedKeySet.has(key)) { - persistCompletedKey(base, key); - completedKeySet.add(key); - } - healed++; - continue; - } - - // Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed) + // Clear stale dispatched records (dispatched > 1h ago, process crashed) const age = now - (record.startedAt ?? 0); if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) { clearUnitRuntimeRecord(base, unitType, unitId); @@ -513,7 +508,10 @@ export async function selfHealRuntimeRecords( } } if (healed > 0) { - ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info"); + ctx.ui.notify( + `Self-heal: cleared ${healed} stale runtime record(s).`, + "info", + ); } } catch (e) { // Non-fatal — self-heal should never block auto-mode start @@ -527,8 +525,15 @@ export async function selfHealRuntimeRecords( * Build concrete, manual remediation steps for a loop-detected unit failure. * These are shown when automatic reconciliation is not possible. */ -export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); +export function buildLoopRemediationSteps( + unitType: string, + unitId: string, + base: string, +): string | null { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; switch (unitType) { case "execute-task": { if (!mid || !sid || !tid) break; @@ -544,9 +549,10 @@ export function buildLoopRemediationSteps(unitType: string, unitId: string, base case "plan-slice": case "research-slice": { if (!mid || !sid) break; - const artifactRel = unitType === "plan-slice" - ? relSliceFile(base, mid, sid, "PLAN") - : relSliceFile(base, mid, sid, "RESEARCH"); + const artifactRel = + unitType === "plan-slice" + ? relSliceFile(base, mid, sid, "PLAN") + : relSliceFile(base, mid, sid, "RESEARCH"); return [ ` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`, ` 2. Run \`gsd doctor\` to reconcile .gsd/ state`, diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 078ed3f31..b7515b137 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -15,61 +15,73 @@ import type { } from "@gsd/pi-coding-agent"; import { deriveState } from "./state.js"; import { loadFile, getManifestStatus } from "./files.js"; -import { loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js"; -import { isInsideWorktree, ensureGsdSymlink } from "./repo-identity.js"; -import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js"; -import { sendDesktopNotification } from "./notifications.js"; -import { sendRemoteNotification } from "../remote-questions/notify.js"; import { - gsdRoot, - resolveMilestoneFile, - milestonesDir, -} from "./paths.js"; + loadEffectiveGSDPreferences, + resolveSkillDiscoveryMode, + getIsolationMode, +} from "./preferences.js"; +import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; +import { gsdRoot, resolveMilestoneFile, milestonesDir } from "./paths.js"; import { invalidateAllCaches } from "./cache.js"; import { synthesizeCrashRecovery } from "./session-forensics.js"; -import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js"; +import { + writeLock, + clearLock, + readCrashLock, + formatCrashInfo, + isLockProcessAlive, +} from "./crash-recovery.js"; import { acquireSessionLock, - updateSessionLock, releaseSessionLock, - readSessionLockData, - isSessionLockProcessAlive, + updateSessionLock, } from "./session-lock.js"; -import { selfHealRuntimeRecords } from "./auto-recovery.js"; import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; -import { nativeIsRepo, nativeInit } from "./native-git-bridge.js"; -import { createGitService } from "./git-service.js"; +import { + nativeIsRepo, + nativeInit, + nativeAddAll, + nativeCommit, +} from "./native-git-bridge.js"; +import { GitServiceImpl } from "./git-service.js"; import { captureIntegrationBranch, detectWorktreeName, setActiveMilestoneId, } from "./worktree.js"; -import { - createAutoWorktree, - enterAutoWorktree, - getAutoWorktreePath, - isInAutoWorktree, -} from "./auto-worktree.js"; -import { readResourceVersion } from "./resource-version.js"; -import { initMetrics, getLedger } from "./metrics.js"; +import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js"; +import { readResourceVersion } from "./auto-worktree-sync.js"; +import { initMetrics } from "./metrics.js"; import { initRoutingHistory } from "./routing-history.js"; -import { restoreHookState, resetHookState, clearPersistedHookState } from "./post-unit-hooks.js"; +import { restoreHookState, resetHookState } from "./post-unit-hooks.js"; import { resetProactiveHealing } from "./doctor-proactive.js"; import { snapshotSkills } from "./skill-discovery.js"; import { isDbAvailable } from "./gsd-db.js"; -import { loadPersistedKeys } from "./auto-recovery.js"; import { hideFooter } from "./auto-dashboard.js"; -import { debugLog, enableDebug, isDebugEnabled, getDebugLogPath } from "./debug-logger.js"; +import { + debugLog, + enableDebug, + isDebugEnabled, + getDebugLogPath, +} from "./debug-logger.js"; import type { AutoSession } from "./auto/session.js"; -import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + statSync, + unlinkSync, +} from "node:fs"; import { join } from "node:path"; -import { getErrorMessage } from "./error-utils.js"; -import { parseUnitId } from "./unit-id.js"; +import { sep as pathSep } from "node:path"; + +import type { WorktreeResolver } from "./worktree-resolver.js"; export interface BootstrapDeps { shouldUseWorktreeIsolation: () => boolean; registerSigtermHandler: (basePath: string) => void; lockBase: () => string; + buildResolver: () => WorktreeResolver; } /** @@ -89,17 +101,16 @@ export async function bootstrapAutoSession( requestedStepMode: boolean, deps: BootstrapDeps, ): Promise { - const { shouldUseWorktreeIsolation, registerSigtermHandler, lockBase } = deps; + const { + shouldUseWorktreeIsolation, + registerSigtermHandler, + lockBase, + buildResolver, + } = deps; - // ── Session lock: acquire FIRST, before any state mutation ────────────── - // This is the primary guard against concurrent sessions on the same project. - // Uses OS-level file locking (proper-lockfile) to prevent TOCTOU races. const lockResult = acquireSessionLock(base); if (!lockResult.acquired) { - ctx.ui.notify( - `${lockResult.reason}\nStop it with \`kill ${lockResult.existingPid ?? "the other process"}\` before starting a new session.`, - "error", - ); + ctx.ui.notify(lockResult.reason, "error"); return false; } @@ -112,379 +123,442 @@ export async function bootstrapAutoSession( try { // Ensure git repo exists if (!nativeIsRepo(base)) { - const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; + const mainBranch = + loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; nativeInit(base, mainBranch); } // Ensure .gitignore has baseline patterns - const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git; - const manageGitignore = gitPrefs?.manage_gitignore; - ensureGitignore(base, { manageGitignore }); - if (manageGitignore !== false) untrackRuntimeFiles(base); + const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git; + const commitDocs = gitPrefs?.commit_docs; + const manageGitignore = gitPrefs?.manage_gitignore; + ensureGitignore(base, { commitDocs, manageGitignore }); + if (manageGitignore !== false) untrackRuntimeFiles(base); - // Migrate legacy in-project .gsd/ to external state directory - recoverFailedMigration(base); - const migration = migrateToExternalState(base); - if (migration.error) { - ctx.ui.notify(`External state migration warning: ${migration.error}`, "warning"); - } - // Ensure symlink exists (handles fresh projects and post-migration) - ensureGsdSymlink(base); - - // Bootstrap .gsd/ if it doesn't exist - const gsdDir = gsdRoot(base); - if (!existsSync(gsdDir)) { - mkdirSync(join(gsdDir, "milestones"), { recursive: true }); - } - - // Initialize GitServiceImpl - s.gitService = createGitService(s.basePath); - - // Check for crash from previous session (use both old and new lock data). - // Skip if the lock PID matches this process — acquireSessionLock() writes - // to the same auto.lock file before this check, so we'd always false-positive. - const crashLock = readCrashLock(base); - if (crashLock && crashLock.pid !== process.pid) { - // We already hold the session lock, so no concurrent session is running. - // The crash lock is from a dead process — recover context from it. - const recoveredMid = parseUnitId(crashLock.unitId).milestone; - const milestoneAlreadyComplete = recoveredMid - ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY") - : false; - - if (milestoneAlreadyComplete) { - ctx.ui.notify( - `Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`, - "info", - ); - } else { - const activityDir = join(gsdRoot(base), "activity"); - const recovery = synthesizeCrashRecovery( - base, crashLock.unitType, crashLock.unitId, - crashLock.sessionFile, activityDir, - ); - if (recovery && recovery.trace.toolCallCount > 0) { - s.pendingCrashRecovery = recovery.prompt; - ctx.ui.notify( - `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`, - "warning", - ); - } else { - ctx.ui.notify( - `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`, - "warning", - ); - } - } - clearLock(base); - } - - // ── Debug mode ── - if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") { - enableDebug(base); - } - if (isDebugEnabled()) { - const { isNativeParserAvailable } = await import("./native-parser-bridge.js"); - debugLog("debug-start", { - platform: process.platform, - arch: process.arch, - node: process.version, - model: ctx.model?.id ?? "unknown", - provider: ctx.model?.provider ?? "unknown", - nativeParser: isNativeParserAvailable(), - cwd: base, - }); - ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info"); - } - - // Invalidate caches before initial state derivation - invalidateAllCaches(); - - // Clean stale runtime unit files for completed milestones (#887) - try { - const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units"); - if (existsSync(runtimeUnitsDir)) { - for (const file of readdirSync(runtimeUnitsDir)) { - if (!file.endsWith(".json")) continue; - const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); - if (!midMatch) continue; - const mid = midMatch[1]; - if (resolveMilestoneFile(base, mid, "SUMMARY")) { - try { unlinkSync(join(runtimeUnitsDir, file)); } catch (e) { debugLog("stale-unit-cleanup-failed", { file, error: getErrorMessage(e) }); } + // Bootstrap .gsd/ if it doesn't exist + const gsdDir = join(base, ".gsd"); + if (!existsSync(gsdDir)) { + mkdirSync(join(gsdDir, "milestones"), { recursive: true }); + if (commitDocs !== false) { + try { + nativeAddAll(base); + nativeCommit(base, "chore: init gsd"); + } catch { + /* nothing to commit */ } } } - } catch (e) { debugLog("stale-unit-dir-cleanup-failed", { error: getErrorMessage(e) }); } - let state = await deriveState(base); + // Initialize GitServiceImpl + s.gitService = new GitServiceImpl( + s.basePath, + loadEffectiveGSDPreferences()?.preferences?.git ?? {}, + ); - // Milestone branch recovery (#601) - let hasSurvivorBranch = false; - if ( - state.activeMilestone && - (state.phase === "pre-planning" || state.phase === "needs-discussion") && - shouldUseWorktreeIsolation() && - !detectWorktreeName(base) && - !isInsideWorktree(base) - ) { - const milestoneBranch = `milestone/${state.activeMilestone.id}`; - const { nativeBranchExists } = await import("./native-git-bridge.js"); - hasSurvivorBranch = nativeBranchExists(base, milestoneBranch); - if (hasSurvivorBranch) { - ctx.ui.notify( - `Found prior session branch ${milestoneBranch}. Resuming.`, - "info", - ); - } - } - - if (!hasSurvivorBranch) { - // No active work — start a new milestone via discuss flow - if (!state.activeMilestone || state.phase === "complete") { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - - invalidateAllCaches(); - const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "complete" && postState.phase !== "pre-planning") { - state = postState; - } else if (postState.activeMilestone && postState.phase === "pre-planning") { - const contextFile = resolveMilestoneFile(base, postState.activeMilestone.id, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); - if (hasContext) { - state = postState; - } else { - ctx.ui.notify( - "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", - "warning", - ); - return releaseLockAndReturn(); - } - } else { + // Check for crash from previous session. Skip our own fresh bootstrap lock. + const crashLock = readCrashLock(base); + if (crashLock && crashLock.pid !== process.pid) { + if (isLockProcessAlive(crashLock)) { + ctx.ui.notify( + `Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`, + "error", + ); return releaseLockAndReturn(); } + const recoveredMid = crashLock.unitId.split("/")[0]; + const milestoneAlreadyComplete = recoveredMid + ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY") + : false; + + if (milestoneAlreadyComplete) { + ctx.ui.notify( + `Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`, + "info", + ); + } else { + const activityDir = join(gsdRoot(base), "activity"); + const recovery = synthesizeCrashRecovery( + base, + crashLock.unitType, + crashLock.unitId, + crashLock.sessionFile, + activityDir, + ); + if (recovery && recovery.trace.toolCallCount > 0) { + s.pendingCrashRecovery = recovery.prompt; + ctx.ui.notify( + `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`, + "warning", + ); + } else { + ctx.ui.notify( + `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`, + "warning", + ); + } + } + clearLock(base); } - // Active milestone exists but has no roadmap - if (state.phase === "pre-planning") { - const mid = state.activeMilestone!.id; - const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); - if (!hasContext) { + // ── Debug mode ── + if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") { + enableDebug(base); + } + if (isDebugEnabled()) { + const { isNativeParserAvailable } = + await import("./native-parser-bridge.js"); + debugLog("debug-start", { + platform: process.platform, + arch: process.arch, + node: process.version, + model: ctx.model?.id ?? "unknown", + provider: ctx.model?.provider ?? "unknown", + nativeParser: isNativeParserAvailable(), + cwd: base, + }); + ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info"); + } + + // Invalidate caches before initial state derivation + invalidateAllCaches(); + + // Clean stale runtime unit files for completed milestones (#887) + try { + const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units"); + if (existsSync(runtimeUnitsDir)) { + for (const file of readdirSync(runtimeUnitsDir)) { + if (!file.endsWith(".json")) continue; + const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); + if (!midMatch) continue; + const mid = midMatch[1]; + if (resolveMilestoneFile(base, mid, "SUMMARY")) { + try { + unlinkSync(join(runtimeUnitsDir, file)); + } catch (e) { + debugLog("stale-unit-cleanup-failed", { + file, + error: e instanceof Error ? e.message : String(e), + }); + } + } + } + } + } catch (e) { + debugLog("stale-unit-dir-cleanup-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + + let state = await deriveState(base); + + // Stale worktree state recovery (#654) + if ( + state.activeMilestone && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) + ) { + const wtPath = getAutoWorktreePath(base, state.activeMilestone.id); + if (wtPath) { + state = await deriveState(wtPath); + } + } + + // Milestone branch recovery (#601) + let hasSurvivorBranch = false; + if ( + state.activeMilestone && + (state.phase === "pre-planning" || state.phase === "needs-discussion") && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) && + !base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`) + ) { + const milestoneBranch = `milestone/${state.activeMilestone.id}`; + const { nativeBranchExists } = await import("./native-git-bridge.js"); + hasSurvivorBranch = nativeBranchExists(base, milestoneBranch); + if (hasSurvivorBranch) { + ctx.ui.notify( + `Found prior session branch ${milestoneBranch}. Resuming.`, + "info", + ); + } + } + + if (!hasSurvivorBranch) { + // No active work — start a new milestone via discuss flow + if (!state.activeMilestone || state.phase === "complete") { const { showSmartEntry } = await import("./guided-flow.js"); await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); invalidateAllCaches(); const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "pre-planning") { + if ( + postState.activeMilestone && + postState.phase !== "complete" && + postState.phase !== "pre-planning" + ) { state = postState; - } else { - ctx.ui.notify( - "Discussion completed but milestone context is still missing. Run /gsd to try again.", - "warning", + } else if ( + postState.activeMilestone && + postState.phase === "pre-planning" + ) { + const contextFile = resolveMilestoneFile( + base, + postState.activeMilestone.id, + "CONTEXT", ); + const hasContext = !!(contextFile && (await loadFile(contextFile))); + if (hasContext) { + state = postState; + } else { + ctx.ui.notify( + "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", + "warning", + ); + return releaseLockAndReturn(); + } + } else { return releaseLockAndReturn(); } } - } - } - // Unreachable safety check - if (!state.activeMilestone) { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - return releaseLockAndReturn(); - } + // Active milestone exists but has no roadmap + if (state.phase === "pre-planning") { + const mid = state.activeMilestone!.id; + const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); + const hasContext = !!(contextFile && (await loadFile(contextFile))); + if (!hasContext) { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - // ── Initialize session state ── - 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(); - 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 SIGTERM handler - registerSigtermHandler(base); - - // Capture integration branch - if (s.currentMilestoneId) { - if (getIsolationMode() !== "none") { - captureIntegrationBranch(base, s.currentMilestoneId); - } - setActiveMilestoneId(base, s.currentMilestoneId); - } - - // ── Auto-worktree setup ── - s.originalBasePath = base; - - if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isInsideWorktree(base)) { - try { - const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId); - if (existingWtPath) { - const wtPath = enterAutoWorktree(base, s.currentMilestoneId); - s.basePath = wtPath; - s.gitService = createGitService(s.basePath); - ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info"); - } else { - const wtPath = createAutoWorktree(base, s.currentMilestoneId); - s.basePath = wtPath; - s.gitService = createGitService(s.basePath); - ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info"); + invalidateAllCaches(); + const postState = await deriveState(base); + if (postState.activeMilestone && postState.phase !== "pre-planning") { + state = postState; + } else { + ctx.ui.notify( + "Discussion completed but milestone context is still missing. Run /gsd to try again.", + "warning", + ); + return releaseLockAndReturn(); + } + } } - registerSigtermHandler(s.originalBasePath); - } catch (err) { - ctx.ui.notify( - `Auto-worktree setup failed: ${getErrorMessage(err)}. Continuing in project root.`, - "warning", - ); } - } - // ── DB lifecycle ── - const gsdDbPath = join(gsdRoot(s.basePath), "gsd.db"); - const gsdDirPath = gsdRoot(s.basePath); - if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) { - const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md")); - const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md")); - const hasMilestones = existsSync(join(gsdDirPath, "milestones")); - if (hasDecisions || hasRequirements || hasMilestones) { + // Unreachable safety check + if (!state.activeMilestone) { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + return releaseLockAndReturn(); + } + + // ── Initialize session state ── + s.active = true; + s.stepMode = requestedStepMode; + s.verbose = verboseMode; + s.cmdCtx = ctx; + s.basePath = base; + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.lastBudgetAlertLevel = 0; + s.unitLifetimeDispatches.clear(); + resetHookState(); + restoreHookState(base); + resetProactiveHealing(); + 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 SIGTERM handler + registerSigtermHandler(base); + + // Capture integration branch + if (s.currentMilestoneId) { + if (getIsolationMode() !== "none") { + captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs }); + } + setActiveMilestoneId(base, s.currentMilestoneId); + } + + // ── Auto-worktree setup ── + s.originalBasePath = base; + + const isUnderGsdWorktrees = (p: string): boolean => { + const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; + if (p.includes(marker)) return true; + const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`; + return p.endsWith(worktreesSuffix); + }; + + if ( + s.currentMilestoneId && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) && + !isUnderGsdWorktrees(base) + ) { + buildResolver().enterMilestone(s.currentMilestoneId, { + notify: ctx.ui.notify.bind(ctx.ui), + }); + if (s.basePath !== base) { + // Successfully entered worktree — re-register SIGTERM handler at original base + registerSigtermHandler(s.originalBasePath); + } + } + + // ── DB lifecycle ── + 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")); + const hasMilestones = existsSync(join(gsdDirPath, "milestones")); + if (hasDecisions || hasRequirements || hasMilestones) { + try { + const { openDatabase: openDb } = await import("./gsd-db.js"); + const { migrateFromMarkdown } = await import("./md-importer.js"); + openDb(gsdDbPath); + migrateFromMarkdown(s.basePath); + } catch (err) { + process.stderr.write( + `gsd-migrate: auto-migration failed: ${(err as Error).message}\n`, + ); + } + } + } + if (existsSync(gsdDbPath) && !isDbAvailable()) { try { const { openDatabase: openDb } = await import("./gsd-db.js"); - const { migrateFromMarkdown } = await import("./md-importer.js"); openDb(gsdDbPath); - migrateFromMarkdown(s.basePath); } catch (err) { - process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`); + process.stderr.write( + `gsd-db: failed to open existing database: ${(err as Error).message}\n`, + ); } } - } - if (existsSync(gsdDbPath) && !isDbAvailable()) { - try { - const { openDatabase: openDb } = await import("./gsd-db.js"); - openDb(gsdDbPath); - } catch (err) { - process.stderr.write(`gsd-db: failed to open existing database: ${(err as Error).message}\n`); + + // Initialize metrics + initMetrics(s.basePath); + + // Initialize routing history + initRoutingHistory(s.basePath); + + // Capture session's model at auto-mode start (#650) + const currentModel = ctx.model; + if (currentModel) { + s.autoModeStartModel = { + provider: currentModel.provider, + id: currentModel.id, + }; } - } - // Initialize metrics - initMetrics(s.basePath); - - // Initialize routing history - initRoutingHistory(s.basePath); - - // Capture session's model at auto-mode start (#650) - const currentModel = ctx.model; - if (currentModel) { - s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id }; - } - - // Snapshot installed skills - if (resolveSkillDiscoveryMode() !== "off") { - snapshotSkills(); - } - - ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); - ctx.ui.setFooter(hideFooter); - const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode"; - const pendingCount = (state.registry ?? []).filter(m => m.status !== 'complete' && m.status !== 'parked').length; - const scopeMsg = pendingCount > 1 - ? `Will loop through ${pendingCount} milestones.` - : "Will loop until milestone complete."; - ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); - - // Update lock file with milestone info (OS lock already acquired at bootstrap start) - updateSessionLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); - writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); - - // Secrets collection gate — pause instead of blocking (#1146) - const mid = state.activeMilestone!.id; - try { - const manifestStatus = await getManifestStatus(base, mid); - if (manifestStatus && manifestStatus.pending.length > 0) { - const pendingKeys = manifestStatus.pending; - const keyList = pendingKeys.map((k: string) => ` • ${k}`).join("\n"); - s.paused = true; - s.pausedForSecrets = true; - ctx.ui.notify( - `Auto-mode paused: ${pendingKeys.length} env variable${pendingKeys.length > 1 ? "s" : ""} needed for ${mid}.\n${keyList}\n\nCollect them with /gsd secrets, then resume with /gsd auto.`, - "warning", - ); - ctx.ui.setStatus("gsd-auto", "paused"); - sendDesktopNotification( - "GSD — Secrets Required", - `${pendingKeys.length} env variable(s) needed for ${mid}. Run /gsd secrets to provide them.`, - "warning", - "attention", - ); - // Notify remote channel if configured (one-way — never collect secrets via remote) - sendRemoteNotification( - "GSD — Secrets Required", - `Auto-mode paused: ${pendingKeys.length} env variable(s) needed for ${mid}.\n${keyList}\n\nReturn to the terminal and run /gsd secrets to provide them securely.`, - ).catch(() => {}); // fire-and-forget - return false; + // Snapshot installed skills + if (resolveSkillDiscoveryMode() !== "off") { + snapshotSkills(); } - } catch (err) { - ctx.ui.notify( - `Secrets check error: ${getErrorMessage(err)}. Continuing without secrets.`, - "warning", + + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); + ctx.ui.setFooter(hideFooter); + const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode"; + const pendingCount = (state.registry ?? []).filter( + (m) => m.status !== "complete" && m.status !== "parked", + ).length; + const scopeMsg = + pendingCount > 1 + ? `Will loop through ${pendingCount} milestones.` + : "Will loop until milestone complete."; + ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); + + updateSessionLock( + lockBase(), + "starting", + s.currentMilestoneId ?? "unknown", + 0, ); - } + writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); - // Self-heal: clear stale runtime records - await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); - - // Self-heal: remove stale .git/index.lock - try { - const gitLockFile = join(base, ".git", "index.lock"); - if (existsSync(gitLockFile)) { - const lockAge = Date.now() - statSync(gitLockFile).mtimeMs; - if (lockAge > 60_000) { - unlinkSync(gitLockFile); - ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info"); - } - } - } catch (e) { debugLog("git-lock-cleanup-failed", { error: getErrorMessage(e) }); } - - // Pre-flight: validate milestone queue - try { - const msDir = join(gsdRoot(base), "milestones"); - if (existsSync(msDir)) { - const milestoneIds = readdirSync(msDir, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name)) - .map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name); - if (milestoneIds.length > 1) { - const issues: string[] = []; - for (const id of milestoneIds) { - const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT"); - if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`); - } - if (issues.length > 0) { - ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => ` ⚠ ${i}`).join("\n")}`, "warning"); + // Secrets collection gate + const mid = state.activeMilestone!.id; + try { + const manifestStatus = await getManifestStatus(base, mid); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await collectSecretsFromManifest(base, 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.`, + "info", + ); } else { - ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info"); + ctx.ui.notify("Secrets collection skipped.", "info"); } } + } catch (err) { + ctx.ui.notify( + `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, + "warning", + ); + } + + // Self-heal: remove stale .git/index.lock + try { + const gitLockFile = join(base, ".git", "index.lock"); + if (existsSync(gitLockFile)) { + const lockAge = Date.now() - statSync(gitLockFile).mtimeMs; + if (lockAge > 60_000) { + unlinkSync(gitLockFile); + ctx.ui.notify( + "Removed stale .git/index.lock from prior crash.", + "info", + ); + } + } + } catch (e) { + debugLog("git-lock-cleanup-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + + // Pre-flight: validate milestone queue + try { + const msDir = join(base, ".gsd", "milestones"); + if (existsSync(msDir)) { + const milestoneIds = readdirSync(msDir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name)) + .map((d) => d.name.match(/^(M\d{3})/)?.[1] ?? d.name); + if (milestoneIds.length > 1) { + const issues: string[] = []; + for (const id of milestoneIds) { + const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT"); + if (draft) + issues.push( + `${id}: has CONTEXT-DRAFT.md (will pause for discussion)`, + ); + } + if (issues.length > 0) { + ctx.ui.notify( + `Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map((i) => ` ⚠ ${i}`).join("\n")}`, + "warning", + ); + } else { + ctx.ui.notify( + `Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, + "info", + ); + } + } + } + } catch { + /* non-fatal */ } - } catch { /* non-fatal */ } return true; } catch (err) { diff --git a/src/resources/extensions/gsd/auto-stuck-detection.ts b/src/resources/extensions/gsd/auto-stuck-detection.ts deleted file mode 100644 index b8ec5d954..000000000 --- a/src/resources/extensions/gsd/auto-stuck-detection.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Stuck detection and loop recovery for auto-mode unit dispatch. - * - * Tracks dispatch counts per unit, enforces lifetime caps, and attempts - * stub/artifact recovery before stopping. - * - * Extracted from dispatchNextUnit() in auto.ts. Returns action values - * instead of calling stopAuto/dispatchNextUnit — the caller handles - * control flow. - */ - -import type { ExtensionContext } from "@gsd/pi-coding-agent"; -import { - inspectExecuteTaskDurability, -} from "./unit-runtime.js"; -import { - verifyExpectedArtifact, - diagnoseExpectedArtifact, - skipExecuteTask, - persistCompletedKey, - buildLoopRemediationSteps, -} from "./auto-recovery.js"; -import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; -import { saveActivityLog } from "./activity-log.js"; -import { invalidateAllCaches } from "./cache.js"; -import { sendDesktopNotification } from "./notifications.js"; -import { debugLog } from "./debug-logger.js"; -import { - resolveMilestonePath, - resolveSlicePath, - resolveTasksDir, - buildTaskFileName, -} from "./paths.js"; -import { - MAX_UNIT_DISPATCHES, - STUB_RECOVERY_THRESHOLD, - MAX_LIFETIME_DISPATCHES, -} from "./auto/session.js"; -import type { AutoSession } from "./auto/session.js"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { parseUnitId } from "./unit-id.js"; - -export interface StuckContext { - s: AutoSession; - ctx: ExtensionContext; - unitType: string; - unitId: string; - basePath: string; - buildSnapshotOpts: () => CloseoutOptions & Record; -} - -export type StuckResult = - | { action: "proceed" } - | { action: "recovered"; dispatchAgain: true } - | { action: "stop"; reason: string; notifyMessage?: string }; - -/** - * Check dispatch counts, enforce lifetime cap and MAX_UNIT_DISPATCHES, - * attempt stub/artifact recovery. Returns an action for the caller. - */ -export async function checkStuckAndRecover(sctx: StuckContext): Promise { - const { s, ctx, unitType, unitId, basePath, buildSnapshotOpts } = sctx; - const dispatchKey = `${unitType}/${unitId}`; - const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0; - - // Real dispatch reached — clear the consecutive-skip counter for this unit. - s.unitConsecutiveSkips.delete(dispatchKey); - - debugLog("dispatch-unit", { - type: unitType, - id: unitId, - cycle: prevCount + 1, - lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1, - }); - - // Hard lifetime cap — survives counter resets from loop-recovery/self-repair. - const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1; - s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount); - if (lifetimeCount > MAX_LIFETIME_DISPATCHES) { - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); - return { - action: "stop", - reason: `Hard loop: ${unitType} ${unitId}`, - notifyMessage: `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.`, - }; - } - - if (prevCount >= MAX_UNIT_DISPATCHES) { - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - - // Final reconciliation pass for execute-task - if (unitType === "execute-task") { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - if (mid && sid && tid) { - const status = await inspectExecuteTaskDurability(basePath, unitId); - if (status) { - const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount); - if (reconciled && verifyExpectedArtifact(unitType, unitId, 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", - ); - const reconciledKey = `${unitType}/${unitId}`; - persistCompletedKey(basePath, reconciledKey); - s.completedKeySet.add(reconciledKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - return { action: "recovered", dispatchAgain: true }; - } - } - } - } - - // General reconciliation: artifact appeared on last attempt - if (verifyExpectedArtifact(unitType, unitId, basePath)) { - ctx.ui.notify( - `Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`, - "info", - ); - persistCompletedKey(basePath, dispatchKey); - s.completedKeySet.add(dispatchKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - return { action: "recovered", dispatchAgain: true }; - } - - // Last resort for complete-milestone: generate stub summary - if (unitType === "complete-milestone") { - try { - const mPath = resolveMilestonePath(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); - s.completedKeySet.add(dispatchKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - return { action: "recovered", dispatchAgain: true }; - } - } - } catch { /* non-fatal — fall through to normal stop */ } - } - - const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); - const remediation = buildLoopRemediationSteps(unitType, unitId, basePath); - sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error"); - return { - action: "stop", - reason: `Loop: ${unitType} ${unitId}`, - notifyMessage: `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`, - }; - } - - 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 { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - if (status && mid && sid && tid) { - if (status.summaryExists && !status.taskChecked) { - const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0); - if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) { - ctx.ui.notify( - `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`, - "warning", - ); - const repairedKey = `${unitType}/${unitId}`; - persistCompletedKey(basePath, repairedKey); - s.completedKeySet.add(repairedKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - return { action: "recovered", dispatchAgain: true }; - } - } else if (prevCount >= STUB_RECOVERY_THRESHOLD && !status.summaryExists) { - const tasksDir = resolveTasksDir(basePath, mid, sid); - const sDir = resolveSlicePath(basePath, mid, sid); - const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null); - if (targetDir) { - if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); - const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY")); - if (!existsSync(summaryPath)) { - const stubContent = [ - `# PARTIAL RECOVERY — attempt ${prevCount + 1} of ${MAX_UNIT_DISPATCHES}`, - ``, - `Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) has not yet produced a real summary.`, - `This placeholder was written by auto-mode after ${prevCount} dispatch attempts.`, - ``, - `The next agent session will retry this task. Replace this file with real work when done.`, - ].join("\n"); - writeFileSync(summaryPath, stubContent, "utf-8"); - ctx.ui.notify( - `Stub recovery (attempt ${prevCount + 1}/${MAX_UNIT_DISPATCHES}): ${unitId} stub summary placeholder written. Retrying with recovery context.`, - "warning", - ); - } - } - } - } - } - ctx.ui.notify( - `${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`, - "warning", - ); - } - - return { action: "proceed" }; -} diff --git a/src/resources/extensions/gsd/auto-supervisor.ts b/src/resources/extensions/gsd/auto-supervisor.ts index f5c1a6336..05e0713fb 100644 --- a/src/resources/extensions/gsd/auto-supervisor.ts +++ b/src/resources/extensions/gsd/auto-supervisor.ts @@ -1,17 +1,16 @@ /** - * Auto-mode Supervisor — signal handling and working-tree activity detection. + * Auto-mode Supervisor — SIGTERM handling and working-tree activity detection. * * Pure functions — no module-level globals or AutoContext dependency. */ import { clearLock } from "./crash-recovery.js"; -import { releaseSessionLock } from "./session-lock.js"; import { nativeHasChanges } from "./native-git-bridge.js"; -// ─── Signal Handling ────────────────────────────────────────────────────────── +// ─── SIGTERM Handling ───────────────────────────────────────────────────────── /** - * Register SIGTERM and SIGINT handlers that clear lock files and exit cleanly. + * Register a SIGTERM handler that clears the lock file and exits cleanly. * Captures the active base path at registration time so the handler * always references the correct path even if the module variable changes. * Removes any previously registered handler before installing the new one. @@ -22,25 +21,19 @@ export function registerSigtermHandler( currentBasePath: string, previousHandler: (() => void) | null, ): () => void { - if (previousHandler) { - process.off("SIGTERM", previousHandler); - process.off("SIGINT", previousHandler); - } + if (previousHandler) process.off("SIGTERM", previousHandler); const handler = () => { - releaseSessionLock(currentBasePath); clearLock(currentBasePath); process.exit(0); }; process.on("SIGTERM", handler); - process.on("SIGINT", handler); return handler; } -/** Deregister signal handlers (called on stop/pause). */ +/** Deregister the SIGTERM handler (called on stop/pause). */ export function deregisterSigtermHandler(handler: (() => void) | null): void { if (handler) { process.off("SIGTERM", handler); - process.off("SIGINT", handler); } } diff --git a/src/resources/extensions/gsd/auto-timeout-recovery.ts b/src/resources/extensions/gsd/auto-timeout-recovery.ts index d0ce43792..9177c8361 100644 --- a/src/resources/extensions/gsd/auto-timeout-recovery.ts +++ b/src/resources/extensions/gsd/auto-timeout-recovery.ts @@ -18,14 +18,14 @@ import { writeBlockerPlaceholder, } from "./auto-recovery.js"; import { existsSync } from "node:fs"; -import { parseUnitId } from "./unit-id.js"; + +import { resolveAgentEnd } from "./auto-loop.js"; export interface RecoveryContext { basePath: string; verbose: boolean; currentUnitStartedAt: number; unitRecoveryCount: Map; - dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise; } export async function recoverTimedOutUnit( @@ -36,7 +36,7 @@ export async function recoverTimedOutUnit( reason: "idle" | "hard", rctx: RecoveryContext, ): Promise<"recovered" | "paused"> { - const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount, dispatchNextUnit } = rctx; + const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount } = rctx; const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); const recoveryAttempts = runtime?.recoveryAttempts ?? 0; @@ -75,7 +75,7 @@ export async function recoverTimedOutUnit( "info", ); unitRecoveryCount.delete(recoveryKey); - await dispatchNextUnit(ctx, pi); + resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); return "recovered"; } @@ -129,7 +129,7 @@ export async function recoverTimedOutUnit( // Retries exhausted — write missing durable artifacts and advance. const diagnostic = formatExecuteTaskRecoveryStatus(status); - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const [mid, sid, tid] = unitId.split("/"); const skipped = mid && sid && tid ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts) : false; @@ -146,7 +146,7 @@ export async function recoverTimedOutUnit( "warning", ); unitRecoveryCount.delete(recoveryKey); - await dispatchNextUnit(ctx, pi); + resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); return "recovered"; } @@ -180,7 +180,7 @@ export async function recoverTimedOutUnit( "info", ); unitRecoveryCount.delete(recoveryKey); - await dispatchNextUnit(ctx, pi); + resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); return "recovered"; } @@ -249,7 +249,7 @@ export async function recoverTimedOutUnit( "warning", ); unitRecoveryCount.delete(recoveryKey); - await dispatchNextUnit(ctx, pi); + resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); return "recovered"; } diff --git a/src/resources/extensions/gsd/auto-timers.ts b/src/resources/extensions/gsd/auto-timers.ts index 91bd27697..32b2101e5 100644 --- a/src/resources/extensions/gsd/auto-timers.ts +++ b/src/resources/extensions/gsd/auto-timers.ts @@ -2,7 +2,7 @@ * Unit supervision timers — soft timeout warning, idle watchdog, * hard timeout, and context-pressure monitor. * - * Extracted from dispatchNextUnit() in auto.ts. All timers are set up + * Originally extracted from dispatchNextUnit() in auto.ts (now deleted — replaced by autoLoop). * via startUnitSupervision() and torn down by the caller via clearUnitTimeout(). */ @@ -20,7 +20,6 @@ import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; import { saveActivityLog } from "./activity-log.js"; import { recoverTimedOutUnit, type RecoveryContext } from "./auto-timeout-recovery.js"; import type { AutoSession } from "./auto/session.js"; -import { getErrorMessage } from "./error-utils.js"; export interface SupervisionContext { s: AutoSession; @@ -128,7 +127,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void { ); await pauseAuto(ctx, pi); } catch (err) { - const message = getErrorMessage(err); + const message = err instanceof Error ? err.message : String(err); console.error(`[idle-watchdog] Unhandled error: ${message}`); try { ctx.ui.notify(`Idle watchdog error: ${message}`, "warning"); @@ -160,7 +159,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void { ); await pauseAuto(ctx, pi); } catch (err) { - const message = getErrorMessage(err); + const message = err instanceof Error ? err.message : String(err); console.error(`[hard-timeout] Unhandled error: ${message}`); try { ctx.ui.notify(`Hard timeout error: ${message}`, "warning"); diff --git a/src/resources/extensions/gsd/auto-verification.ts b/src/resources/extensions/gsd/auto-verification.ts index 6f794bc66..1e9045d74 100644 --- a/src/resources/extensions/gsd/auto-verification.ts +++ b/src/resources/extensions/gsd/auto-verification.ts @@ -21,11 +21,8 @@ import { runDependencyAudit, } from "./verification-gate.js"; import { writeVerificationJSON } from "./verification-evidence.js"; -import { removePersistedKey } from "./auto-recovery.js"; -import type { AutoSession, PendingVerificationRetry } from "./auto/session.js"; +import type { AutoSession } from "./auto/session.js"; import { join } from "node:path"; -import { getErrorMessage } from "./error-utils.js"; -import { parseUnitId } from "./unit-id.js"; export interface VerificationContext { s: AutoSession; @@ -35,17 +32,21 @@ export interface VerificationContext { export type VerificationResult = "continue" | "retry" | "pause"; +function isInfraVerificationFailure(stderr: string): boolean { + return /\b(ENOENT|ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN|spawn\s+\S+\s+ENOENT|command not found)\b/i.test( + stderr, + ); +} + /** * Run the verification gate for the current execute-task unit. * Returns: * - "continue" — gate passed (or no checks configured), proceed normally - * - "retry" — gate failed with retries remaining, dispatchNextUnit already called + * - "retry" — gate failed with retries remaining, s.pendingVerificationRetry set for loop re-iteration * - "pause" — gate failed with retries exhausted, pauseAuto already called */ export async function runPostUnitVerification( vctx: VerificationContext, - dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise, - startDispatchGapWatchdog: (ctx: ExtensionContext, pi: ExtensionAPI) => void, pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, ): Promise { const { s, ctx, pi } = vctx; @@ -59,15 +60,16 @@ export async function runPostUnitVerification( const prefs = effectivePrefs?.preferences; // Read task plan verify field - const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); + const parts = s.currentUnit.id.split("/"); let taskPlanVerify: string | undefined; - if (mid && sid && tid) { + if (parts.length >= 3) { + const [mid, sid, tid] = parts; const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); if (planFile) { const planContent = await loadFile(planFile); if (planContent) { const slicePlan = parsePlan(planContent); - const taskEntry = slicePlan?.tasks?.find(t => t.id === tid); + const taskEntry = slicePlan?.tasks?.find((t) => t.id === tid); taskPlanVerify = taskEntry?.verify; } } @@ -85,7 +87,7 @@ export async function runPostUnitVerification( const runtimeErrors = await captureRuntimeErrors(); if (runtimeErrors.length > 0) { result.runtimeErrors = runtimeErrors; - if (runtimeErrors.some(e => e.blocking)) { + if (runtimeErrors.some((e) => e.blocking)) { result.passed = false; } } @@ -94,7 +96,9 @@ export async function runPostUnitVerification( const auditWarnings = runDependencyAudit(s.basePath); if (auditWarnings.length > 0) { result.auditWarnings = auditWarnings; - process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`); + process.stderr.write( + `verification-gate: ${auditWarnings.length} audit warning(s)\n`, + ); for (const w of auditWarnings) { process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`); } @@ -102,59 +106,49 @@ export async function runPostUnitVerification( // Auto-fix retry preferences const autoFixEnabled = prefs?.verification_auto_fix !== false; - const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2; - const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; + const maxRetries = + typeof prefs?.verification_max_retries === "number" + ? prefs.verification_max_retries + : 2; if (result.checks.length > 0) { - const blockingChecks = result.checks.filter(c => c.blocking); - const advisoryChecks = result.checks.filter(c => !c.blocking); - const blockingPassCount = blockingChecks.filter(c => c.exitCode === 0).length; - const advisoryFailCount = advisoryChecks.filter(c => c.exitCode !== 0).length; - + const passCount = result.checks.filter((c) => c.exitCode === 0).length; + const total = result.checks.length; if (result.passed) { - let msg = blockingChecks.length > 0 - ? `Verification gate: ${blockingPassCount}/${blockingChecks.length} blocking checks passed` - : `Verification gate: passed (no blocking checks)`; - if (advisoryFailCount > 0) { - msg += ` (${advisoryFailCount} advisory warning${advisoryFailCount > 1 ? "s" : ""})`; - } - ctx.ui.notify(msg); - // Log advisory warnings to stderr for visibility - if (advisoryFailCount > 0) { - const advisoryFailures = advisoryChecks.filter(c => c.exitCode !== 0); - process.stderr.write(`verification-gate: ${advisoryFailCount} advisory (non-blocking) failure(s)\n`); - for (const f of advisoryFailures) { - process.stderr.write(` [advisory] ${f.command} exited ${f.exitCode}\n`); - } - } + ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`); } else { - const blockingFailures = blockingChecks.filter(c => c.exitCode !== 0); - const failNames = blockingFailures.map(f => f.command).join(", "); + const failures = result.checks.filter((c) => c.exitCode !== 0); + const failNames = failures.map((f) => f.command).join(", "); ctx.ui.notify(`Verification gate: FAILED — ${failNames}`); - process.stderr.write(`verification-gate: ${blockingFailures.length}/${blockingChecks.length} blocking checks failed\n`); - for (const f of blockingFailures) { + process.stderr.write( + `verification-gate: ${total - passCount}/${total} checks failed\n`, + ); + for (const f of failures) { process.stderr.write(` ${f.command} exited ${f.exitCode}\n`); - if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`); - } - if (advisoryFailCount > 0) { - process.stderr.write(`verification-gate: ${advisoryFailCount} additional advisory (non-blocking) failure(s)\n`); + if (f.stderr) + process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`); } } } // Log blocking runtime errors - if (result.runtimeErrors?.some(e => e.blocking)) { - const blockingErrors = result.runtimeErrors.filter(e => e.blocking); - process.stderr.write(`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`); + if (result.runtimeErrors?.some((e) => e.blocking)) { + const blockingErrors = result.runtimeErrors.filter((e) => e.blocking); + process.stderr.write( + `verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`, + ); for (const err of blockingErrors) { - process.stderr.write(` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`); + process.stderr.write( + ` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`, + ); } } // Write verification evidence JSON const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; - if (mid && sid && tid) { + if (parts.length >= 3) { try { + const [mid, sid, tid] = parts; const sDir = resolveSlicePath(s.basePath, mid, sid); if (sDir) { const tasksDir = join(sDir, "tasks"); @@ -162,52 +156,48 @@ export async function runPostUnitVerification( writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id); } else { const nextAttempt = attempt + 1; - writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries); + writeVerificationJSON( + result, + tasksDir, + tid, + s.currentUnit.id, + nextAttempt, + maxRetries, + ); } } } catch (evidenceErr) { - process.stderr.write(`verification-evidence: write error — ${(evidenceErr as Error).message}\n`); + process.stderr.write( + `verification-evidence: write error — ${(evidenceErr as Error).message}\n`, + ); } } + const advisoryFailure = + !result.passed && + (result.discoverySource === "package-json" || + result.checks.some((check) => + isInfraVerificationFailure(check.stderr), + )); + + if (advisoryFailure) { + s.verificationRetryCount.delete(s.currentUnit.id); + s.pendingVerificationRetry = null; + ctx.ui.notify( + result.discoverySource === "package-json" + ? "Verification failed in auto-discovered package.json checks — treating as advisory." + : "Verification failed due to infrastructure/runtime environment issues — treating as advisory.", + "warning", + ); + return "continue"; + } + // ── Auto-fix retry logic ── if (result.passed) { s.verificationRetryCount.delete(s.currentUnit.id); s.pendingVerificationRetry = null; return "continue"; - } - - // Check if all failures are infra errors (ETIMEDOUT, ENOENT, etc.). - // Infra errors are transient OS-level problems the agent cannot fix — - // retrying the entire task is wasteful and creates phantom failures. - const failedChecks = result.checks.filter(c => c.exitCode !== 0); - const allInfraErrors = failedChecks.length > 0 && failedChecks.every(c => c.infraError === true); - if (allInfraErrors) { - const infraNames = failedChecks.map(f => f.command).join(", "); - ctx.ui.notify(`Verification gate: infra error (${infraNames}) — skipping retry, not a code issue`, "warning"); - process.stderr.write(`verification-gate: all ${failedChecks.length} failure(s) are infra errors — treating as transient, no retry\n`); - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - return "continue"; - } - - if (result.discoverySource === "package-json") { - // Auto-discovered checks from package.json may fail on pre-existing errors - // that the current task didn't introduce. Don't trigger the retry loop — - // log a warning and let the task proceed (#1186). - process.stderr.write( - `verification-gate: auto-discovered checks failed (source: package-json) — treating as advisory, not blocking\n`, - ); - ctx.ui.notify( - `Verification: auto-discovered checks failed (pre-existing errors likely). Continuing without retry.`, - "warning", - ); - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - return "continue"; - } - - if (autoFixEnabled && attempt + 1 <= maxRetries) { + } else if (autoFixEnabled && attempt + 1 <= maxRetries) { const nextAttempt = attempt + 1; s.verificationRetryCount.set(s.currentUnit.id, nextAttempt); s.pendingVerificationRetry = { @@ -215,17 +205,11 @@ export async function runPostUnitVerification( failureContext: formatFailureContext(result), attempt: nextAttempt, }; - ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning"); - s.completedKeySet.delete(completionKey); - removePersistedKey(s.basePath, completionKey); - // Dispatch retry immediately - try { - await dispatchNextUnit(ctx, pi); - } catch (retryDispatchErr) { - const msg = getErrorMessage(retryDispatchErr); - ctx.ui.notify(`Verification retry dispatch error: ${msg}`, "error"); - startDispatchGapWatchdog(ctx, pi); - } + ctx.ui.notify( + `Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, + "warning", + ); + // Return "retry" — the autoLoop while loop will re-iterate with the retry context return "retry"; } else { // Gate failed, retries exhausted @@ -241,7 +225,9 @@ export async function runPostUnitVerification( } } catch (err) { // Gate errors are non-fatal - process.stderr.write(`verification-gate: error — ${(err as Error).message}\n`); + process.stderr.write( + `verification-gate: error — ${(err as Error).message}\n`, + ); return "continue"; } } diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts new file mode 100644 index 000000000..76ef7c065 --- /dev/null +++ b/src/resources/extensions/gsd/auto-worktree-sync.ts @@ -0,0 +1,204 @@ +/** + * Worktree ↔ project root state synchronization for auto-mode. + * + * When auto-mode runs inside a worktree, dispatch-critical state files + * (.gsd/ metadata) diverge between the worktree (where work happens) + * and the project root (where startAutoMode reads initial state on restart). + * Without syncing, restarting auto-mode reads stale state from the project + * root and re-dispatches already-completed units. + * + * Also contains resource staleness detection and stale worktree escape. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + cpSync, + unlinkSync, + readdirSync, +} from "node:fs"; +import { join, sep as pathSep } from "node:path"; +import { homedir } from "node:os"; +import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; + +// ─── Project Root → Worktree Sync ───────────────────────────────────────── + +/** + * Sync milestone artifacts from project root INTO worktree before deriveState. + * Covers the case where the LLM wrote artifacts to the main repo filesystem + * (e.g. via absolute paths) but the worktree has stale data. Also deletes + * gsd.db in the worktree so it rebuilds from fresh disk state (#853). + * Non-fatal — sync failure should never block dispatch. + */ +export function syncProjectRootToWorktree( + projectRoot: string, + worktreePath: string, + milestoneId: string | null, +): void { + if (!worktreePath || !projectRoot || worktreePath === projectRoot) return; + if (!milestoneId) return; + + const prGsd = join(projectRoot, ".gsd"); + const wtGsd = join(worktreePath, ".gsd"); + + // Copy milestone directory from project root to worktree if the project root + // has newer artifacts (e.g. slices that don't exist in the worktree yet) + safeCopyRecursive( + join(prGsd, "milestones", milestoneId), + join(wtGsd, "milestones", milestoneId), + ); + + // Delete worktree gsd.db so it rebuilds from the freshly synced files. + // Stale DB rows are the root cause of the infinite skip loop (#853). + try { + const wtDb = join(wtGsd, "gsd.db"); + if (existsSync(wtDb)) { + unlinkSync(wtDb); + } + } catch { + /* non-fatal */ + } +} + +// ─── Worktree → Project Root Sync ───────────────────────────────────────── + +/** + * Sync dispatch-critical .gsd/ state files from worktree to project root. + * Only runs when inside an auto-worktree (worktreePath differs from projectRoot). + * Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries). + * Non-fatal — sync failure should never block dispatch. + */ +export function syncStateToProjectRoot( + worktreePath: string, + projectRoot: string, + milestoneId: string | null, +): void { + if (!worktreePath || !projectRoot || worktreePath === projectRoot) return; + if (!milestoneId) return; + + const wtGsd = join(worktreePath, ".gsd"); + const prGsd = join(projectRoot, ".gsd"); + + // 1. STATE.md — the quick-glance status used by initial deriveState() + safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true }); + + // 2. Milestone directory — ROADMAP, slice PLANs, task summaries + // Copy the entire milestone .gsd subtree so deriveState reads current checkboxes + safeCopyRecursive( + join(wtGsd, "milestones", milestoneId), + join(prGsd, "milestones", milestoneId), + { force: true }, + ); + + // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords(). + // Without this, a crash during a unit leaves the runtime record only in the + // worktree. If the next session resolves basePath before worktree re-entry, + // selfHeal can't find or clear the stale record (#769). + safeCopyRecursive( + join(wtGsd, "runtime", "units"), + join(prGsd, "runtime", "units"), + { force: true }, + ); +} + +// ─── Resource Staleness ─────────────────────────────────────────────────── + +/** + * Read the resource version (semver) from the managed-resources manifest. + * Uses gsdVersion instead of syncedAt so that launching a second session + * doesn't falsely trigger staleness (#804). + */ +export function readResourceVersion(): string | null { + const agentDir = + process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent"); + const manifestPath = join(agentDir, "managed-resources.json"); + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + return typeof manifest?.gsdVersion === "string" + ? manifest.gsdVersion + : null; + } catch { + return null; + } +} + +/** + * Check if managed resources have been updated since session start. + * Returns a warning message if stale, null otherwise. + */ +export function checkResourcesStale( + versionOnStart: string | null, +): string | null { + if (versionOnStart === null) return null; + const current = readResourceVersion(); + if (current === null) return null; + if (current !== versionOnStart) { + return "GSD resources were updated since this session started. Restart gsd to load the new code."; + } + return null; +} + +// ─── Stale Worktree Escape ──────────────────────────────────────────────── + +/** + * Detect and escape a stale worktree cwd (#608). + * + * After milestone completion + merge, the worktree directory is removed but + * the process cwd may still point inside `.gsd/worktrees//`. + * When a new session starts, `process.cwd()` is passed as `base` to startAuto + * and all subsequent writes land in the wrong directory. This function detects + * that scenario and chdir back to the project root. + * + * Returns the corrected base path. + */ +export function escapeStaleWorktree(base: string): string { + const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; + const idx = base.indexOf(marker); + if (idx === -1) return base; + + // base is inside .gsd/worktrees/ — extract the project root + const projectRoot = base.slice(0, idx); + try { + process.chdir(projectRoot); + } catch { + // If chdir fails, return the original — caller will handle errors downstream + return base; + } + return projectRoot; +} + +/** + * Clean stale runtime unit files for completed milestones. + * + * After restart, stale runtime/units/*.json from prior milestones can + * cause deriveState to resume the wrong milestone (#887). Removes files + * for milestones that have a SUMMARY (fully complete). + */ +export function cleanStaleRuntimeUnits( + gsdRootPath: string, + hasMilestoneSummary: (mid: string) => boolean, +): number { + const runtimeUnitsDir = join(gsdRootPath, "runtime", "units"); + if (!existsSync(runtimeUnitsDir)) return 0; + + let cleaned = 0; + try { + for (const file of readdirSync(runtimeUnitsDir)) { + if (!file.endsWith(".json")) continue; + const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); + if (!midMatch) continue; + if (hasMilestoneSummary(midMatch[1])) { + try { + unlinkSync(join(runtimeUnitsDir, file)); + cleaned++; + } catch { + /* non-fatal */ + } + } + } + } catch { + /* non-fatal */ + } + return cleaned; +} diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 375045f69..d49e3c7d5 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -6,24 +6,40 @@ * manages create, enter, detect, and teardown for auto-mode worktrees. */ -import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, mkdirSync, lstatSync as lstatSyncFn } from "node:fs"; -import { isAbsolute, join, sep } from "node:path"; +import { + existsSync, + cpSync, + readFileSync, + readdirSync, + mkdirSync, + realpathSync, + unlinkSync, + lstatSync as lstatSyncFn, +} from "node:fs"; +import { isAbsolute, join } from "node:path"; import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js"; +import { + copyWorktreeDb, + reconcileWorktreeDb, + isDbAvailable, +} from "./gsd-db.js"; +import { atomicWriteSync } from "./atomic-write.js"; import { execSync, execFileSync } from "node:child_process"; +import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; +import { gsdRoot } from "./paths.js"; import { createWorktree, removeWorktree, worktreePath, } from "./worktree-manager.js"; -import { detectWorktreeName, resolveGitHeadPath, nudgeGitBranchCache } from "./worktree.js"; -import { ensureGsdSymlink } from "./repo-identity.js"; import { - MergeConflictError, - readIntegrationBranch, -} from "./git-service.js"; + detectWorktreeName, + resolveGitHeadPath, + nudgeGitBranchCache, +} from "./worktree.js"; +import { MergeConflictError, readIntegrationBranch } from "./git-service.js"; import { parseRoadmap } from "./files.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { gsdRoot } from "./paths.js"; import { nativeGetCurrentBranch, nativeWorkingTreeStatus, @@ -38,13 +54,28 @@ import { nativeBranchDelete, nativeBranchExists, } from "./native-git-bridge.js"; -import { getErrorMessage } from "./error-utils.js"; // ─── Module State ────────────────────────────────────────────────────────── /** Original project root before chdir into auto-worktree. */ let originalBase: string | null = null; +function clearProjectRootStateFiles(basePath: string, milestoneId: string): void { + const gsdDir = gsdRoot(basePath); + const transientFiles = [ + join(gsdDir, "STATE.md"), + join(gsdDir, "auto.lock"), + join(gsdDir, "milestones", milestoneId, `${milestoneId}-META.json`), + ]; + + for (const file of transientFiles) { + try { + unlinkSync(file); + } catch { + /* non-fatal — file may not exist */ + } + } +} // ─── Worktree ↔ Main Repo Sync (#1311) ────────────────────────────────────── /** @@ -61,7 +92,10 @@ let originalBase: string | null = null; * Only adds missing content — never overwrites existing files in the worktree * (the worktree's execution state is authoritative for in-progress work). */ -export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } { +export function syncGsdStateToWorktree( + mainBasePath: string, + worktreePath_: string, +): { synced: string[] } { const mainGsd = gsdRoot(mainBasePath); const wtGsd = gsdRoot(worktreePath_); const synced: string[] = []; @@ -78,7 +112,13 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced }; // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE) - const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"]; + const rootFiles = [ + "DECISIONS.md", + "REQUIREMENTS.md", + "PROJECT.md", + "KNOWLEDGE.md", + "OVERRIDES.md", + ]; for (const f of rootFiles) { const src = join(mainGsd, f); const dst = join(wtGsd, f); @@ -86,7 +126,9 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri try { cpSync(src, dst); synced.push(f); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } @@ -96,9 +138,11 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri if (existsSync(mainMilestonesDir)) { try { mkdirSync(wtMilestonesDir, { recursive: true }); - const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name)) - .map(d => d.name); + const mainMilestones = readdirSync(mainMilestonesDir, { + withFileTypes: true, + }) + .filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name)) + .map((d) => d.name); for (const mid of mainMilestones) { const srcDir = join(mainMilestonesDir, mid); @@ -109,12 +153,16 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri try { cpSync(srcDir, dstDir, { recursive: true }); synced.push(`milestones/${mid}/`); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } else { // Milestone directory exists but may be missing files (stale snapshot). // Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.) try { - const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json")); + const srcFiles = readdirSync(srcDir).filter( + (f) => f.endsWith(".md") || f.endsWith(".json"), + ); for (const f of srcFiles) { const srcFile = join(srcDir, f); const dstFile = join(dstDir, f); @@ -125,7 +173,9 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri cpSync(srcFile, dstFile); synced.push(`milestones/${mid}/${f}`); } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } @@ -136,12 +186,16 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri try { cpSync(srcSlicesDir, dstSlicesDir, { recursive: true }); synced.push(`milestones/${mid}/slices/`); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) { // Both exist — sync missing slice directories - const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); + const srcSlices = readdirSync(srcSlicesDir, { + withFileTypes: true, + }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); for (const sid of srcSlices) { const srcSlice = join(srcSlicesDir, sid); const dstSlice = join(dstSlicesDir, sid); @@ -149,14 +203,20 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri try { cpSync(srcSlice, dstSlice, { recursive: true }); synced.push(`milestones/${mid}/slices/${sid}/`); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } return { synced }; @@ -170,7 +230,11 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri * Only syncs .gsd/milestones/ content — root-level files (DECISIONS, REQUIREMENTS, etc.) * are handled by the merge itself. */ -export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string, milestoneId: string): { synced: string[] } { +export function syncWorktreeStateBack( + mainBasePath: string, + worktreePath: string, + milestoneId: string, +): { synced: string[] } { const mainGsd = gsdRoot(mainBasePath); const wtGsd = gsdRoot(worktreePath); const synced: string[] = []; @@ -199,40 +263,53 @@ export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string try { cpSync(src, dst, { force: true }); synced.push(`milestones/${milestoneId}/${entry.name}`); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // Sync slice-level files (summaries, UATs) const wtSlicesDir = join(wtMilestoneDir, "slices"); const mainSlicesDir = join(mainMilestoneDir, "slices"); if (existsSync(wtSlicesDir)) { try { - for (const sliceEntry of readdirSync(wtSlicesDir, { withFileTypes: true })) { + for (const sliceEntry of readdirSync(wtSlicesDir, { + withFileTypes: true, + })) { if (!sliceEntry.isDirectory()) continue; const sid = sliceEntry.name; const wtSliceDir = join(wtSlicesDir, sid); const mainSliceDir = join(mainSlicesDir, sid); mkdirSync(mainSliceDir, { recursive: true }); - for (const fileEntry of readdirSync(wtSliceDir, { withFileTypes: true })) { + for (const fileEntry of readdirSync(wtSliceDir, { + withFileTypes: true, + })) { if (fileEntry.isFile() && fileEntry.name.endsWith(".md")) { const src = join(wtSliceDir, fileEntry.name); const dst = join(mainSliceDir, fileEntry.name); try { cpSync(src, dst, { force: true }); - synced.push(`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`); - } catch { /* non-fatal */ } + synced.push( + `milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`, + ); + } catch { + /* non-fatal */ + } } } } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } return { synced }; } - // ─── Worktree Post-Create Hook (#597) ──────────────────────────────────────── /** @@ -243,7 +320,11 @@ export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string * Reads the hook path from git.worktree_post_create in preferences. * Pass hookPath directly to bypass preference loading (useful for testing). */ -export function runWorktreePostCreateHook(sourceDir: string, worktreeDir: string, hookPath?: string): string | null { +export function runWorktreePostCreateHook( + sourceDir: string, + worktreeDir: string, + hookPath?: string, +): string | null { if (hookPath === undefined) { const prefs = loadEffectiveGSDPreferences()?.preferences?.git; hookPath = prefs?.worktree_post_create; @@ -270,7 +351,7 @@ export function runWorktreePostCreateHook(sourceDir: string, worktreeDir: string }); return null; } catch (err) { - const msg = getErrorMessage(err); + const msg = err instanceof Error ? err.message : String(err); return `Worktree post-create hook failed: ${msg}`; } } @@ -291,7 +372,110 @@ export function autoWorktreeBranch(milestoneId: string): string { * to prevent split-brain. */ -export function createAutoWorktree(basePath: string, milestoneId: string): string { +/** + * Forward-merge plan checkbox state from the project root into a freshly + * re-attached worktree (#778). + * + * When auto-mode stops via crash (not graceful stop), the milestone branch + * HEAD may be behind the filesystem state at the project root because + * syncStateToProjectRoot() runs after every task completion but the final + * git commit may not have happened before the crash. On restart the worktree + * is re-attached to the branch HEAD, which has [ ] for the crashed task, + * causing verifyExpectedArtifact() to fail and triggering an infinite + * dispatch/skip loop. + * + * Fix: after re-attaching, read every *.md plan file in the milestone + * directory at the project root and apply any [x] checkbox states that are + * ahead of the worktree version (forward-only: never downgrade [x] → [ ]). + * + * This is safe because syncStateToProjectRoot() is the authoritative source + * of post-task state at the project root — it writes the same [x] the LLM + * produced, then the auto-commit follows. If the commit never happened, the + * filesystem copy is still valid and correct. + */ +function reconcilePlanCheckboxes( + projectRoot: string, + wtPath: string, + milestoneId: string, +): void { + const srcMilestone = join(projectRoot, ".gsd", "milestones", milestoneId); + const dstMilestone = join(wtPath, ".gsd", "milestones", milestoneId); + if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return; + + // Walk all markdown files in the milestone directory (plans, summaries, etc.) + function walkMd(dir: string): string[] { + const results: string[] = []; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkMd(full)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(full); + } + } + } catch { + /* non-fatal */ + } + return results; + } + + for (const srcFile of walkMd(srcMilestone)) { + const rel = srcFile.slice(srcMilestone.length); + const dstFile = dstMilestone + rel; + if (!existsSync(dstFile)) continue; // only reconcile existing files + + let srcContent: string; + let dstContent: string; + try { + srcContent = readFileSync(srcFile, "utf-8"); + dstContent = readFileSync(dstFile, "utf-8"); + } catch { + continue; + } + + if (srcContent === dstContent) continue; + + // Extract all checked task IDs from the source (project root) + // Pattern: - [x] **T: or - [x] **S: (case-insensitive x) + const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm; + const srcChecked = new Set(); + for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]); + + if (srcChecked.size === 0) continue; + + // Forward-apply: replace [ ] → [x] for any IDs that are checked in src + let updated = dstContent; + let changed = false; + for (const id of srcChecked) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const uncheckedRe = new RegExp( + `^(- )\\[ \\]( \\*\\*${escapedId}:)`, + "gm", + ); + if (uncheckedRe.test(updated)) { + updated = updated.replace( + new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"), + "$1[x]$2", + ); + changed = true; + } + } + + if (changed) { + try { + atomicWriteSync(dstFile, updated, "utf-8"); + } catch { + /* non-fatal */ + } + } + } +} + +export function createAutoWorktree( + basePath: string, + milestoneId: string, +): string { const branch = autoWorktreeBranch(milestoneId); // Check if the milestone branch already exists — it survives auto-mode @@ -303,21 +487,46 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin let info: { name: string; path: string; branch: string; exists: boolean }; if (branchExists) { // Re-attach worktree to the existing milestone branch (preserving commits) - info = createWorktree(basePath, milestoneId, { branch, reuseExistingBranch: true }); + info = createWorktree(basePath, milestoneId, { + branch, + reuseExistingBranch: true, + }); } else { // Fresh start — create branch from integration branch - const integrationBranch = readIntegrationBranch(basePath, milestoneId) ?? undefined; - info = createWorktree(basePath, milestoneId, { branch, startPoint: integrationBranch }); + const integrationBranch = + readIntegrationBranch(basePath, milestoneId) ?? undefined; + info = createWorktree(basePath, milestoneId, { + branch, + startPoint: integrationBranch, + }); } - // Ensure worktree shares external state via symlink - ensureGsdSymlink(info.path); - - // Sync .gsd/ state from main repo into the worktree (#1311). - // Even with the symlink, the worktree may have stale git-tracked files - // if .gsd/ is not gitignored. And on fresh create, the milestone files - // created on main since the branch point won't be in the worktree. - syncGsdStateToWorktree(basePath, info.path); + // Copy .gsd/ planning artifacts from the source repo into the new worktree. + // Worktrees are fresh git checkouts — untracked files don't carry over. + // Planning artifacts may be untracked if the project's .gitignore had a + // blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops + // on plan-slice because the plan file doesn't exist in the worktree. + // + // IMPORTANT: Skip when re-attaching to an existing branch (#759). + // The branch checkout already has committed artifacts with correct state + // (e.g. [x] for completed slices). Copying from the project root would + // overwrite them with stale data ([ ] checkboxes) because the root is + // not always fully synced. + if (!branchExists) { + copyPlanningArtifacts(basePath, info.path); + } else { + // Re-attaching to an existing branch: forward-merge any plan checkpoint + // state from the project root into the worktree (#778). + // + // If auto-mode stopped via crash, the milestone branch HEAD may lag behind + // the project root filesystem because syncStateToProjectRoot() ran after + // task completion but the auto-commit never fired. On restart the worktree + // is re-created from the branch HEAD (which has [ ] for the crashed task), + // causing verifyExpectedArtifact() to return false → stale-key eviction → + // infinite dispatch/skip loop. Reconciling here ensures the worktree sees + // the same [x] state that syncStateToProjectRoot() wrote to the root. + reconcilePlanCheckboxes(basePath, info.path, milestoneId); + } // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets const hookError = runWorktreePostCreateHook(basePath, info.path); @@ -336,7 +545,7 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin // Don't store originalBase -- caller can retry or clean up. throw new GSDError( GSD_IO_ERROR, - `Auto-worktree created at ${info.path} but chdir failed: ${getErrorMessage(err)}`, + `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`, ); } @@ -344,6 +553,49 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin return info.path; } +/** + * Copy .gsd/ planning artifacts from source repo to a new worktree. + * Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md, + * STATE.md, KNOWLEDGE.md, and OVERRIDES.md. + * Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir. + * Best-effort — failures are non-fatal since auto-mode can recreate artifacts. + */ +function copyPlanningArtifacts(srcBase: string, wtPath: string): void { + const srcGsd = join(srcBase, ".gsd"); + const dstGsd = join(wtPath, ".gsd"); + if (!existsSync(srcGsd)) return; + + // Copy milestones/ directory (planning files, roadmaps, plans, research) + safeCopyRecursive(join(srcGsd, "milestones"), join(dstGsd, "milestones"), { + force: true, + filter: (src) => !src.endsWith("-META.json"), + }); + + // Copy top-level planning files + for (const file of [ + "DECISIONS.md", + "REQUIREMENTS.md", + "PROJECT.md", + "QUEUE.md", + "STATE.md", + "KNOWLEDGE.md", + "OVERRIDES.md", + ]) { + safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true }); + } + + // Copy gsd.db if present in source + const srcDb = join(srcGsd, "gsd.db"); + const destDb = join(dstGsd, "gsd.db"); + if (existsSync(srcDb)) { + try { + copyWorktreeDb(srcDb, destDb); + } catch { + /* non-fatal */ + } + } +} + /** * Teardown an auto-worktree: chdir back to original base, then remove * the worktree and its branch. @@ -363,12 +615,15 @@ export function teardownAutoWorktree( } catch (err) { throw new GSDError( GSD_IO_ERROR, - `Failed to chdir back to ${originalBasePath} during teardown: ${getErrorMessage(err)}`, + `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`, ); } nudgeGitBranchCache(previousCwd); - removeWorktree(originalBasePath, milestoneId, { branch, deleteBranch: !preserveBranch }); + removeWorktree(originalBasePath, milestoneId, { + branch, + deleteBranch: !preserveBranch, + }); } /** @@ -376,36 +631,13 @@ export function teardownAutoWorktree( * Checks both module state and git branch prefix. */ export function isInAutoWorktree(basePath: string): boolean { + if (!originalBase) return false; const cwd = process.cwd(); - - // Primary check: use originalBase if available (fast path) - if (originalBase) { - const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath; - const wtDir = join(gsdRoot(resolvedBase), "worktrees"); - if (!cwd.startsWith(wtDir)) return false; - const branch = nativeGetCurrentBranch(cwd); - return branch.startsWith("milestone/"); - } - - // Fallback: infer worktree status structurally when originalBase is null - // (happens after session restart where module-level state is lost, #1120). - // Check if cwd is inside a .gsd/worktrees/ directory and has a .git file - // (worktree marker) pointing to the main repo. - const worktreeMarker = join(cwd, ".git"); - if (!existsSync(worktreeMarker)) return false; - try { - const stat = statSync(worktreeMarker); - if (stat.isDirectory()) return false; // Main repo has .git dir, not file - // Worktrees have a .git file with "gitdir: ..." pointing to the main repo - const gitContent = readFileSync(worktreeMarker, "utf-8").trim(); - if (!gitContent.startsWith("gitdir:")) return false; - // Verify we're inside a GSD-managed worktree - if (!detectWorktreeName(cwd)) return false; - const branch = nativeGetCurrentBranch(cwd); - return branch.startsWith("milestone/"); - } catch { - return false; - } + const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath; + const wtDir = join(resolvedBase, ".gsd", "worktrees"); + if (!cwd.startsWith(wtDir)) return false; + const branch = nativeGetCurrentBranch(cwd); + return branch.startsWith("milestone/"); } /** @@ -416,7 +648,10 @@ export function isInAutoWorktree(basePath: string): boolean { * gitdir: pointer) rather than just a stray directory. This prevents * mis-detection of leftover directories as active worktrees (#695). */ -export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null { +export function getAutoWorktreePath( + basePath: string, + milestoneId: string, +): string | null { const p = worktreePath(basePath, milestoneId); if (!existsSync(p)) return null; @@ -440,39 +675,42 @@ export function getAutoWorktreePath(basePath: string, milestoneId: string): stri * * Atomic: chdir + originalBase update in same try block. */ -export function enterAutoWorktree(basePath: string, milestoneId: string): string { +export function enterAutoWorktree( + basePath: string, + milestoneId: string, +): string { const p = worktreePath(basePath, milestoneId); if (!existsSync(p)) { - throw new GSDError(GSD_IO_ERROR, `Auto-worktree for ${milestoneId} does not exist at ${p}`); + throw new GSDError( + GSD_IO_ERROR, + `Auto-worktree for ${milestoneId} does not exist at ${p}`, + ); } // Validate this is a real git worktree, not a stray directory (#695) const gitPath = join(p, ".git"); if (!existsSync(gitPath)) { - throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} exists but is not a git worktree (no .git)`); + throw new GSDError( + GSD_GIT_ERROR, + `Auto-worktree path ${p} exists but is not a git worktree (no .git)`, + ); } try { const content = readFileSync(gitPath, "utf8").trim(); if (!content.startsWith("gitdir: ")) { - throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`); + throw new GSDError( + GSD_GIT_ERROR, + `Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`, + ); } } catch (err) { if (err instanceof Error && err.message.includes("worktree")) throw err; - throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`); + throw new GSDError( + GSD_IO_ERROR, + `Auto-worktree path ${p} exists but .git is unreadable`, + ); } - // Ensure worktree shares external state via symlink (#1311). - // On resume (enterAutoWorktree), the symlink may be missing if it was - // created before ensureGsdSymlink existed, or the .gsd/ directory may be - // a stale git-tracked copy instead of a symlink. Refreshing here ensures - // the worktree sees the same milestone state as the main repo. - ensureGsdSymlink(p); - - // Sync .gsd/ state from main repo into worktree (#1311). - // Covers the case where .gsd/ is a real directory (not symlinked) and - // milestones were created on main after the worktree was last used. - syncGsdStateToWorktree(basePath, p); - const previousCwd = process.cwd(); try { @@ -481,7 +719,7 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string } catch (err) { throw new GSDError( GSD_IO_ERROR, - `Failed to enter auto-worktree at ${p}: ${getErrorMessage(err)}`, + `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`, ); } @@ -504,8 +742,10 @@ export function getActiveAutoWorktreeContext(): { } | null { if (!originalBase) return null; const cwd = process.cwd(); - const resolvedBase = existsSync(originalBase) ? realpathSync(originalBase) : originalBase; - const wtDir = join(gsdRoot(resolvedBase), "worktrees"); + const resolvedBase = existsSync(originalBase) + ? realpathSync(originalBase) + : originalBase; + const wtDir = join(resolvedBase, ".gsd", "worktrees"); if (!cwd.startsWith(wtDir)) return null; const worktreeName = detectWorktreeName(cwd); if (!worktreeName) return null; @@ -529,7 +769,10 @@ function autoCommitDirtyState(cwd: string): boolean { const status = nativeWorkingTreeStatus(cwd); if (!status) return false; nativeAddAll(cwd); - const result = nativeCommit(cwd, "chore: auto-commit before milestone merge"); + const result = nativeCommit( + cwd, + "chore: auto-commit before milestone merge", + ); return result !== null; } catch { return false; @@ -565,59 +808,53 @@ export function mergeMilestoneToMain( // 1. Auto-commit dirty state in worktree before leaving autoCommitDirtyState(worktreeCwd); + // Reconcile worktree DB into main DB before leaving worktree context + if (isDbAvailable()) { + try { + const worktreeDbPath = join(worktreeCwd, ".gsd", "gsd.db"); + const mainDbPath = join(originalBasePath_, ".gsd", "gsd.db"); + reconcileWorktreeDb(mainDbPath, worktreeDbPath); + } catch { + /* non-fatal */ + } + } + // 2. Parse roadmap for slice listing const roadmap = parseRoadmap(roadmapContent); - const completedSlices = roadmap.slices.filter(s => s.done); + const completedSlices = roadmap.slices.filter((s) => s.done); // 3. chdir to original base const previousCwd = process.cwd(); process.chdir(originalBasePath_); - // 3a. Auto-commit any dirty state in the project root. Without this, the - // squash merge can fail with "Your local changes would be overwritten" (#1127). - autoCommitDirtyState(originalBasePath_); - - // 3b. Remove untracked .gsd/ runtime files that syncStateToProjectRoot copied. - // Only clean specific runtime files — NEVER touch milestones/, decisions, or - // other planning artifacts that represent user work (#1250). - const runtimeFilesToClean = ["STATE.md", "completed-units.json", "auto.lock", "gsd.db"]; - for (const f of runtimeFilesToClean) { - const p = join(originalBasePath_, ".gsd", f); - try { if (existsSync(p)) unlinkSync(p); } catch { /* non-fatal */ } - } - try { - const runtimeDir = join(originalBasePath_, ".gsd", "runtime"); - if (existsSync(runtimeDir)) rmSync(runtimeDir, { recursive: true, force: true }); - } catch { /* non-fatal */ } - // 4. Resolve integration branch — prefer milestone metadata, fall back to preferences / "main" const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; - const integrationBranch = readIntegrationBranch(originalBasePath_, milestoneId); + const integrationBranch = readIntegrationBranch( + originalBasePath_, + milestoneId, + ); const mainBranch = integrationBranch ?? prefs.main_branch ?? "main"; + // Remove transient project-root state files before any branch or merge + // operation. Untracked milestone metadata can otherwise block squash merges. + clearProjectRootStateFiles(originalBasePath_, milestoneId); + // 5. Checkout integration branch (skip if already current — avoids git error // when main is already checked out in the project-root worktree, #757) const currentBranchAtBase = nativeGetCurrentBranch(originalBasePath_); if (currentBranchAtBase !== mainBranch) { - // Remove untracked .gsd/ state files that may conflict with the branch - // being checked out. These are regenerated by doctor/rebuildState and - // are not meaningful in the main working tree — the worktree had the - // real state. Without this, `git checkout main` fails with - // "Your local changes would be overwritten" (#827). - const gsdStateFiles = ["STATE.md", "completed-units.json", "auto.lock"]; - for (const f of gsdStateFiles) { - const p = join(gsdRoot(originalBasePath_), f); - try { unlinkSync(p); } catch { /* non-fatal — file may not exist */ } - } nativeCheckoutBranch(originalBasePath_, mainBranch); } // 6. Build rich commit message - const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId; + const milestoneTitle = + roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId; const subject = `feat(${milestoneId}): ${milestoneTitle}`; let body = ""; if (completedSlices.length > 0) { - const sliceLines = completedSlices.map(s => `- ${s.id}: ${s.title}`).join("\n"); + const sliceLines = completedSlices + .map((s) => `- ${s.id}: ${s.title}`) + .join("\n"); body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`; } const commitMessage = subject + body; @@ -627,17 +864,20 @@ export function mergeMilestoneToMain( if (!mergeResult.success) { // Check for conflicts — use merge result first, fall back to nativeConflictFiles - const conflictedFiles = mergeResult.conflicts.length > 0 - ? mergeResult.conflicts - : nativeConflictFiles(originalBasePath_); + const conflictedFiles = + mergeResult.conflicts.length > 0 + ? mergeResult.conflicts + : nativeConflictFiles(originalBasePath_); if (conflictedFiles.length > 0) { // Separate .gsd/ state file conflicts from real code conflicts. - // GSD state files (STATE.md, completed-units.json, auto.lock, etc.) + // GSD state files (STATE.md, auto.lock, etc.) // diverge between branches during normal operation — always prefer the // milestone branch version since it has the latest execution state. - const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/")); - const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/")); + const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/")); + const codeConflicts = conflictedFiles.filter( + (f) => !f.startsWith(".gsd/"), + ); // Auto-resolve .gsd/ conflicts by accepting the milestone branch version if (gsdConflicts.length > 0) { @@ -655,7 +895,12 @@ export function mergeMilestoneToMain( // If there are still non-.gsd conflicts, escalate if (codeConflicts.length > 0) { - throw new MergeConflictError(codeConflicts, "squash", milestoneBranch, mainBranch); + throw new MergeConflictError( + codeConflicts, + "squash", + milestoneBranch, + mainBranch, + ); } } // No conflicts detected — possibly "already up to date", fall through to commit @@ -710,7 +955,10 @@ export function mergeMilestoneToMain( // 10. Remove worktree directory first (must happen before branch deletion) try { - removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false }); + removeWorktree(originalBasePath_, milestoneId, { + branch: null as unknown as string, + deleteBranch: false, + }); } catch { // Best-effort -- worktree dir may already be gone } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 27d5611f7..0d74f8448 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,20 +18,33 @@ import type { import { deriveState } from "./state.js"; import type { GSDState } from "./types.js"; -import { loadFile, getManifestStatus, resolveAllOverrides, parsePlan, parseSummary } from "./files.js"; -import { loadPrompt } from "./prompt-loader.js"; -import { runVerificationGate, formatFailureContext, captureRuntimeErrors, runDependencyAudit } from "./verification-gate.js"; -import { writeVerificationJSON } from "./verification-evidence.js"; +import { getManifestStatus } from "./files.js"; +export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { - gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath, - resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFile, - milestonesDir, buildTaskFileName, + gsdRoot, + resolveMilestoneFile, + resolveSliceFile, + resolveSlicePath, + resolveMilestonePath, + resolveDir, + resolveTasksDir, + resolveTaskFile, + milestonesDir, + buildTaskFileName, } from "./paths.js"; import { invalidateAllCaches } from "./cache.js"; -import { saveActivityLog, clearActivityLogState } from "./activity-log.js"; -import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js"; -import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js"; +import { clearActivityLogState } from "./activity-log.js"; +import { + synthesizeCrashRecovery, + getDeepDiagnostic, +} from "./session-forensics.js"; +import { + writeLock, + clearLock, + readCrashLock, + isLockProcessAlive, +} from "./crash-recovery.js"; import { acquireSessionLock, validateSessionLock, @@ -44,7 +57,11 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "./unit-runtime.js"; -import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js"; +import { + resolveAutoSupervisorConfig, + loadEffectiveGSDPreferences, + getIsolationMode, +} from "./preferences.js"; import { sendDesktopNotification } from "./notifications.js"; import type { GSDPreferences } from "./preferences.js"; import { @@ -69,11 +86,13 @@ import { closeoutUnit } from "./auto-unit-closeout.js"; import { recoverTimedOutUnit } from "./auto-timeout-recovery.js"; import { selectAndApplyModel } from "./auto-model-selection.js"; import { + syncProjectRootToWorktree, + syncStateToProjectRoot, readResourceVersion, checkResourcesStale, escapeStaleWorktree, -} from "./resource-version.js"; -import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js"; +} from "./auto-worktree-sync.js"; +import { resetRoutingHistory, recordOutcome } from "./routing-history.js"; import { checkPostUnitHooks, getActiveHook, @@ -85,8 +104,7 @@ import { restoreHookState, clearPersistedHookState, } from "./post-unit-hooks.js"; -import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; -import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js"; +import { runGSDDoctor, rebuildState } from "./doctor.js"; import { preDispatchHealthGate, recordHealthSnapshot, @@ -95,20 +113,22 @@ import { formatHealthSummary, getConsecutiveErrorUnits, } from "./doctor-proactive.js"; -import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js"; -import { captureAvailableSkills, getAndClearSkills, resetSkillTelemetry } from "./skill-telemetry.js"; +import { clearSkillSnapshot } from "./skill-discovery.js"; import { - initMetrics, resetMetrics, getLedger, - getProjectTotals, formatCost, formatTokenCount, + captureAvailableSkills, + resetSkillTelemetry, +} from "./skill-telemetry.js"; +import { + initMetrics, + resetMetrics, + getLedger, + getProjectTotals, + formatCost, + formatTokenCount, } from "./metrics.js"; -import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js"; -import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js"; import { join } from "node:path"; -import { sep as pathSep } from "node:path"; -import { parseUnitId } from "./unit-id.js"; -import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync } from "node:fs"; import { atomicWriteSync } from "./atomic-write.js"; -import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js"; import { autoCommitCurrentBranch, captureIntegrationBranch, @@ -119,9 +139,8 @@ import { parseSliceBranch, setActiveMilestoneId, } from "./worktree.js"; -import { createGitService, type TaskCommitContext } from "./git-service.js"; +import { GitServiceImpl } from "./git-service.js"; import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js"; -import { formatGitError } from "./git-self-heal.js"; import { createAutoWorktree, enterAutoWorktree, @@ -134,24 +153,18 @@ import { syncWorktreeStateBack, } from "./auto-worktree.js"; import { pruneQueueOrder } from "./queue-order.js"; -import { consumeSignal } from "./session-status-io.js"; -import { showNextAction } from "../shared/mod.js"; -import { debugLog, debugTime, debugCount, debugPeak, enableDebug, isDebugEnabled, writeDebugSummary, getDebugLogPath } from "./debug-logger.js"; + +import { debugLog, isDebugEnabled, writeDebugSummary } from "./debug-logger.js"; import { resolveExpectedArtifactPath, verifyExpectedArtifact, writeBlockerPlaceholder, diagnoseExpectedArtifact, skipExecuteTask, - completedKeysPath, - persistCompletedKey, - removePersistedKey, - loadPersistedKeys, - selfHealRuntimeRecords, buildLoopRemediationSteps, reconcileMergeState, } from "./auto-recovery.js"; -import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js"; +import { resolveDispatch } from "./auto-dispatch.js"; import { type AutoDashboardData, updateProgressWidget as _updateProgressWidget, @@ -170,28 +183,52 @@ import { detectWorkingTreeActivity, } from "./auto-supervisor.js"; import { isDbAvailable } from "./gsd-db.js"; -import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js"; +import { countPendingCaptures } from "./captures.js"; // ── Extracted modules ────────────────────────────────────────────────────── -import { startUnitSupervision, type SupervisionContext } from "./auto-timers.js"; -import { checkIdempotency, type IdempotencyContext } from "./auto-idempotency.js"; -import { checkStuckAndRecover, type StuckContext } from "./auto-stuck-detection.js"; -import { runPostUnitVerification, type VerificationContext } from "./auto-verification.js"; -import { postUnitPreVerification, postUnitPostVerification, type PostUnitContext } from "./auto-post-unit.js"; +import { startUnitSupervision } from "./auto-timers.js"; +import { runPostUnitVerification } from "./auto-verification.js"; +import { + postUnitPreVerification, + postUnitPostVerification, +} from "./auto-post-unit.js"; import { bootstrapAutoSession, type BootstrapDeps } from "./auto-start.js"; +import { autoLoop, resolveAgentEnd, type LoopDeps } from "./auto-loop.js"; +import { + WorktreeResolver, + type WorktreeResolverDeps, +} from "./worktree-resolver.js"; +import { reorderForCaching } from "./prompt-ordering.js"; -// Resource staleness, stale worktree escape → resource-version.ts +// Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts // ─── Session State ───────────────────────────────────────────────────────── import { AutoSession, - MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES, - MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH, - NEW_SESSION_TIMEOUT_MS, DISPATCH_HANG_TIMEOUT_MS, + MAX_UNIT_DISPATCHES, + STUB_RECOVERY_THRESHOLD, + MAX_LIFETIME_DISPATCHES, + NEW_SESSION_TIMEOUT_MS, +} from "./auto/session.js"; +import type { + CompletedUnit, + CurrentUnit, + UnitRouting, + StartModel, +} from "./auto/session.js"; +export { + MAX_UNIT_DISPATCHES, + STUB_RECOVERY_THRESHOLD, + MAX_LIFETIME_DISPATCHES, + NEW_SESSION_TIMEOUT_MS, +} from "./auto/session.js"; +export type { + CompletedUnit, + CurrentUnit, + UnitRouting, + StartModel, } from "./auto/session.js"; -import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerificationRetry } from "./auto/session.js"; -import { getErrorMessage } from "./error-utils.js"; // ── ENCAPSULATION INVARIANT ───────────────────────────────────────────────── // ALL mutable auto-mode state lives in the AutoSession class (auto/session.ts). @@ -207,7 +244,8 @@ import { getErrorMessage } from "./error-utils.js"; // ───────────────────────────────────────────────────────────────────────────── const s = new AutoSession(); -import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js"; +/** 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; @@ -216,7 +254,52 @@ export function shouldUseWorktreeIsolation(): boolean { return true; // default: worktree } -// All mutable state lives in AutoSession (auto/session.ts) — see encapsulation invariant above. +/** Crash recovery prompt — set by startAuto, consumed by the main loop */ + +/** Pending verification retry — set when gate fails with retries remaining, consumed by autoLoop */ + +/** 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 */ + +/** Dashboard tracking */ + +/** Track dynamic routing decision for the current unit (for metrics) */ + +/** Queue of quick-task captures awaiting dispatch after triage resolution */ + +/** + * Model captured at auto-mode start. Used to prevent model bleed between + * concurrent GSD instances sharing the same global settings.json (#650). + * When preferences don't specify a model for a unit type, this ensures + * the session's original model is re-applied instead of reading from + * the shared global settings (which another instance may have overwritten). + */ + +/** Track current milestone to detect transitions */ + +/** Model the user had selected before auto-mode started */ + +/** Progress-aware timeout supervision */ + +/** Context-pressure continue-here monitor — fires once when context usage >= 70% */ + +/** Prompt character measurement for token savings analysis (R051). */ + +/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */ + +/** + * Tool calls currently being executed — prevents false idle detection during long-running tools. + * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been + * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open). + */ +// Re-export budget utilities for external consumers +export { + getBudgetAlertLevel, + getNewBudgetAlertLevel, + getBudgetEnforcementAction, +} from "./auto-budget.js"; + /** Wrapper: register SIGTERM handler and store reference. */ function registerSigtermHandler(currentBasePath: string): void { s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler); @@ -228,6 +311,8 @@ function deregisterSigtermHandler(): void { s.sigtermHandler = null; } +export { type AutoDashboardData } from "./auto-dashboard.js"; + export function getAutoDashboardData(): AutoDashboardData { const ledger = getLedger(); const totals = ledger ? getProjectTotals(ledger.units) : null; @@ -240,12 +325,15 @@ export function getAutoDashboardData(): AutoDashboardData { } catch { // Non-fatal — captures module may not be loaded } - return { active: s.active, paused: s.paused, + return { + active: s.active, + paused: s.paused, stepMode: s.stepMode, startTime: s.autoStartTime, - elapsed: (s.active || s.paused) ? Date.now() - s.autoStartTime : 0, + elapsed: s.active || s.paused ? Date.now() - s.autoStartTime : 0, currentUnit: s.currentUnit ? { ...s.currentUnit } : null, - completedUnits: [...s.completedUnits], basePath: s.basePath, + completedUnits: [...s.completedUnits], + basePath: s.basePath, totalCost: totals?.cost ?? 0, totalTokens: totals?.tokens.total ?? 0, pendingCaptureCount, @@ -267,7 +355,10 @@ export function isAutoPaused(): boolean { * Used by error-recovery to fall back to the session's own model * instead of reading (potentially stale) preferences from disk (#1065). */ -export function getAutoModeStartModel(): { provider: string; id: string } | null { +export function getAutoModeStartModel(): { + provider: string; + id: string; +} | null { return s.autoModeStartModel; } @@ -288,9 +379,11 @@ export function getOldestInFlightToolAgeMs(): number { * Return the base path to use for the auto.lock file. * Always uses the original project root (not the worktree) so that * a second terminal can discover and stop a running auto-mode session. + * + * Delegates to AutoSession.lockBasePath — the single source of truth. */ function lockBase(): string { - return s.originalBasePath || s.basePath; + return s.lockBasePath; } /** @@ -300,7 +393,11 @@ function lockBase(): string { * * Returns true if a remote session was found and signaled, false otherwise. */ -export function stopAutoRemote(projectRoot: string): { found: boolean; pid?: number; error?: string } { +export function stopAutoRemote(projectRoot: string): { + found: boolean; + pid?: number; + error?: string; +} { const lock = readCrashLock(projectRoot); if (!lock) return { found: false }; @@ -341,19 +438,20 @@ function clearUnitTimeout(): void { s.continueHereHandle = null; } clearInFlightTools(); - clearDispatchGapWatchdog(); -} - -function clearDispatchGapWatchdog(): void { - 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 = s.currentUnit ? readUnitRuntimeRecord(s.basePath, unitType, unitId) : null; +function buildSnapshotOpts( + unitType: string, + unitId: string, +): { + continueHereFired?: boolean; + promptCharCount?: number; + baselineCharCount?: number; +} & Record { + const runtime = s.currentUnit + ? readUnitRuntimeRecord(s.basePath, unitType, unitId) + : null; return { promptCharCount: s.lastPromptCharCount, baselineCharCount: s.lastBaselineCharCount, @@ -362,154 +460,45 @@ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFire }; } -// ─── Extracted Merge Helper ─────────────────────────────────────────────── - -/** - * Attempt to merge the current milestone branch to main. - * Handles both worktree and branch isolation modes with a single code path. - * Returns true if merge succeeded, false on error (non-fatal, logged). - * - * Extracted from 4 duplicate merge blocks in dispatchNextUnit to eliminate - * the bug factory where fixing one copy didn't fix the others (#1308). - */ -function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "transition" | "complete"): boolean { - const isolationMode = getIsolationMode(); - - // Worktree merge path - if (isInAutoWorktree(s.basePath) && s.originalBasePath) { - try { - // Sync completion artifacts from worktree → external state before merge (#1412) - try { - const { synced } = syncWorktreeStateBack(s.originalBasePath, s.basePath, milestoneId); - if (synced.length > 0) { - debugLog("worktree-reverse-sync", { milestoneId, synced: synced.length }); - } - } catch (syncErr) { - debugLog("worktree-reverse-sync-failed", { milestoneId, error: getErrorMessage(syncErr) }); - } - - const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP"); - if (!roadmapPath) { - teardownAutoWorktree(s.originalBasePath, milestoneId); - ctx.ui.notify(`Exited worktree for ${milestoneId} (no roadmap for merge).`, "info"); - return false; - } - const roadmapContent = readFileSync(roadmapPath, "utf-8"); - const mergeResult = mergeMilestoneToMain(s.originalBasePath, milestoneId, roadmapContent); - s.basePath = s.originalBasePath; - s.gitService = createGitService(s.basePath); - ctx.ui.notify( - `Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, - "info", - ); - return true; - } catch (err) { - ctx.ui.notify( - `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`, - "warning", - ); - if (s.originalBasePath) { - s.basePath = s.originalBasePath; - try { process.chdir(s.basePath); } catch { /* best-effort */ } - } - return false; - } - } - - // Branch-mode merge path - if (isolationMode === "branch") { - try { - const currentBranch = getCurrentBranch(s.basePath); - const milestoneBranch = autoWorktreeBranch(milestoneId); - if (currentBranch === milestoneBranch) { - const roadmapPath = resolveMilestoneFile(s.basePath, milestoneId, "ROADMAP"); - if (roadmapPath) { - const roadmapContent = readFileSync(roadmapPath, "utf-8"); - const mergeResult = mergeMilestoneToMain(s.basePath, milestoneId, roadmapContent); - s.gitService = createGitService(s.basePath); - ctx.ui.notify( - `Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`, - "info", - ); - return true; - } - } - } catch (err) { - ctx.ui.notify( - `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`, - "warning", - ); - } - } - - return false; +function handleLostSessionLock(ctx?: ExtensionContext): void { + debugLog("session-lock-lost", { lockBase: lockBase() }); + s.active = false; + s.paused = false; + clearUnitTimeout(); + deregisterSigtermHandler(); + ctx?.ui.notify( + "Session lock lost — another GSD process appears to have taken over. Stopping gracefully.", + "error", + ); + ctx?.ui.setStatus("gsd-auto", undefined); + ctx?.ui.setWidget("gsd-progress", undefined); + ctx?.ui.setFooter(undefined); } -/** - * 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 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(); - s.dispatchGapHandle = setTimeout(async () => { - s.dispatchGapHandle = null; - if (!s.active || !s.cmdCtx) return; - - if (s.verbose) { - ctx.ui.notify( - "Dispatch gap detected — re-evaluating state.", - "info", - ); - } - - try { - await dispatchNextUnit(ctx, pi); - } catch (retryErr) { - const message = getErrorMessage(retryErr); - await stopAuto(ctx, pi, `Dispatch gap recovery failed: ${message}`); - return; - } - - 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 { +export async function stopAuto( + ctx?: ExtensionContext, + pi?: ExtensionAPI, + reason?: string, +): Promise { if (!s.active && !s.paused) return; const reasonSuffix = reason ? ` — ${reason}` : ""; clearUnitTimeout(); - if (lockBase()) { - releaseSessionLock(lockBase()); - clearLock(lockBase()); - } + if (lockBase()) clearLock(lockBase()); + if (lockBase()) releaseSessionLock(lockBase()); clearSkillSnapshot(); resetSkillTelemetry(); - s.dispatching = false; - s.skipDepth = 0; // Remove SIGTERM handler registered at auto-mode start deregisterSigtermHandler(); // ── Auto-worktree: exit worktree and reset s.basePath on stop ── - if (s.currentMilestoneId && isInAutoWorktree(s.basePath)) { - try { - try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch (e) { debugLog("stop-auto-commit-failed", { error: getErrorMessage(e) }); } - teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true }); - s.basePath = s.originalBasePath; - s.gitService = createGitService(s.basePath); - ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info"); - } catch (err) { - ctx?.ui.notify( - `Auto-worktree teardown failed: ${getErrorMessage(err)}`, - "warning", - ); - } + if (s.currentMilestoneId) { + const notifyCtx = ctx + ? { notify: ctx.ui.notify.bind(ctx.ui) } + : { notify: () => {} }; + buildResolver().exitMilestone(s.currentMilestoneId, notifyCtx, { + preserveBranch: true, + }); } // ── DB cleanup: close the SQLite connection ── @@ -517,12 +506,20 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason try { const { closeDatabase } = await import("./gsd-db.js"); closeDatabase(); - } catch (e) { debugLog("db-close-failed", { error: getErrorMessage(e) }); } + } catch (e) { + debugLog("db-close-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } } if (s.originalBasePath) { s.basePath = s.originalBasePath; - try { process.chdir(s.basePath); } catch { /* best-effort */ } + try { + process.chdir(s.basePath); + } catch { + /* best-effort */ + } } const ledger = getLedger(); @@ -537,7 +534,13 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason } if (s.basePath) { - try { await rebuildState(s.basePath); } catch (e) { debugLog("stop-rebuild-state-failed", { error: getErrorMessage(e) }); } + try { + await rebuildState(s.basePath); + } catch (e) { + debugLog("stop-rebuild-state-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } } if (isDebugEnabled()) { @@ -556,7 +559,6 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason s.stepMode = false; s.unitDispatchCount.clear(); s.unitRecoveryCount.clear(); - s.unitConsecutiveSkips.clear(); clearInFlightTools(); s.lastBudgetAlertLevel = 0; s.lastStateRebuildAt = 0; @@ -570,18 +572,19 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason clearSliceProgressCache(); clearActivityLogState(); resetProactiveHealing(); - 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); if (pi && ctx && s.originalModelId && s.originalModelProvider) { - const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId); + const original = ctx.modelRegistry.find( + s.originalModelProvider, + s.originalModelId, + ); if (original) await pi.setModel(original); s.originalModelId = null; s.originalModelProvider = null; @@ -595,16 +598,17 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason * The user can interact with the agent, then `/gsd auto` resumes * from disk state. Called when the user presses Escape during auto-mode. */ -export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise { +export async function pauseAuto( + ctx?: ExtensionContext, + _pi?: ExtensionAPI, +): Promise { if (!s.active) return; clearUnitTimeout(); s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null; - if (lockBase()) { - releaseSessionLock(lockBase()); - clearLock(lockBase()); - } + if (lockBase()) clearLock(lockBase()); + if (lockBase()) releaseSessionLock(lockBase()); deregisterSigtermHandler(); @@ -622,6 +626,158 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro ); } +/** + * Build a WorktreeResolverDeps from auto.ts private scope. + * Shared by buildResolver() and buildLoopDeps(). + */ +function buildResolverDeps(): WorktreeResolverDeps { + return { + isInAutoWorktree, + shouldUseWorktreeIsolation, + getIsolationMode, + mergeMilestoneToMain, + syncWorktreeStateBack, + teardownAutoWorktree, + createAutoWorktree, + enterAutoWorktree, + getAutoWorktreePath, + autoCommitCurrentBranch, + getCurrentBranch, + autoWorktreeBranch, + resolveMilestoneFile, + readFileSync: (path: string, encoding: string) => + readFileSync(path, encoding as BufferEncoding), + GitServiceImpl: + GitServiceImpl as unknown as WorktreeResolverDeps["GitServiceImpl"], + loadEffectiveGSDPreferences: + loadEffectiveGSDPreferences as unknown as WorktreeResolverDeps["loadEffectiveGSDPreferences"], + invalidateAllCaches, + captureIntegrationBranch, + }; +} + +/** + * Build a WorktreeResolver wrapping the current session. + * Cheap to construct — it's just a thin wrapper over `s` + deps. + * Used by stopAuto(), resume path, and buildLoopDeps(). + */ +function buildResolver(): WorktreeResolver { + return new WorktreeResolver(s, buildResolverDeps()); +} + +/** + * Build the LoopDeps object from auto.ts private scope. + * This bundles all private functions that autoLoop needs without exporting them. + */ +function buildLoopDeps(): LoopDeps { + return { + lockBase, + buildSnapshotOpts, + stopAuto, + pauseAuto, + clearUnitTimeout, + updateProgressWidget, + + // State and cache + invalidateAllCaches, + deriveState, + loadEffectiveGSDPreferences, + + // Pre-dispatch health gate + preDispatchHealthGate, + + // Worktree sync + syncProjectRootToWorktree, + + // Resource version guard + checkResourcesStale, + + // Session lock + validateSessionLock, + updateSessionLock, + handleLostSessionLock, + + // Milestone transition + sendDesktopNotification, + setActiveMilestoneId, + pruneQueueOrder, + isInAutoWorktree, + shouldUseWorktreeIsolation, + mergeMilestoneToMain, + teardownAutoWorktree, + createAutoWorktree, + captureIntegrationBranch, + getIsolationMode, + getCurrentBranch, + autoWorktreeBranch, + resolveMilestoneFile, + reconcileMergeState, + + // Budget/context/secrets + getLedger, + getProjectTotals, + formatCost, + getBudgetAlertLevel, + getNewBudgetAlertLevel, + getBudgetEnforcementAction, + getManifestStatus, + collectSecretsFromManifest, + + // Dispatch + resolveDispatch, + runPreDispatchHooks, + getPriorSliceCompletionBlocker, + getMainBranch, + collectObservabilityWarnings: _collectObservabilityWarnings, + buildObservabilityRepairBlock, + + // Unit closeout + runtime records + closeoutUnit, + verifyExpectedArtifact, + clearUnitRuntimeRecord, + writeUnitRuntimeRecord, + recordOutcome, + writeLock, + captureAvailableSkills, + ensurePreconditions, + updateSliceProgressCache, + + // Model selection + supervision + selectAndApplyModel, + startUnitSupervision, + + // Prompt helpers + getDeepDiagnostic, + isDbAvailable, + reorderForCaching, + + // Filesystem + existsSync, + readFileSync: (path: string, encoding: string) => + readFileSync(path, encoding as BufferEncoding), + atomicWriteSync, + + // Git + GitServiceImpl: GitServiceImpl as unknown as LoopDeps["GitServiceImpl"], + + // WorktreeResolver + resolver: buildResolver(), + + // Post-unit processing + postUnitPreVerification, + runPostUnitVerification, + postUnitPostVerification, + + // Session manager + getSessionFile: (ctx: ExtensionContext) => { + try { + return ctx.sessionManager?.getSessionFile() ?? ""; + } catch { + return ""; + } + }, + } as unknown as LoopDeps; +} export async function startAuto( ctx: ExtensionCommandContext, @@ -637,13 +793,9 @@ export async function startAuto( // If resuming from paused state, just re-activate and dispatch next unit. if (s.paused) { - // Re-acquire session lock before resuming const resumeLock = acquireSessionLock(base); if (!resumeLock.acquired) { - ctx.ui.notify( - `Cannot resume: ${resumeLock.reason}`, - "error", - ); + ctx.ui.notify(`Cannot resume: ${resumeLock.reason}`, "error"); return; } @@ -655,47 +807,52 @@ export async function startAuto( s.basePath = base; s.unitDispatchCount.clear(); s.unitLifetimeDispatches.clear(); - s.unitConsecutiveSkips.clear(); if (!getLedger()) initMetrics(base); if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId); // ── Auto-worktree: re-enter worktree on resume ── - if (s.currentMilestoneId && shouldUseWorktreeIsolation() && s.originalBasePath && !isInAutoWorktree(s.basePath) && !detectWorktreeName(s.basePath) && !detectWorktreeName(s.originalBasePath)) { - try { - const existingWtPath = getAutoWorktreePath(s.originalBasePath, s.currentMilestoneId); - if (existingWtPath) { - const wtPath = enterAutoWorktree(s.originalBasePath, s.currentMilestoneId); - s.basePath = wtPath; - s.gitService = createGitService(s.basePath); - ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info"); - } else { - const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId); - s.basePath = wtPath; - s.gitService = createGitService(s.basePath); - ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info"); - } - } catch (err) { - ctx.ui.notify( - `Auto-worktree re-entry failed: ${getErrorMessage(err)}. Continuing at current path.`, - "warning", - ); - } + if ( + s.currentMilestoneId && + shouldUseWorktreeIsolation() && + s.originalBasePath && + !isInAutoWorktree(s.basePath) && + !detectWorktreeName(s.basePath) && + !detectWorktreeName(s.originalBasePath) + ) { + buildResolver().enterMilestone(s.currentMilestoneId, { + notify: ctx.ui.notify.bind(ctx.ui), + }); } registerSigtermHandler(lockBase()); ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); - ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); + ctx.ui.notify( + s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", + "info", + ); restoreHookState(s.basePath); - try { await rebuildState(s.basePath); } catch (e) { debugLog("resume-rebuild-state-failed", { error: getErrorMessage(e) }); } + try { + await rebuildState(s.basePath); + } catch (e) { + debugLog("resume-rebuild-state-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } try { 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"); + ctx.ui.notify( + `Resume: applied ${report.fixesApplied.length} fix(es) to state.`, + "info", + ); } - } catch (e) { debugLog("resume-doctor-failed", { error: getErrorMessage(e) }); } - await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); + } catch (e) { + debugLog("resume-doctor-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } invalidateAllCaches(); if (s.pausedSessionFile) { @@ -703,7 +860,8 @@ export async function startAuto( const recovery = synthesizeCrashRecovery( s.basePath, s.currentUnit?.type ?? "unknown", - s.currentUnit?.id ?? "unknown", s.pausedSessionFile ?? undefined, + s.currentUnit?.id ?? "unknown", + s.pausedSessionFile ?? undefined, activityDir, ); if (recovery && recovery.trace.toolCallCount > 0) { @@ -716,42 +874,20 @@ export async function startAuto( s.pausedSessionFile = null; } - // If resuming from a secrets pause, re-collect before dispatching (#1146) - if (s.pausedForSecrets && s.currentMilestoneId) { - try { - const manifestStatus = await getManifestStatus(s.basePath, s.currentMilestoneId); - if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await collectSecretsFromManifest(s.basePath, s.currentMilestoneId, ctx); - if (result && result.applied.length > 0) { - ctx.ui.notify( - `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, - "info", - ); - } else if (result && result.applied.length === 0 && result.skipped.length > 0) { - // All keys were skipped — still pending, re-pause - s.paused = true; - s.active = false; - ctx.ui.notify( - `All env variables were skipped. Auto-mode remains paused.\nCollect them with /gsd secrets, then resume with /gsd auto.`, - "warning", - ); - ctx.ui.setStatus("gsd-auto", "paused"); - return; - } - } - } catch (err) { - ctx.ui.notify( - `Secrets check error: ${getErrorMessage(err)}. Continuing without secrets.`, - "warning", - ); - } - s.pausedForSecrets = false; - } + updateSessionLock( + lockBase(), + "resuming", + s.currentMilestoneId ?? "unknown", + s.completedUnits.length, + ); + writeLock( + lockBase(), + "resuming", + s.currentMilestoneId ?? "unknown", + s.completedUnits.length, + ); - updateSessionLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length); - writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length); - - await dispatchNextUnit(ctx, pi); + await autoLoop(ctx, pi, s, buildLoopDeps()); return; } @@ -760,210 +896,45 @@ export async function startAuto( shouldUseWorktreeIsolation, registerSigtermHandler, lockBase, + buildResolver, }; - const ready = await bootstrapAutoSession(s, ctx, pi, base, verboseMode, requestedStepMode, bootstrapDeps); + const ready = await bootstrapAutoSession( + s, + ctx, + pi, + base, + verboseMode, + requestedStepMode, + bootstrapDeps, + ); if (!ready) return; // Dispatch the first unit - await dispatchNextUnit(ctx, pi); + await autoLoop(ctx, pi, s, buildLoopDeps()); } // ─── Agent End Handler ──────────────────────────────────────────────────────── -/** Guard against concurrent handleAgentEnd execution. */ - +/** + * Deprecated thin wrapper — kept as export for backward compatibility. + * The actual agent_end processing now happens via resolveAgentEnd() in auto-loop.ts, + * which is called directly from index.ts. The autoLoop() while loop handles all + * post-unit processing (verification, hooks, dispatch) that this function used to do. + * + * If called by straggler code, it simply resolves the pending promise so the loop + * can continue. + */ export async function handleAgentEnd( ctx: ExtensionContext, pi: ExtensionAPI, ): Promise { if (!s.active || !s.cmdCtx) return; - if (s.handlingAgentEnd) { - // Another agent_end arrived while we're still processing the previous one. - // This happens when a unit dispatched inside handleAgentEnd (e.g. via hooks, - // triage, or quick-task early-dispatch paths) completes before the outer - // handleAgentEnd returns. Queue a retry so the completed unit's agent_end - // is not silently dropped (#1072). - s.pendingAgentEndRetry = true; - return; - } - s.handlingAgentEnd = true; - - try { - - // Unit completed — clear its timeout clearUnitTimeout(); - - // ── Pre-verification processing (commit, doctor, state rebuild, etc.) ── - const postUnitCtx: PostUnitContext = { - s, - ctx, - pi, - buildSnapshotOpts, - lockBase, - stopAuto, - pauseAuto, - updateProgressWidget, - }; - - const preResult = await postUnitPreVerification(postUnitCtx); - if (preResult === "dispatched") return; - - // ── Verification gate: run typecheck/lint/test after execute-task ── - const verificationResult = await runPostUnitVerification( - { s, ctx, pi }, - dispatchNextUnit, - startDispatchGapWatchdog, - pauseAuto, - ); - if (verificationResult === "retry" || verificationResult === "pause") return; - - // ── Post-verification processing (DB dual-write, hooks, triage, quick-tasks) ── - const postResult = await postUnitPostVerification(postUnitCtx); - if (postResult === "dispatched" || postResult === "stopped") return; - if (postResult === "step-wizard") { - await showStepWizard(ctx, pi); - return; - } - - // ── Dispatch with hang detection (#1073) ──────────────────────────────── - // Start a safety watchdog BEFORE calling dispatchNextUnit. If dispatch - // hangs at any await (newSession, model selection, etc.), the gap watchdog - // inside handleAgentEnd never fires because we never reach the check. - // This pre-dispatch watchdog ensures recovery even when dispatchNextUnit - // itself is permanently blocked. - const dispatchHangGuard = setTimeout(() => { - if (!s.active) return; - // dispatchNextUnit has been running for too long — it's likely hung. - // Start the gap watchdog which will retry dispatch from scratch. - if (!s.unitTimeoutHandle && !s.wrapupWarningHandle) { - ctx.ui.notify( - `Dispatch hang detected (${DISPATCH_HANG_TIMEOUT_MS / 1000}s without completion). Starting recovery watchdog.`, - "warning", - ); - startDispatchGapWatchdog(ctx, pi); - } - }, DISPATCH_HANG_TIMEOUT_MS); - - try { - await dispatchNextUnit(ctx, pi); - } catch (dispatchErr) { - const message = getErrorMessage(dispatchErr); - ctx.ui.notify( - `Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`, - "error", - ); - startDispatchGapWatchdog(ctx, pi); - return; - } finally { - clearTimeout(dispatchHangGuard); - } - - if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) { - startDispatchGapWatchdog(ctx, pi); - } - - } finally { - s.handlingAgentEnd = false; - - // If an agent_end event was dropped by the reentrancy guard while we were - // processing, re-enter handleAgentEnd on the next microtask. This prevents - // the summarizing phase stall (#1072) where a unit dispatched inside - // handleAgentEnd (hooks, triage, quick-task) completes before we return, - // and its agent_end is silently dropped — leaving auto-mode active but - // permanently stalled with no unit running and no watchdog set. - if (s.pendingAgentEndRetry) { - s.pendingAgentEndRetry = false; - // Clear gap watchdog from the previous cycle to prevent concurrent - // dispatch when the deferred handleAgentEnd calls dispatchNextUnit (#1272). - clearDispatchGapWatchdog(); - setImmediate(() => { - handleAgentEnd(ctx, pi).catch((err) => { - const msg = getErrorMessage(err); - ctx.ui.notify(`Deferred agent_end retry failed: ${msg}`, "error"); - pauseAuto(ctx, pi).catch(() => {}); - }); - }); - } - } + resolveAgentEnd({ messages: [] }); } - -// ─── Step Mode Wizard ───────────────────────────────────────────────────── - -/** - * Show the step-mode wizard after a unit completes. - */ -async function showStepWizard( - ctx: ExtensionContext, - pi: ExtensionAPI, -): Promise { - if (!s.cmdCtx) return; - - const state = await deriveState(s.basePath); - const mid = state.activeMilestone?.id; - - const justFinished = s.currentUnit - ? `${unitVerb(s.currentUnit.type)} ${s.currentUnit.id}` - : "previous unit"; - - if (!mid || state.phase === "complete") { - const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked"); - if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked" && state.phase !== "pre-planning") { - const ids = incomplete.map(m => m.id).join(", "); - 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 { - await stopAuto(ctx, pi, state.phase === "complete" ? "All work complete" : "No active milestone"); - } - return; - } - - const nextDesc = _describeNextUnit(state); - - const choice = await showNextAction(s.cmdCtx, { - title: `GSD — ${justFinished} complete`, - summary: [ - `${mid}: ${state.activeMilestone?.title ?? mid}`, - ...(state.activeSlice ? [`${state.activeSlice.id}: ${state.activeSlice.title}`] : []), - ], - actions: [ - { - id: "continue", - label: nextDesc.label, - description: nextDesc.description, - recommended: true, - }, - { - id: "auto", - label: "Switch to auto", - description: "Continue without pausing between steps.", - }, - { - id: "status", - label: "View status", - description: "Open the dashboard.", - }, - ], - notYetMessage: "Run /gsd next when ready to continue.", - }); - - if (choice === "continue") { - await dispatchNextUnit(ctx, pi); - } else if (choice === "auto") { - s.stepMode = false; - ctx.ui.setStatus("gsd-auto", "auto"); - ctx.ui.notify("Switched to auto-mode.", "info"); - await dispatchNextUnit(ctx, pi); - } else if (choice === "status") { - const { fireStatusViaCommand } = await import("./commands.js"); - await fireStatusViaCommand(ctx as ExtensionCommandContext); - await showStepWizard(ctx, pi); - } else { - await pauseAuto(ctx, pi); - } -} - +// describeNextUnit is imported from auto-dashboard.ts and re-exported +export { describeNextUnit } from "./auto-dashboard.js"; /** Thin wrapper: delegates to auto-dashboard.ts, passing state accessors. */ function updateProgressWidget( @@ -973,9 +944,17 @@ function updateProgressWidget( state: GSDState, ): void { const badge = s.currentUnitRouting?.tier - ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? undefined) + ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? + undefined) : undefined; - _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge); + _updateProgressWidget( + ctx, + unitType, + unitId, + state, + widgetStateAccessors, + badge, + ); } /** State accessors for the widget — closures over module globals. */ @@ -987,695 +966,6 @@ const widgetStateAccessors: WidgetStateAccessors = { isVerbose: () => s.verbose, }; -// ─── Core Loop ──────────────────────────────────────────────────────────────── - -async function dispatchNextUnit( - ctx: ExtensionContext, - pi: ExtensionAPI, -): Promise { - 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; - } - - // ── Session lock validation: detect if another process has taken over ── - if (lockBase() && !validateSessionLock(lockBase())) { - debugLog("dispatchNextUnit session-lock-lost — another process may have taken over"); - ctx.ui.notify( - "Session lock lost — another GSD process appears to have taken over. Stopping gracefully.", - "error", - ); - // Don't call stopAuto here to avoid releasing the lock we don't own - s.active = false; - s.paused = false; - clearUnitTimeout(); - deregisterSigtermHandler(); - ctx.ui.setStatus("gsd-auto", undefined); - ctx.ui.setWidget("gsd-progress", undefined); - ctx.ui.setFooter(undefined); - return; - } - - // Reentrancy guard — unconditional to prevent concurrent dispatch from - // gap watchdog or pendingAgentEndRetry during skip chains (#1272). - // Previously the guard was bypassed when skipDepth > 0, but the recursive - // skip chain's inner finally block resets s.dispatching = false before the - // outer call's finally runs, opening a window for concurrent entry. - if (s.dispatching) { - debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing"); - return; - } - s.dispatching = true; - try { - // Recursion depth guard - 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)); - } - - // Resource version guard - const staleMsg = checkResourcesStale(s.resourceVersionOnStart); - if (staleMsg) { - await stopAuto(ctx, pi, staleMsg); - return; - } - - invalidateAllCaches(); - s.lastPromptCharCount = undefined; - s.lastBaselineCharCount = undefined; - - // ── Pre-dispatch health gate ── - try { - const healthGate = await preDispatchHealthGate(s.basePath); - if (healthGate.fixesApplied.length > 0) { - ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info"); - } - if (!healthGate.proceed) { - ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error"); - await pauseAuto(ctx, pi); - return; - } - } catch { - // Non-fatal - } - - const stopDeriveTimer = debugTime("derive-state"); - let state = await deriveState(s.basePath); - stopDeriveTimer({ - phase: state.phase, - milestone: state.activeMilestone?.id, - slice: state.activeSlice?.id, - task: state.activeTask?.id, - }); - let mid = state.activeMilestone?.id; - let midTitle = state.activeMilestone?.title; - - // Detect milestone transition - if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { - ctx.ui.notify( - `Milestone ${ s.currentMilestoneId } complete. Advancing to ${mid}: ${midTitle}.`, - "info", - ); - sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone"); - const vizPrefs = loadEffectiveGSDPreferences()?.preferences; - if (vizPrefs?.auto_visualize) { - ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); - } - if (vizPrefs?.auto_report !== false) { - try { - const { loadVisualizerData } = await import("./visualizer-data.js"); - const { generateHtmlReport } = await import("./export-html.js"); - const { writeReportSnapshot, reportsDir } = await import("./reports.js"); - const { basename } = await import("node:path"); - 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(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: s.basePath, - html: generateHtmlReport(snapData, { - projectName: projName, - projectPath: s.basePath, - gsdVersion, - milestoneId: s.currentMilestoneId, - indexRelPath: "index.html", - }), - milestoneId: s.currentMilestoneId, - milestoneTitle: msTitle, - kind: "milestone", - projectName: projName, - projectPath: s.basePath, - gsdVersion, - totalCost: snapData.totals?.cost ?? 0, - totalTokens: snapData.totals?.tokens.total ?? 0, - totalDuration: snapData.totals?.duration ?? 0, - doneSlices, - totalSlices, - doneMilestones: snapData.milestones.filter(m => m.status === "complete").length, - totalMilestones: snapData.milestones.length, - phase: snapData.phase, - }); - ctx.ui.notify( - `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, - "info", - ); - } catch (err) { - ctx.ui.notify( - `Report generation failed: ${getErrorMessage(err)}`, - "warning", - ); - } - } - // Reset stuck detection for new milestone - s.unitDispatchCount.clear(); - s.unitRecoveryCount.clear(); - s.unitConsecutiveSkips.clear(); - s.unitLifetimeDispatches.clear(); - try { - const file = completedKeysPath(s.basePath); - if (existsSync(file)) { - atomicWriteSync(file, JSON.stringify([])); - } - s.completedKeySet.clear(); - } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); } - - // ── Worktree lifecycle on milestone transition (#616) ── - if ((isInAutoWorktree(s.basePath) || getIsolationMode() === "branch") && shouldUseWorktreeIsolation()) { - tryMergeMilestone(ctx, s.currentMilestoneId, "transition"); - - // Reset to project root and re-derive state for the new milestone - if (s.originalBasePath) { - s.basePath = s.originalBasePath; - s.gitService = createGitService(s.basePath); - } - invalidateAllCaches(); - - state = await deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - - if (mid) { - captureIntegrationBranch(s.basePath, mid); - try { - const wtPath = createAutoWorktree(s.basePath, mid); - s.basePath = wtPath; - s.gitService = createGitService(s.basePath); - ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info"); - } catch (err) { - ctx.ui.notify( - `Auto-worktree creation for ${mid} failed: ${getErrorMessage(err)}. Continuing in project root.`, - "warning", - ); - } - } - } else { - if (getIsolationMode() !== "none") { - captureIntegrationBranch(s.originalBasePath || s.basePath, mid); - } - } - - const pendingIds = (state.registry ?? []) - .filter(m => m.status !== "complete") - .map(m => m.id); - pruneQueueOrder(s.basePath, pendingIds); - } - if (mid) { - s.currentMilestoneId = mid; - setActiveMilestoneId(s.basePath, mid); - } - - if (!mid) { - 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" && m.status !== "parked"); - if (incomplete.length === 0) { - // Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962) - if (s.currentMilestoneId) { - tryMergeMilestone(ctx, s.currentMilestoneId, "complete"); - } - sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone"); - await stopAuto(ctx, pi, "All milestones complete"); - } else if (state.phase === "blocked") { - const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; - await stopAuto(ctx, pi, blockerMsg); - ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); - sendDesktopNotification("GSD", blockerMsg, "error", "attention"); - } else { - const ids = incomplete.map(m => m.id).join(", "); - 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`); - } - return; - } - - if (!midTitle) { - midTitle = mid; - ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning"); - } - - // ── Mid-merge safety check ── - if (reconcileMergeState(s.basePath, ctx)) { - invalidateAllCaches(); - state = await deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - } - - if (!mid || !midTitle) { - 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" - : `Milestone ${mid} has no title after reconciliation`; - await stopAuto(ctx, pi, noMilestoneReason); - return; - } - - // Determine next unit - let unitType: string; - let unitId: string; - let prompt: string; - - if (state.phase === "complete") { - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } - try { - const file = completedKeysPath(s.basePath); - if (existsSync(file)) { - atomicWriteSync(file, JSON.stringify([])); - } - s.completedKeySet.clear(); - } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); } - // ── Milestone merge ── - if (s.currentMilestoneId) { - tryMergeMilestone(ctx, s.currentMilestoneId, "complete"); - } - sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone"); - await stopAuto(ctx, pi, `Milestone ${mid} complete`); - return; - } - - if (state.phase === "blocked") { - 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); - ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); - sendDesktopNotification("GSD", blockerMsg, "error", "attention"); - return; - } - - // Budget ceiling guard, context window guard, secrets gate, dispatch table - const prefs = loadEffectiveGSDPreferences()?.preferences; - - const budgetCeiling = prefs?.budget_ceiling; - if (budgetCeiling !== undefined && budgetCeiling > 0) { - const currentLedger = getLedger(); - const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0; - const budgetPct = totalCost / budgetCeiling; - const budgetAlertLevel = getBudgetAlertLevel(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)}).`; - s.lastBudgetAlertLevel = newBudgetAlertLevel; - if (budgetEnforcementAction === "halt") { - sendDesktopNotification("GSD", msg, "error", "budget"); - await stopAuto(ctx, pi, "Budget ceiling reached"); - return; - } - if (budgetEnforcementAction === "pause") { - ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning"); - sendDesktopNotification("GSD", msg, "warning", "budget"); - await pauseAuto(ctx, pi); - return; - } - ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); - sendDesktopNotification("GSD", msg, "warning", "budget"); - } else if (newBudgetAlertLevel === 90) { - 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) { - 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) { - 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) { - s.lastBudgetAlertLevel = 0; - } - } else { - s.lastBudgetAlertLevel = 0; - } - - const contextThreshold = prefs?.context_pause_threshold ?? 0; - 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"); - sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention"); - await pauseAuto(ctx, pi); - return; - } - } - - // Secrets re-check gate - const runSecretsGate = async () => { - try { - const manifestStatus = await getManifestStatus(s.basePath, mid); - if (manifestStatus && manifestStatus.pending.length > 0) { - 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.`, - "info", - ); - } else { - ctx.ui.notify("Secrets collection skipped.", "info"); - } - } - } catch (err) { - ctx.ui.notify( - `Secrets collection error: ${getErrorMessage(err)}. Continuing with next task.`, - "warning", - ); - } - }; - - await runSecretsGate(); - - // ── Interactive discussion gate ── - // If the active milestone needs discussion (has CONTEXT-DRAFT.md but no roadmap), - // stop auto-mode and route to the interactive discussion flow. The guided-flow - // handles needs-discussion correctly — it just needs to be called instead of - // letting the dispatch table fire "needs-discussion → stop" (#1170). - if (state.phase === "needs-discussion") { - 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 cmdCtx = s.cmdCtx!; - const basePath = s.basePath; - await stopAuto(ctx, pi, `${mid}: ${midTitle} needs discussion before planning.`); - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(cmdCtx, pi, basePath); - return; - } - - // ── Dispatch table ── - const dispatchResult = await resolveDispatch({ basePath: s.basePath, mid, midTitle: midTitle!, state, prefs, - }); - - if (dispatchResult.action === "stop") { - 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; - } - - if (dispatchResult.action !== "dispatch") { - // Defer re-dispatch to next microtask so s.dispatching is released first, - // preventing reentrancy guard bypass during concurrent entry (#1272). - setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => { - ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error"); - pauseAuto(ctx, pi).catch(() => {}); - })); - return; - } - - unitType = dispatchResult.unitType; - unitId = dispatchResult.unitId; - prompt = dispatchResult.prompt; - - // ── Pre-dispatch hooks ── - 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(", ")}`, - "info", - ); - } - if (preDispatchResult.action === "skip") { - ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info"); - setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => { - ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error"); - pauseAuto(ctx, pi).catch(() => {}); - })); - return; - } - if (preDispatchResult.action === "replace") { - prompt = preDispatchResult.prompt ?? prompt; - if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; - } else if (preDispatchResult.prompt) { - prompt = preDispatchResult.prompt; - } - - const priorSliceBlocker = getPriorSliceCompletionBlocker(s.basePath, getMainBranch(s.basePath), unitType, unitId); - if (priorSliceBlocker) { - await stopAuto(ctx, pi, priorSliceBlocker); - return; - } - - const observabilityIssues = await _collectObservabilityWarnings(ctx, s.basePath, unitType, unitId); - - // ── Idempotency check (delegated to auto-idempotency.ts) ── - const idempotencyResult = checkIdempotency({ - s, - unitType, - unitId, - basePath: s.basePath, - notify: (msg, level) => ctx.ui.notify(msg, level), - }); - - if (idempotencyResult.action === "skip") { - if (idempotencyResult.reason === "completed" || idempotencyResult.reason === "fallback-persisted" || idempotencyResult.reason === "phantom-loop-cleared" || idempotencyResult.reason === "evicted") { - if (!s.active) return; - s.skipDepth++; - const skipDelay = idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150; - // Defer re-dispatch so s.dispatching is released first (#1272). - setTimeout(() => { - dispatchNextUnit(ctx, pi).catch(err => { - ctx.ui.notify(`Deferred skip-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error"); - pauseAuto(ctx, pi).catch(() => {}); - }).finally(() => { - s.skipDepth = Math.max(0, s.skipDepth - 1); - }); - }, skipDelay); - return; - } - } else if (idempotencyResult.action === "stop") { - await stopAuto(ctx, pi, idempotencyResult.reason); - ctx.ui.notify( - `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle.`, - "error", - ); - return; - } - // "rerun" and "proceed" fall through to stuck detection - - // ── Stuck detection (delegated to auto-stuck-detection.ts) ── - const stuckResult = await checkStuckAndRecover({ - s, - ctx, - unitType, - unitId, - basePath: s.basePath, - buildSnapshotOpts: () => buildSnapshotOpts(unitType, unitId), - }); - - if (stuckResult.action === "stop") { - await stopAuto(ctx, pi, stuckResult.reason); - if (stuckResult.notifyMessage) { - ctx.ui.notify(stuckResult.notifyMessage, "error"); - } - return; - } - if (stuckResult.action === "recovered" && stuckResult.dispatchAgain) { - // Defer re-dispatch so s.dispatching is released first (#1272). - setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => { - ctx.ui.notify(`Deferred recovery-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error"); - pauseAuto(ctx, pi).catch(() => {}); - })); - return; - } - - // Snapshot metrics + activity log for the PREVIOUS unit before we reassign. - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - - if (s.currentUnitRouting) { - const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId; - recordOutcome( - s.currentUnit.type, - s.currentUnitRouting.tier as "light" | "standard" | "heavy", - !isRetry, - ); - } - - const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`; - const incomingKey = `${unitType}/${unitId}`; - 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) { - persistCompletedKey(s.basePath, closeoutKey); - s.completedKeySet.add(closeoutKey); - } - - s.completedUnits.push({ - type: s.currentUnit.type, - id: s.currentUnit.id, - startedAt: s.currentUnit.startedAt, - finishedAt: Date.now(), - }); - if (s.completedUnits.length > 200) { - s.completedUnits = s.completedUnits.slice(-200); - } - 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}`); - } - } - s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; - captureAvailableSkills(); - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: s.currentUnit.startedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }); - - // Status bar + progress widget - ctx.ui.setStatus("gsd-auto", "auto"); - if (mid) updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); - updateProgressWidget(ctx, unitType, unitId, state); - - ensurePreconditions(unitType, unitId, s.basePath, state); - - // Fresh session — with timeout to prevent permanent hangs (#1073). - // If newSession() hangs (e.g., session manager deadlock, network issue), - // without this timeout the entire dispatch chain stalls permanently: no - // timeouts are set, no gap watchdog fires, and auto-mode is left active - // but idle until the user Ctrl+C's. - let result: { cancelled: boolean }; - try { - const sessionPromise = s.cmdCtx!.newSession(); - const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => - setTimeout(() => resolve({ cancelled: true }), NEW_SESSION_TIMEOUT_MS), - ); - result = await Promise.race([sessionPromise, timeoutPromise]); - } catch (sessionErr) { - const msg = getErrorMessage(sessionErr); - ctx.ui.notify(`Session creation failed: ${msg}. Retrying via watchdog.`, "error"); - throw new Error(`newSession() failed: ${msg}`); - } - if (result.cancelled) { - ctx.ui.notify( - `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, - "warning", - ); - await stopAuto(ctx, pi, "Session creation failed"); - return; - } - - const sessionFile = ctx.sessionManager.getSessionFile(); - updateSessionLock(lockBase(), unitType, unitId, s.completedUnits.length, sessionFile); - writeLock(lockBase(), unitType, unitId, s.completedUnits.length, sessionFile); - - // Prompt injection - const MAX_RECOVERY_CHARS = 50_000; - let finalPrompt = prompt; - - 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 (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}`; - 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]" - : diagnostic; - finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; - } - } - - const repairBlock = buildObservabilityRepairBlock(observabilityIssues); - if (repairBlock) { - finalPrompt = `${finalPrompt}${repairBlock}`; - } - - // ── Prompt char measurement ── - 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(s.basePath, "decisions.md", "Decisions"), - inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"), - inlineGsdRootFile(s.basePath, "project.md", "Project"), - ]); - s.lastBaselineCharCount = - (decisionsContent?.length ?? 0) + - (requirementsContent?.length ?? 0) + - (projectContent?.length ?? 0); - } catch { - // Non-fatal - } - } - - // Cache-optimize prompt section ordering - try { - const { reorderForCaching } = await import("./prompt-ordering.js"); - finalPrompt = reorderForCaching(finalPrompt); - } catch (reorderErr) { - const msg = getErrorMessage(reorderErr); - process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`); - } - - // Select and apply model - const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel); - s.currentUnitRouting = modelResult.routing; - - // ── Start unit supervision (delegated to auto-timers.ts) ── - clearUnitTimeout(); - startUnitSupervision({ - s, - ctx, - pi, - unitType, - unitId, - prefs, - buildSnapshotOpts: () => buildSnapshotOpts(unitType, unitId), - buildRecoveryContext: () => buildRecoveryContext(), - pauseAuto, - }); - - // Inject prompt - if (!s.active) return; - pi.sendMessage( - { customType: "gsd-auto", content: finalPrompt, display: s.verbose }, - { triggerTurn: true }, - ); - - } finally { - s.dispatching = false; - } -} - // ─── Preconditions ──────────────────────────────────────────────────────────── /** @@ -1683,9 +973,13 @@ async function dispatchNextUnit( * dispatching a unit. The LLM should never need to mkdir or git checkout. */ function ensurePreconditions( - unitType: string, unitId: string, base: string, state: GSDState, + unitType: string, + unitId: string, + base: string, + state: GSDState, ): void { - const { milestone: mid } = parseUnitId(unitId); + const parts = unitId.split("/"); + const mid = parts[0]!; const mDir = resolveMilestonePath(base, mid); if (!mDir) { @@ -1693,8 +987,8 @@ function ensurePreconditions( mkdirSync(join(newDir, "slices"), { recursive: true }); } - const sid = parseUnitId(unitId).slice; - if (sid) { + if (parts.length >= 2) { + const sid = parts[1]!; const mDirResolved = resolveMilestonePath(base, mid); if (mDirResolved) { @@ -1710,16 +1004,17 @@ function ensurePreconditions( } } } - } // ─── Diagnostics ────────────────────────────────────────────────────────────── /** Build recovery context from module state for recoverTimedOutUnit */ function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryContext { - return { basePath: s.basePath, verbose: s.verbose, - currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), unitRecoveryCount: s.unitRecoveryCount, - dispatchNextUnit, + return { + basePath: s.basePath, + verbose: s.verbose, + currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), + unitRecoveryCount: s.unitRecoveryCount, }; } @@ -1736,17 +1031,6 @@ export { * Test-only: expose skip-loop state for unit tests. * Not part of the public API. */ -export function _getUnitConsecutiveSkips(): Map { return s.unitConsecutiveSkips; } -export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); } - -/** - * Test-only: expose dispatching / skipDepth state for reentrancy guard tests. - * Not part of the public API. - */ -export function _getDispatching(): boolean { return s.dispatching; } -export function _setDispatching(v: boolean): void { s.dispatching = v; } -export function _getSkipDepth(): number { return s.skipDepth; } -export function _setSkipDepth(v: number): void { s.skipDepth = v; } /** * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks. @@ -1776,7 +1060,11 @@ export async function dispatchHookUnit( const hookUnitType = `hook/${hookName}`; const hookStartedAt = Date.now(); - s.currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt }; + s.currentUnit = { + type: triggerUnitType, + id: triggerUnitId, + startedAt: hookStartedAt, + }; const result = await s.cmdCtx!.newSession(); if (result.cancelled) { @@ -1784,32 +1072,49 @@ export async function dispatchHookUnit( return false; } - s.currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt }; + s.currentUnit = { + type: hookUnitType, + id: triggerUnitId, + startedAt: hookStartedAt, + }; - writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: hookStartedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }); + writeUnitRuntimeRecord( + s.basePath, + hookUnitType, + triggerUnitId, + hookStartedAt, + { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: hookStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }, + ); if (hookModel) { const availableModels = ctx.modelRegistry.getAvailable(); - const match = availableModels.find(m => - m.id === hookModel || `${m.provider}/${m.id}` === hookModel, + const match = availableModels.find( + (m) => m.id === hookModel || `${m.provider}/${m.id}` === hookModel, ); if (match) { try { await pi.setModel(match); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } const sessionFile = ctx.sessionManager.getSessionFile(); - updateSessionLock(lockBase(), hookUnitType, triggerUnitId, s.completedUnits.length, sessionFile); - writeLock(lockBase(), hookUnitType, triggerUnitId, s.completedUnits.length, sessionFile); + writeLock( + lockBase(), + hookUnitType, + triggerUnitId, + s.completedUnits.length, + sessionFile, + ); clearUnitTimeout(); const supervisor = resolveAutoSupervisorConfig(); @@ -1818,10 +1123,16 @@ export async function dispatchHookUnit( s.unitTimeoutHandle = null; if (!s.active) return; if (s.currentUnit) { - writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, { - phase: "timeout", - timeoutAt: Date.now(), - }); + writeUnitRuntimeRecord( + s.basePath, + hookUnitType, + triggerUnitId, + hookStartedAt, + { + phase: "timeout", + timeoutAt: Date.now(), + }, + ); } ctx.ui.notify( `Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, @@ -1834,6 +1145,10 @@ export async function dispatchHookUnit( ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); + debugLog("dispatchHookUnit", { + phase: "send-message", + promptLength: hookPrompt.length, + }); pi.sendMessage( { customType: "gsd-auto", content: hookPrompt, display: true }, { triggerTurn: true }, @@ -1842,4 +1157,5 @@ export async function dispatchHookUnit( return true; } - +// Direct phase dispatch → auto-direct-dispatch.ts +export { dispatchDirectPhase } from "./auto-direct-dispatch.js"; diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 12d7e54f8..e40b87baf 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -52,16 +52,28 @@ export interface PendingVerificationRetry { attempt: number; } +/** + * A typed item enqueued by postUnitPostVerification for the main loop to + * drain via the standard runUnit path. Replaces inline dispatch + * (pi.sendMessage / s.cmdCtx.newSession()) for hooks, triage, and quick-tasks. + */ +export interface SidecarItem { + kind: "hook" | "triage" | "quick-task"; + unitType: string; + unitId: string; + prompt: string; + /** Model override for hook units (e.g. "anthropic/claude-3-5-sonnet"). */ + model?: string; + /** Capture ID for quick-task items (already marked executed at enqueue time). */ + captureId?: string; +} + // ─── 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; export const NEW_SESSION_TIMEOUT_MS = 30_000; -export const DISPATCH_HANG_TIMEOUT_MS = 60_000; // ─── AutoSession ───────────────────────────────────────────────────────────── @@ -69,7 +81,6 @@ export class AutoSession { // ── Lifecycle ──────────────────────────────────────────────────────────── active = false; paused = false; - pausedForSecrets = false; stepMode = false; verbose = false; cmdCtx: ExtensionCommandContext | null = null; @@ -83,15 +94,12 @@ export class AutoSession { 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; @@ -113,12 +121,8 @@ export class AutoSession { resourceVersionOnStart: string | null = null; lastStateRebuildAt = 0; - // ── Guards ─────────────────────────────────────────────────────────────── - handlingAgentEnd = false; - pendingAgentEndRetry = false; - dispatching = false; - skipDepth = 0; - readonly recentlyEvictedKeys = new Set(); + // ── Sidecar queue ───────────────────────────────────────────────────── + sidecarQueue: SidecarItem[] = []; // ── Metrics ────────────────────────────────────────────────────────────── autoStartTime = 0; @@ -129,6 +133,29 @@ export class AutoSession { // ── Signal handler ─────────────────────────────────────────────────────── sigtermHandler: (() => void) | null = null; + // ── Loop promise state ────────────────────────────────────────────────── + /** + * True only while runUnit is rotating into a fresh session. agent_end events + * emitted from the previous session's abort during this window must be + * ignored; they do not belong to the new unit. + */ + sessionSwitchInFlight = false; + + /** + * One-shot resolver for the current unit's agent_end promise. + * Non-null only while a unit is in-flight (between sendMessage and agent_end). + * Scoped to the session to prevent concurrent session corruption. + */ + pendingResolve: ((result: { status: "completed" | "cancelled" | "error"; event?: { messages: unknown[] } }) => void) | null = null; + + /** + * Queue for agent_end events that arrive when no pendingResolve exists. + * This happens when error-recovery sendMessage retries produce agent_end + * events between loop iterations. The next runUnit drains this queue + * instead of waiting for a new event. + */ + pendingAgentEndQueue: Array<{ messages: unknown[] }> = []; + // ── Methods ────────────────────────────────────────────────────────────── clearTimers(): void { @@ -136,13 +163,11 @@ export class AutoSession { 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 { @@ -163,7 +188,6 @@ export class AutoSession { // Lifecycle this.active = false; this.paused = false; - this.pausedForSecrets = false; this.stepMode = false; this.verbose = false; this.cmdCtx = null; @@ -177,9 +201,6 @@ export class AutoSession { 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; @@ -201,21 +222,20 @@ export class AutoSession { this.resourceVersionOnStart = null; this.lastStateRebuildAt = 0; - // Guards - this.handlingAgentEnd = false; - this.pendingAgentEndRetry = false; - this.dispatching = false; - this.skipDepth = 0; - this.recentlyEvictedKeys.clear(); - // Metrics this.autoStartTime = 0; this.lastPromptCharCount = undefined; this.lastBaselineCharCount = undefined; this.pendingQuickTasks = []; + this.sidecarQueue = []; // Signal handler this.sigtermHandler = null; + + // Loop promise state + this.sessionSwitchInFlight = false; + this.pendingResolve = null; + this.pendingAgentEndQueue = []; } toJSON(): Record { @@ -227,10 +247,7 @@ export class AutoSession { 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/captures.ts b/src/resources/extensions/gsd/captures.ts index 9837cb907..2c18a987c 100644 --- a/src/resources/extensions/gsd/captures.ts +++ b/src/resources/extensions/gsd/captures.ts @@ -9,10 +9,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, resolve, sep } from "node:path"; import { randomUUID } from "node:crypto"; import { gsdRoot } from "./paths.js"; -import { resolveProjectRoot } from "./worktree.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -59,8 +58,15 @@ const VALID_CLASSIFICATIONS: readonly string[] = [ * directory that contains `.gsd/worktrees/` — that's the project root. */ export function resolveCapturesPath(basePath: string): string { - const projectRoot = resolveProjectRoot(resolve(basePath)); - return join(gsdRoot(projectRoot), CAPTURES_FILENAME); + const resolved = resolve(basePath); + const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = resolved.indexOf(worktreeMarker); + if (idx !== -1) { + // basePath is inside a worktree — resolve to project root + const projectRoot = resolved.slice(0, idx); + return join(projectRoot, ".gsd", CAPTURES_FILENAME); + } + return join(gsdRoot(basePath), CAPTURES_FILENAME); } // ─── File I/O ───────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index d12f12a37..717b711f9 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -5,7 +5,6 @@ import { readdirSync } from "node:fs"; import { resolveMilestoneFile, milestonesDir } from "./paths.js"; import { parseRoadmapSlices } from "./roadmap-slices.js"; import { findMilestoneIds } from "./guided-flow.js"; -import { parseUnitId } from "./unit-id.js"; const SLICE_DISPATCH_TYPES = new Set([ "research-slice", @@ -37,10 +36,15 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null { } } -export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null { +export function getPriorSliceCompletionBlocker( + base: string, + _mainBranch: string, + unitType: string, + unitId: string, +): string | null { if (!SLICE_DISPATCH_TYPES.has(unitType)) return null; - const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId); + const [targetMid, targetSid] = unitId.split("/"); if (!targetMid || !targetSid) return null; // Use findMilestoneIds to respect custom queue order. @@ -51,9 +55,7 @@ export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string const milestoneIds = allIds.slice(0, targetIdx + 1); for (const mid of milestoneIds) { - // Skip parked milestones — they don't block dispatch of later milestones - const parkedFile = resolveMilestoneFile(base, mid, "PARKED"); - if (parkedFile) continue; + if (resolveMilestoneFile(base, mid, "PARKED")) continue; // Read from disk (working tree) — always has the latest state const roadmapContent = readRoadmapFromDisk(base, mid); @@ -61,17 +63,19 @@ export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string const slices = parseRoadmapSlices(roadmapContent); if (mid !== targetMid) { - const incomplete = slices.find(slice => !slice.done); + const incomplete = slices.find((slice) => !slice.done); if (incomplete) { return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${mid}/${incomplete.id} is not complete.`; } continue; } - const targetIndex = slices.findIndex(slice => slice.id === targetSid); + const targetIndex = slices.findIndex((slice) => slice.id === targetSid); if (targetIndex === -1) return null; - const incomplete = slices.slice(0, targetIndex).find(slice => !slice.done); + const incomplete = slices + .slice(0, targetIndex) + .find((slice) => !slice.done); if (incomplete) { return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`; } diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 6e7af17a2..ada0fa1b7 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -21,7 +21,12 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md **Empty arrays (`[]`) are equivalent to omitting the field entirely.** During validation, GSD deletes empty arrays from the preferences object (see `validatePreferences()` in `preferences.ts`): ```typescript -for (const key of ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const) { +for (const key of [ + "always_use_skills", + "prefer_skills", + "avoid_skills", + "custom_instructions", +] as const) { if (validated[key] && validated[key]!.length === 0) { delete validated[key]; } @@ -50,6 +55,7 @@ Preferences are loaded from two locations and merged: 2. **Project:** `.gsd/preferences.md` — applies to the current project only **Merge behavior** (see `mergePreferences()` in `preferences.ts`): + - **Scalar fields** (`skill_discovery`, `budget_ceiling`, etc.): Project wins if defined, otherwise global. Uses nullish coalescing (`??`). - **Array fields** (`always_use_skills`, `prefer_skills`, etc.): Concatenated via `mergeStringLists()` (global first, then project). - **Object fields** (`models`, `git`, `auto_supervisor`): Shallow merge via spread operator `{ ...base, ...override }`. @@ -60,10 +66,10 @@ For `models`, project settings override global at the phase level. If global has These are **separate concerns**: -| Field | What it controls | Code reference | -|-------|-----------------|----------------| -| `skill_discovery` | **Whether** GSD looks for relevant skills during research | `resolveSkillDiscoveryMode()` in `preferences.ts` | -| `always_use_skills`, `prefer_skills`, `avoid_skills` | **Which** skills to use when they're found relevant | `renderPreferencesForSystemPrompt()` in `preferences.ts` | +| Field | What it controls | Code reference | +| ---------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------- | +| `skill_discovery` | **Whether** GSD looks for relevant skills during research | `resolveSkillDiscoveryMode()` in `preferences.ts` | +| `always_use_skills`, `prefer_skills`, `avoid_skills` | **Which** skills to use when they're found relevant | `renderPreferencesForSystemPrompt()` in `preferences.ts` | Setting `prefer_skills: []` does **not** disable skill discovery — it just means you have no preference overrides. Use `skill_discovery: off` to disable discovery entirely. @@ -75,14 +81,14 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `mode`: workflow mode — `"solo"` or `"team"`. Sets sensible defaults for git and project settings based on your workflow. Mode defaults are the lowest priority layer — any explicit preference overrides them. Omit to configure everything manually. - | Setting | `solo` | `team` | - |---|---|---| - | `git.auto_push` | `true` | `false` | - | `git.push_branches` | `false` | `true` | - | `git.pre_merge_check` | `false` | `true` | - | `git.merge_strategy` | `"squash"` | `"squash"` | - | `git.isolation` | `"worktree"` | `"worktree"` | - | `unique_milestone_ids` | `false` | `true` | + | Setting | `solo` | `team` | + | ---------------------- | ------------ | ------------ | + | `git.auto_push` | `true` | `false` | + | `git.push_branches` | `false` | `true` | + | `git.pre_merge_check` | `false` | `true` | + | `git.merge_strategy` | `"squash"` | `"squash"` | + | `git.isolation` | `"worktree"` | `"worktree"` | + | `unique_milestone_ids` | `false` | `true` | Quick setup: `/gsd mode` (global) or `/gsd mode project` (project-level). @@ -141,11 +147,12 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `context_pause_threshold`: number (0-100) — context window usage percentage at which auto-mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled). -- `token_profile`: `"budget"`, `"balanced"`, or `"quality"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) runs all phases; `quality` prefers higher-quality models. See token-optimization docs. +- `token_profile`: `"budget"`, `"balanced"`, or `"quality"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models. See token-optimization docs. - `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys: - `skip_research`: boolean — skip milestone-level research. Default: `false`. - - `skip_reassess`: boolean — skip roadmap reassessment after each slice. Default: `false`. + - `reassess_after_slice`: boolean — run roadmap reassessment after each completed slice. Default: `false`. + - `skip_reassess`: boolean — force-disable roadmap reassessment even if `reassess_after_slice` is enabled. Default: `false`. - `skip_slice_research`: boolean — skip per-slice research. Default: `false`. - `remote_questions`: route interactive questions to Slack/Discord for headless auto-mode. Keys: @@ -359,11 +366,11 @@ If you use a bare model ID (no provider prefix) and it exists in multiple provid --- version: 1 models: - research: openrouter/deepseek/deepseek-r1 # $0.28/$0.42 per 1M tokens + research: openrouter/deepseek/deepseek-r1 # $0.28/$0.42 per 1M tokens planning: - model: claude-opus-4-6 # $5/$25 — best for architecture + model: claude-opus-4-6 # $5/$25 — best for architecture fallbacks: - - openrouter/z-ai/glm-5 # $1/$3.20 — strong alternative + - openrouter/z-ai/glm-5 # $1/$3.20 — strong alternative execution: openrouter/minimax/minimax-m2.5 # $0.30/$1.20 — cheapest quality completion: openrouter/minimax/minimax-m2.5 --- diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index 85d3efaab..bd02127b5 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -314,10 +314,9 @@ export async function checkRuntimeHealth( }); if (shouldFix("orphaned_completed_units")) { - const { removePersistedKey } = await import("./auto-recovery.js"); - for (const key of orphaned) { - removePersistedKey(basePath, key); - } + const orphanedSet = new Set(orphaned); + const remaining = keys.filter((key) => !orphanedSet.has(key)); + await saveFile(completedKeysFile, JSON.stringify(remaining)); fixesApplied.push(`removed ${orphaned.length} orphaned completed-unit key(s)`); } } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 44c900127..dc46190e9 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -42,6 +42,8 @@ export interface GitPreferences { push_branches?: boolean; remote?: string; snapshots?: boolean; + /** Deprecated. .gsd/ is managed externally; retained for compatibility. */ + commit_docs?: boolean; pre_merge_check?: boolean | string; commit_type?: string; main_branch?: string; @@ -226,7 +228,12 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st /** Regex matching GSD quick-task branches: gsd/quick/- */ export const QUICK_BRANCH_RE = /^gsd\/quick\//; -export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void { +export function writeIntegrationBranch( + basePath: string, + milestoneId: string, + branch: string, + _options?: { commitDocs?: boolean }, +): void { // Don't record slice branches as the integration target if (SLICE_BRANCH_RE.test(branch)) return; // Don't record quick-task branches — they are ephemeral and merge back diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 14ca26328..adc8629d1 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -86,7 +86,10 @@ const BASELINE_PATTERNS = [ * `.gsd/` state is managed externally (symlinked to `~/.gsd/projects//`), * so the entire directory is always gitignored. */ -export function ensureGitignore(basePath: string, options?: { manageGitignore?: boolean }): boolean { +export function ensureGitignore( + basePath: string, + options?: { manageGitignore?: boolean; commitDocs?: boolean }, +): boolean { // If manage_gitignore is explicitly false, do not touch .gitignore at all if (options?.manageGitignore === false) return false; @@ -212,4 +215,3 @@ custom_instructions: return true; } - diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index be32bee0b..a31a2329e 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -5,10 +5,11 @@ // Exposes a unified sync API for decisions and requirements storage. // Schema is initialized on first open with WAL mode for file-backed DBs. -import { createRequire } from 'node:module'; -import { existsSync } from 'node:fs'; -import type { Decision, Requirement } from './types.js'; -import { GSDError, GSD_STALE_STATE } from './errors.js'; +import { createRequire } from "node:module"; +import { existsSync, copyFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import type { Decision, Requirement } from "./types.js"; +import { GSDError, GSD_STALE_STATE } from "./errors.js"; // Create a require function for loading native modules in ESM context const _require = createRequire(import.meta.url); @@ -20,7 +21,7 @@ const _require = createRequire(import.meta.url); * Both expose prepare().run/get/all — the adapter normalizes row objects. */ interface DbStatement { - run(...params: unknown[]): void; + run(...params: unknown[]): unknown; get(...params: unknown[]): Record | undefined; all(...params: unknown[]): Record[]; } @@ -31,7 +32,7 @@ interface DbAdapter { close(): void; } -type ProviderName = 'node:sqlite' | 'better-sqlite3'; +type ProviderName = "node:sqlite" | "better-sqlite3"; let providerName: ProviderName | null = null; let providerModule: unknown = null; @@ -46,18 +47,20 @@ function suppressSqliteWarning(): void { // @ts-expect-error — overriding process.emit with filtered version process.emit = function (event: string, ...args: unknown[]): boolean { if ( - event === 'warning' && + event === "warning" && args[0] && - typeof args[0] === 'object' && - 'name' in args[0] && - (args[0] as { name: string }).name === 'ExperimentalWarning' && - 'message' in args[0] && - typeof (args[0] as { message: string }).message === 'string' && - (args[0] as { message: string }).message.includes('SQLite') + typeof args[0] === "object" && + "name" in args[0] && + (args[0] as { name: string }).name === "ExperimentalWarning" && + "message" in args[0] && + typeof (args[0] as { message: string }).message === "string" && + (args[0] as { message: string }).message.includes("SQLite") ) { return false; } - return origEmit.apply(process, [event, ...args] as Parameters) as unknown as boolean; + return origEmit.apply(process, [event, ...args] as Parameters< + typeof process.emit + >) as unknown as boolean; }; } @@ -68,10 +71,10 @@ function loadProvider(): void { // Try node:sqlite first try { suppressSqliteWarning(); - const mod = _require('node:sqlite'); + const mod = _require("node:sqlite"); if (mod.DatabaseSync) { providerModule = mod; - providerName = 'node:sqlite'; + providerName = "node:sqlite"; return; } } catch { @@ -80,17 +83,19 @@ function loadProvider(): void { // Try better-sqlite3 try { - const mod = _require('better-sqlite3'); - if (typeof mod === 'function' || (mod && mod.default)) { + const mod = _require("better-sqlite3"); + if (typeof mod === "function" || (mod && mod.default)) { providerModule = mod.default || mod; - providerName = 'better-sqlite3'; + providerName = "better-sqlite3"; return; } } catch { // better-sqlite3 not available } - process.stderr.write('gsd-db: No SQLite provider available (tried node:sqlite, better-sqlite3)\n'); + process.stderr.write( + "gsd-db: No SQLite provider available (tried node:sqlite, better-sqlite3)\n", + ); } // ─── Database Adapter ────────────────────────────────────────────────────── @@ -101,13 +106,13 @@ function loadProvider(): void { function normalizeRow(row: unknown): Record | undefined { if (row == null) return undefined; if (Object.getPrototypeOf(row) === null) { - return { ...row as Record }; + return { ...(row as Record) }; } return row as Record; } function normalizeRows(rows: unknown[]): Record[] { - return rows.map(r => normalizeRow(r)!); + return rows.map((r) => normalizeRow(r)!); } function createAdapter(rawDb: unknown): DbAdapter { @@ -128,8 +133,8 @@ function createAdapter(rawDb: unknown): DbAdapter { prepare(sql: string): DbStatement { const stmt = db.prepare(sql); return { - run(...params: unknown[]): void { - stmt.run(...params); + run(...params: unknown[]): unknown { + return stmt.run(...params); }, get(...params: unknown[]): Record | undefined { return normalizeRow(stmt.get(...params)); @@ -149,8 +154,10 @@ function openRawDb(path: string): unknown { loadProvider(); if (!providerModule || !providerName) return null; - if (providerName === 'node:sqlite') { - const { DatabaseSync } = providerModule as { DatabaseSync: new (path: string) => unknown }; + if (providerName === "node:sqlite") { + const { DatabaseSync } = providerModule as { + DatabaseSync: new (path: string) => unknown; + }; return new DatabaseSync(path); } @@ -166,10 +173,10 @@ const SCHEMA_VERSION = 3; function initSchema(db: DbAdapter, fileBacked: boolean): void { // WAL mode for file-backed databases (must be outside transaction) if (fileBacked) { - db.exec('PRAGMA journal_mode=WAL'); + db.exec("PRAGMA journal_mode=WAL"); } - db.exec('BEGIN'); + db.exec("BEGIN"); try { db.exec(` CREATE TABLE IF NOT EXISTS schema_version ( @@ -245,24 +252,37 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { ) `); - db.exec('CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)'); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)", + ); // Views — DROP + CREATE since CREATE VIEW IF NOT EXISTS doesn't update definitions - db.exec(`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`); - db.exec(`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`); - db.exec(`CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`); + db.exec( + `CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`, + ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`, + ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`, + ); // Insert schema version if not already present - const existing = db.prepare('SELECT count(*) as cnt FROM schema_version').get(); - if (existing && (existing['cnt'] as number) === 0) { - db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run( - { ':version': SCHEMA_VERSION, ':applied_at': new Date().toISOString() }, - ); + const existing = db + .prepare("SELECT count(*) as cnt FROM schema_version") + .get(); + if (existing && (existing["cnt"] as number) === 0) { + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": SCHEMA_VERSION, + ":applied_at": new Date().toISOString(), + }); } - db.exec('COMMIT'); + db.exec("COMMIT"); } catch (err) { - db.exec('ROLLBACK'); + db.exec("ROLLBACK"); throw err; } @@ -275,12 +295,12 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { * and applies DDL for each version step up to SCHEMA_VERSION. */ function migrateSchema(db: DbAdapter): void { - const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get(); - const currentVersion = row ? (row['v'] as number) : 0; + const row = db.prepare("SELECT MAX(version) as v FROM schema_version").get(); + const currentVersion = row ? (row["v"] as number) : 0; if (currentVersion >= SCHEMA_VERSION) return; - db.exec('BEGIN'); + db.exec("BEGIN"); try { // v1 → v2: add artifacts table if (currentVersion < 2) { @@ -296,9 +316,9 @@ function migrateSchema(db: DbAdapter): void { ) `); - db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run( - { ':version': 2, ':applied_at': new Date().toISOString() }, - ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ ":version": 2, ":applied_at": new Date().toISOString() }); } // v2 → v3: add memories + memory_processed_units tables @@ -327,18 +347,22 @@ function migrateSchema(db: DbAdapter): void { ) `); - db.exec('CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)'); - db.exec('DROP VIEW IF EXISTS active_memories'); - db.exec('CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL'); - - db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run( - { ':version': 3, ':applied_at': new Date().toISOString() }, + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)", ); + db.exec("DROP VIEW IF EXISTS active_memories"); + db.exec( + "CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL", + ); + + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ ":version": 3, ":applied_at": new Date().toISOString() }); } - db.exec('COMMIT'); + db.exec("COMMIT"); } catch (err) { - db.exec('ROLLBACK'); + db.exec("ROLLBACK"); throw err; } } @@ -385,12 +409,16 @@ export function openDatabase(path: string): boolean { if (!rawDb) return false; const adapter = createAdapter(rawDb); - const fileBacked = path !== ':memory:'; + const fileBacked = path !== ":memory:"; try { initSchema(adapter, fileBacked); } catch (err) { - try { adapter.close(); } catch { /* swallow */ } + try { + adapter.close(); + } catch { + /* swallow */ + } throw err; } @@ -420,14 +448,15 @@ export function closeDatabase(): void { * Runs a function inside a transaction. Rolls back on error. */ export function transaction(fn: () => T): T { - if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); - currentDb.exec('BEGIN'); + if (!currentDb) + throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb.exec("BEGIN"); try { const result = fn(); - currentDb.exec('COMMIT'); + currentDb.exec("COMMIT"); return result; } catch (err) { - currentDb.exec('ROLLBACK'); + currentDb.exec("ROLLBACK"); throw err; } } @@ -437,21 +466,24 @@ export function transaction(fn: () => T): T { /** * Insert a decision. The `seq` field is auto-generated. */ -export function insertDecision(d: Omit): void { - if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); - currentDb.prepare( - `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) +export function insertDecision(d: Omit): void { + if (!currentDb) + throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb + .prepare( + `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, - ).run({ - ':id': d.id, - ':when_context': d.when_context, - ':scope': d.scope, - ':decision': d.decision, - ':choice': d.choice, - ':rationale': d.rationale, - ':revisable': d.revisable, - ':superseded_by': d.superseded_by, - }); + ) + .run({ + ":id": d.id, + ":when_context": d.when_context, + ":scope": d.scope, + ":decision": d.decision, + ":choice": d.choice, + ":rationale": d.rationale, + ":revisable": d.revisable, + ":superseded_by": d.superseded_by, + }); } /** @@ -459,18 +491,18 @@ export function insertDecision(d: Omit): void { */ export function getDecisionById(id: string): Decision | null { if (!currentDb) return null; - const row = currentDb.prepare('SELECT * FROM decisions WHERE id = ?').get(id); + const row = currentDb.prepare("SELECT * FROM decisions WHERE id = ?").get(id); if (!row) return null; return { - seq: row['seq'] as number, - id: row['id'] as string, - when_context: row['when_context'] as string, - scope: row['scope'] as string, - decision: row['decision'] as string, - choice: row['choice'] as string, - rationale: row['rationale'] as string, - revisable: row['revisable'] as string, - superseded_by: (row['superseded_by'] as string) ?? null, + seq: row["seq"] as number, + id: row["id"] as string, + when_context: row["when_context"] as string, + scope: row["scope"] as string, + decision: row["decision"] as string, + choice: row["choice"] as string, + rationale: row["rationale"] as string, + revisable: row["revisable"] as string, + superseded_by: (row["superseded_by"] as string) ?? null, }; } @@ -479,16 +511,16 @@ export function getDecisionById(id: string): Decision | null { */ export function getActiveDecisions(): Decision[] { if (!currentDb) return []; - const rows = currentDb.prepare('SELECT * FROM active_decisions').all(); - return rows.map(row => ({ - seq: row['seq'] as number, - id: row['id'] as string, - when_context: row['when_context'] as string, - scope: row['scope'] as string, - decision: row['decision'] as string, - choice: row['choice'] as string, - rationale: row['rationale'] as string, - revisable: row['revisable'] as string, + const rows = currentDb.prepare("SELECT * FROM active_decisions").all(); + return rows.map((row) => ({ + seq: row["seq"] as number, + id: row["id"] as string, + when_context: row["when_context"] as string, + scope: row["scope"] as string, + decision: row["decision"] as string, + choice: row["choice"] as string, + rationale: row["rationale"] as string, + revisable: row["revisable"] as string, superseded_by: null, })); } @@ -499,24 +531,27 @@ export function getActiveDecisions(): Decision[] { * Insert a requirement. */ export function insertRequirement(r: Requirement): void { - if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); - currentDb.prepare( - `INSERT INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by) + if (!currentDb) + throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb + .prepare( + `INSERT INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by) VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`, - ).run({ - ':id': r.id, - ':class': r.class, - ':status': r.status, - ':description': r.description, - ':why': r.why, - ':source': r.source, - ':primary_owner': r.primary_owner, - ':supporting_slices': r.supporting_slices, - ':validation': r.validation, - ':notes': r.notes, - ':full_content': r.full_content, - ':superseded_by': r.superseded_by, - }); + ) + .run({ + ":id": r.id, + ":class": r.class, + ":status": r.status, + ":description": r.description, + ":why": r.why, + ":source": r.source, + ":primary_owner": r.primary_owner, + ":supporting_slices": r.supporting_slices, + ":validation": r.validation, + ":notes": r.notes, + ":full_content": r.full_content, + ":superseded_by": r.superseded_by, + }); } /** @@ -524,21 +559,23 @@ export function insertRequirement(r: Requirement): void { */ export function getRequirementById(id: string): Requirement | null { if (!currentDb) return null; - const row = currentDb.prepare('SELECT * FROM requirements WHERE id = ?').get(id); + const row = currentDb + .prepare("SELECT * FROM requirements WHERE id = ?") + .get(id); if (!row) return null; return { - id: row['id'] as string, - class: row['class'] as string, - status: row['status'] as string, - description: row['description'] as string, - why: row['why'] as string, - source: row['source'] as string, - primary_owner: row['primary_owner'] as string, - supporting_slices: row['supporting_slices'] as string, - validation: row['validation'] as string, - notes: row['notes'] as string, - full_content: row['full_content'] as string, - superseded_by: (row['superseded_by'] as string) ?? null, + id: row["id"] as string, + class: row["class"] as string, + status: row["status"] as string, + description: row["description"] as string, + why: row["why"] as string, + source: row["source"] as string, + primary_owner: row["primary_owner"] as string, + supporting_slices: row["supporting_slices"] as string, + validation: row["validation"] as string, + notes: row["notes"] as string, + full_content: row["full_content"] as string, + superseded_by: (row["superseded_by"] as string) ?? null, }; } @@ -547,19 +584,19 @@ export function getRequirementById(id: string): Requirement | null { */ export function getActiveRequirements(): Requirement[] { if (!currentDb) return []; - const rows = currentDb.prepare('SELECT * FROM active_requirements').all(); - return rows.map(row => ({ - id: row['id'] as string, - class: row['class'] as string, - status: row['status'] as string, - description: row['description'] as string, - why: row['why'] as string, - source: row['source'] as string, - primary_owner: row['primary_owner'] as string, - supporting_slices: row['supporting_slices'] as string, - validation: row['validation'] as string, - notes: row['notes'] as string, - full_content: row['full_content'] as string, + const rows = currentDb.prepare("SELECT * FROM active_requirements").all(); + return rows.map((row) => ({ + id: row["id"] as string, + class: row["class"] as string, + status: row["status"] as string, + description: row["description"] as string, + why: row["why"] as string, + source: row["source"] as string, + primary_owner: row["primary_owner"] as string, + supporting_slices: row["supporting_slices"] as string, + validation: row["validation"] as string, + notes: row["notes"] as string, + full_content: row["full_content"] as string, superseded_by: null, })); } @@ -602,45 +639,51 @@ export function _resetProvider(): void { /** * Insert or replace a decision. Uses the `id` UNIQUE constraint for idempotency. */ -export function upsertDecision(d: Omit): void { - if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); - currentDb.prepare( - `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) +export function upsertDecision(d: Omit): void { + if (!currentDb) + throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb + .prepare( + `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, - ).run({ - ':id': d.id, - ':when_context': d.when_context, - ':scope': d.scope, - ':decision': d.decision, - ':choice': d.choice, - ':rationale': d.rationale, - ':revisable': d.revisable, - ':superseded_by': d.superseded_by ?? null, - }); + ) + .run({ + ":id": d.id, + ":when_context": d.when_context, + ":scope": d.scope, + ":decision": d.decision, + ":choice": d.choice, + ":rationale": d.rationale, + ":revisable": d.revisable, + ":superseded_by": d.superseded_by ?? null, + }); } /** * Insert or replace a requirement. Uses the `id` PK for idempotency. */ export function upsertRequirement(r: Requirement): void { - if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); - currentDb.prepare( - `INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by) + if (!currentDb) + throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb + .prepare( + `INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by) VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`, - ).run({ - ':id': r.id, - ':class': r.class, - ':status': r.status, - ':description': r.description, - ':why': r.why, - ':source': r.source, - ':primary_owner': r.primary_owner, - ':supporting_slices': r.supporting_slices, - ':validation': r.validation, - ':notes': r.notes, - ':full_content': r.full_content, - ':superseded_by': r.superseded_by ?? null, - }); + ) + .run({ + ":id": r.id, + ":class": r.class, + ":status": r.status, + ":description": r.description, + ":why": r.why, + ":source": r.source, + ":primary_owner": r.primary_owner, + ":supporting_slices": r.supporting_slices, + ":validation": r.validation, + ":notes": r.notes, + ":full_content": r.full_content, + ":superseded_by": r.superseded_by ?? null, + }); } /** @@ -655,7 +698,7 @@ export function upsertRequirement(r: Requirement): void { export function clearArtifacts(): void { if (!currentDb) return; try { - currentDb.exec('DELETE FROM artifacts'); + currentDb.exec("DELETE FROM artifacts"); } catch { // Clearing a cache should never be fatal } @@ -669,17 +712,169 @@ export function insertArtifact(a: { task_id: string | null; full_content: string; }): void { - if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open'); - currentDb.prepare( - `INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at) + if (!currentDb) + throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb + .prepare( + `INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at) VALUES (:path, :artifact_type, :milestone_id, :slice_id, :task_id, :full_content, :imported_at)`, - ).run({ - ':path': a.path, - ':artifact_type': a.artifact_type, - ':milestone_id': a.milestone_id, - ':slice_id': a.slice_id, - ':task_id': a.task_id, - ':full_content': a.full_content, - ':imported_at': new Date().toISOString(), - }); + ) + .run({ + ":path": a.path, + ":artifact_type": a.artifact_type, + ":milestone_id": a.milestone_id, + ":slice_id": a.slice_id, + ":task_id": a.task_id, + ":full_content": a.full_content, + ":imported_at": new Date().toISOString(), + }); +} + +// ─── Worktree DB Helpers ────────────────────────────────────────────────── + +export function copyWorktreeDb(srcDbPath: string, destDbPath: string): boolean { + try { + if (!existsSync(srcDbPath)) return false; + const destDir = dirname(destDbPath); + mkdirSync(destDir, { recursive: true }); + copyFileSync(srcDbPath, destDbPath); + return true; + } catch (err) { + process.stderr.write( + `gsd-db: failed to copy DB to worktree: ${(err as Error).message}\n`, + ); + return false; + } +} + +export function reconcileWorktreeDb( + mainDbPath: string, + worktreeDbPath: string, +): { + decisions: number; + requirements: number; + artifacts: number; + conflicts: string[]; +} { + const zero = { + decisions: 0, + requirements: 0, + artifacts: 0, + conflicts: [] as string[], + }; + if (!existsSync(worktreeDbPath)) return zero; + if (worktreeDbPath.includes("'")) { + process.stderr.write( + `gsd-db: worktree DB reconciliation failed: path contains unsafe characters\n`, + ); + return zero; + } + if (!currentDb) { + const opened = openDatabase(mainDbPath); + if (!opened) { + process.stderr.write( + `gsd-db: worktree DB reconciliation failed: cannot open main DB\n`, + ); + return zero; + } + } + const adapter = currentDb!; + const conflicts: string[] = []; + try { + adapter.exec(`ATTACH DATABASE '${worktreeDbPath}' AS wt`); + try { + const decConf = adapter + .prepare( + `SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR m.superseded_by IS NOT w.superseded_by`, + ) + .all(); + for (const row of decConf) + conflicts.push( + `decision ${(row as Record)["id"]}: modified in both`, + ); + const reqConf = adapter + .prepare( + `SELECT m.id FROM requirements m INNER JOIN wt.requirements w ON m.id = w.id WHERE m.description != w.description OR m.status != w.status OR m.notes != w.notes OR m.superseded_by IS NOT w.superseded_by`, + ) + .all(); + for (const row of reqConf) + conflicts.push( + `requirement ${(row as Record)["id"]}: modified in both`, + ); + const merged = { decisions: 0, requirements: 0, artifacts: 0 }; + adapter.exec("BEGIN"); + try { + const dR = adapter + .prepare( + ` + INSERT OR REPLACE INTO decisions ( + id, when_context, scope, decision, choice, rationale, revisable, superseded_by + ) + SELECT + id, when_context, scope, decision, choice, rationale, revisable, superseded_by + FROM wt.decisions + `, + ) + .run(); + merged.decisions = + typeof dR === "object" && dR !== null + ? ((dR as { changes?: number }).changes ?? 0) + : 0; + const rR = adapter + .prepare( + ` + INSERT OR REPLACE INTO requirements ( + id, class, status, description, why, source, primary_owner, + supporting_slices, validation, notes, full_content, superseded_by + ) + SELECT + id, class, status, description, why, source, primary_owner, + supporting_slices, validation, notes, full_content, superseded_by + FROM wt.requirements + `, + ) + .run(); + merged.requirements = + typeof rR === "object" && rR !== null + ? ((rR as { changes?: number }).changes ?? 0) + : 0; + const aR = adapter + .prepare( + ` + INSERT OR REPLACE INTO artifacts ( + path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at + ) + SELECT + path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at + FROM wt.artifacts + `, + ) + .run(); + merged.artifacts = + typeof aR === "object" && aR !== null + ? ((aR as { changes?: number }).changes ?? 0) + : 0; + adapter.exec("COMMIT"); + } catch (txErr) { + try { + adapter.exec("ROLLBACK"); + } catch { + /* best-effort */ + } + throw txErr; + } + return { ...merged, conflicts }; + } finally { + try { + adapter.exec("DETACH DATABASE wt"); + } catch { + /* best-effort */ + } + } + } catch (err) { + process.stderr.write( + `gsd-db: worktree DB reconciliation failed: ${(err as Error).message}\n`, + ); + return { ...zero, conflicts }; + } } diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 39b3a3887..37fae0bcd 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -23,24 +23,31 @@ import type { ExtensionCommandContext, ExtensionContext, } from "@gsd/pi-coding-agent"; -import { - createBashTool, - createEditTool, - createReadTool, - createWriteTool, - importExtensionModule, - isToolCallEventType, -} from "@gsd/pi-coding-agent"; +import { createBashTool, createWriteTool, createReadTool, createEditTool, isToolCallEventType } from "@gsd/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { debugLog, debugTime } from "./debug-logger.js"; -import { registerLazyGSDCommand } from "./commands-bootstrap.js"; +import { registerGSDCommand } from "./commands.js"; import { loadToolApiKeys } from "./commands-config.js"; import { registerExitCommand } from "./exit-command.js"; -import { registerLazyWorktreeCommands } from "./worktree-command-bootstrap.js"; +import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js"; +import { getActiveAutoWorktreeContext } from "./auto-worktree.js"; import { saveFile, formatContinue, loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; +import { deriveState } from "./state.js"; +import { isAutoActive, isAutoPaused, pauseAuto, getAutoDashboardData, getAutoModeStartModel, markToolStart, markToolEnd } from "./auto.js"; +import { isSessionSwitchInFlight, resolveAgentEnd } from "./auto-loop.js"; import { saveActivityLog } from "./activity-log.js"; +import { checkAutoStartAfterDiscuss, getDiscussionMilestoneId, findMilestoneIds, nextMilestoneId } from "./guided-flow.js"; +import { GSDDashboardOverlay } from "./dashboard-overlay.js"; +import { + loadEffectiveGSDPreferences, + renderPreferencesForSystemPrompt, + resolveAllSkillReferences, + resolveModelWithFallbacksForUnit, + getNextFallbackModel, + isTransientNetworkError, +} from "./preferences.js"; import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "./skill-discovery.js"; import { resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTaskFiles, resolveTasksDir, @@ -54,48 +61,10 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { shortcutDesc } from "../shared/mod.js"; import { Text } from "@gsd/pi-tui"; +import { pauseAutoForProviderError, classifyProviderError } from "./provider-error-pause.js"; import { toPosixPath } from "../shared/mod.js"; +import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js"; import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js"; -import { getErrorMessage } from "./error-utils.js"; - -function memoizeImport(loader: () => Promise): () => Promise { - let promise: Promise | null = null; - return () => { - if (!promise) { - promise = loader(); - } - return promise; - }; -} - -const loadAutoModule = memoizeImport(() => importExtensionModule(import.meta.url, "./auto.js")); -const loadStateModule = memoizeImport(() => importExtensionModule(import.meta.url, "./state.js")); -const loadGuidedFlowModule = memoizeImport(() => importExtensionModule(import.meta.url, "./guided-flow.js")); -const loadPreferencesModule = memoizeImport(() => importExtensionModule(import.meta.url, "./preferences.js")); -const loadDashboardOverlayModule = memoizeImport(() => importExtensionModule(import.meta.url, "./dashboard-overlay.js")); -const loadWorktreeCommandModule = memoizeImport(() => importExtensionModule(import.meta.url, "./worktree-command.js")); -const loadAutoWorktreeModule = memoizeImport(() => importExtensionModule(import.meta.url, "./auto-worktree.js")); -const loadProviderErrorPauseModule = memoizeImport(() => importExtensionModule(import.meta.url, "./provider-error-pause.js")); -const loadParallelOrchestratorModule = memoizeImport(() => importExtensionModule(import.meta.url, "./parallel-orchestrator.js")); - -/** - * Ensure the GSD database is available, auto-initializing if needed. - * Returns true if the DB is ready, false if initialization failed. - */ -async function ensureDbAvailable(): Promise { - try { - const db = await importExtensionModule(import.meta.url, "./gsd-db.js"); - if (db.isDbAvailable()) return true; - - // Auto-initialize: open (and create if needed) the DB at the standard path - const gsdDir = gsdRoot(process.cwd()); - if (!existsSync(gsdDir)) return false; // No GSD project — can't create DB - const dbPath = join(gsdDir, "gsd.db"); - return db.openDatabase(dbPath); - } catch { - return false; - } -} // ── Agent Instructions ──────────────────────────────────────────────────── // Lightweight "always follow" files injected into every GSD agent session. @@ -126,9 +95,7 @@ function loadAgentInstructions(): string | null { } // ── Depth verification state ────────────────────────────────────────────── -// Tracks which milestones have passed depth verification. -// Single-milestone flows set '*' (wildcard). Multi-milestone flows set per-ID. -const depthVerifiedMilestones = new Set(); +let depthVerificationDone = false; // ── Queue phase tracking ────────────────────────────────────────────────── // When true, the LLM is in a queue flow writing CONTEXT.md files. @@ -139,28 +106,11 @@ let activeQueuePhase = false; // Tracks per-model retry attempts for transient network errors. // Cleared when a model switch occurs or retries are exhausted. const networkRetryCounters = new Map(); - -// ── Transient error escalation ─────────────────────────────────────────── -// Tracks consecutive transient auto-resume attempts. Each attempt doubles -// the delay. After MAX_TRANSIENT_AUTO_RESUMES consecutive failures, auto-mode -// pauses indefinitely to avoid infinite rapid-fire retries (#1166). -const MAX_TRANSIENT_AUTO_RESUMES = 5; +const MAX_TRANSIENT_AUTO_RESUMES = 3; let consecutiveTransientErrors = 0; export function isDepthVerified(): boolean { - return depthVerifiedMilestones.has("*") || depthVerifiedMilestones.size > 0; -} - -/** Check whether a specific milestone has passed depth verification. */ -export function isDepthVerifiedFor(milestoneId: string): boolean { - // Wildcard means "all milestones verified" (single-milestone flow) - if (depthVerifiedMilestones.has("*")) return true; - return depthVerifiedMilestones.has(milestoneId); -} - -/** Mark a specific milestone as depth-verified. */ -export function markDepthVerified(milestoneId: string): void { - depthVerifiedMilestones.add(milestoneId); + return depthVerificationDone; } /** Check whether a queue phase is active. */ @@ -191,25 +141,11 @@ export function shouldBlockContextWrite( if (!inDiscussion && !inQueue) return { block: false }; if (!MILESTONE_CONTEXT_RE.test(inputPath)) return { block: false }; - - // For discussion flows: check global depth verification (backward compat) - if (inDiscussion && depthVerified) return { block: false }; - - // For queue flows: extract milestone ID from the path and check per-milestone verification - if (inQueue) { - const pathMatch = inputPath.match(/\/(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/); - const targetMid = pathMatch?.[1]; - if (targetMid && depthVerifiedMilestones.has(targetMid)) return { block: false }; - // Wildcard passes all - if (depthVerifiedMilestones.has("*")) return { block: false }; - } + if (depthVerified) return { block: false }; return { block: true, - reason: `Blocked: Cannot write milestone CONTEXT.md without depth verification. ` + - `Use ask_user_questions with a question id containing "depth_verification" first. ` + - `For multi-milestone flows, include the milestone ID in the question id (e.g., "depth_verification_M001"). ` + - `This ensures each milestone's context has been critically examined before being written.`, + reason: `Blocked: Cannot write to milestone CONTEXT.md during discussion phase without depth verification. Call ask_user_questions with question id "depth_verification" first to confirm discussion depth before writing context.`, }; } @@ -224,8 +160,8 @@ const GSD_LOGO_LINES = [ ]; export default function (pi: ExtensionAPI) { - registerLazyGSDCommand(pi); - registerLazyWorktreeCommands(pi); + registerGSDCommand(pi); + registerWorktreeCommand(pi); registerExitCommand(pi); // ── EPIPE guard — prevent crash when stdout/stderr pipe closes unexpectedly ── @@ -235,22 +171,11 @@ export default function (pi: ExtensionAPI) { // chance to persist state and pause instead of crashing (see issue #739). if (!process.listeners("uncaughtException").some(l => l.name === "_gsdEpipeGuard")) { const _gsdEpipeGuard = (err: Error): void => { - const code = (err as NodeJS.ErrnoException).code; - if (code === "EPIPE") { + if ((err as NodeJS.ErrnoException).code === "EPIPE") { // Pipe closed — nothing we can write; just exit cleanly process.exit(0); } - // ECOMPROMISED: proper-lockfile's update timer detected mtime drift (system - // sleep, heavy event loop stall, or filesystem precision mismatch on Node.js - // v25+). The onCompromised callback already set _lockCompromised = true, but - // due to a subtle interaction between the synchronous fs adapter and the - // setTimeout boundary, the error can still propagate here as an uncaught - // exception. Exit cleanly so the process.once("exit") handler removes the - // lock directory — allowing the next session to acquire cleanly (#1322). - if (code === "ECOMPROMISED") { - process.exit(1); - } - // Re-throw anything that isn't EPIPE or ECOMPROMISED so real crashes still surface + // Re-throw anything that isn't EPIPE so real crashes still surface throw err; }; process.on("uncaughtException", _gsdEpipeGuard); @@ -371,8 +296,14 @@ export default function (pi: ExtensionAPI) { when_context: Type.Optional(Type.String({ description: "When/context for the decision (e.g. milestone ID)" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - // Ensure DB is available (auto-initialize if needed) - if (!await ensureDbAvailable()) { + // Check DB availability + let dbAvailable = false; + try { + const db = await import("./gsd-db.js"); + dbAvailable = db.isDbAvailable(); + } catch { /* dynamic import failed */ } + + if (!dbAvailable) { return { content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save decision." }], isError: true, @@ -381,7 +312,7 @@ export default function (pi: ExtensionAPI) { } try { - const { saveDecisionToDb } = await importExtensionModule(import.meta.url, "./db-writer.js"); + const { saveDecisionToDb } = await import("./db-writer.js"); const { id } = await saveDecisionToDb( { scope: params.scope, @@ -398,7 +329,7 @@ export default function (pi: ExtensionAPI) { details: { operation: "save_decision", id }, }; } catch (err) { - const msg = getErrorMessage(err); + const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`gsd-db: gsd_save_decision tool failed: ${msg}\n`); return { content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }], @@ -432,8 +363,13 @@ export default function (pi: ExtensionAPI) { supporting_slices: Type.Optional(Type.String({ description: "Supporting slices" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - // Ensure DB is available (auto-initialize if needed) - if (!await ensureDbAvailable()) { + let dbAvailable = false; + try { + const db = await import("./gsd-db.js"); + dbAvailable = db.isDbAvailable(); + } catch { /* dynamic import failed */ } + + if (!dbAvailable) { return { content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot update requirement." }], isError: true, @@ -443,7 +379,7 @@ export default function (pi: ExtensionAPI) { try { // Verify requirement exists - const db = await importExtensionModule(import.meta.url, "./gsd-db.js"); + const db = await import("./gsd-db.js"); const existing = db.getRequirementById(params.id); if (!existing) { return { @@ -453,7 +389,7 @@ export default function (pi: ExtensionAPI) { }; } - const { updateRequirementInDb } = await importExtensionModule(import.meta.url, "./db-writer.js"); + const { updateRequirementInDb } = await import("./db-writer.js"); const updates: Record = {}; if (params.status !== undefined) updates.status = params.status; if (params.validation !== undefined) updates.validation = params.validation; @@ -469,7 +405,7 @@ export default function (pi: ExtensionAPI) { details: { operation: "update_requirement", id: params.id }, }; } catch (err) { - const msg = getErrorMessage(err); + const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`gsd-db: gsd_update_requirement tool failed: ${msg}\n`); return { content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }], @@ -501,8 +437,13 @@ export default function (pi: ExtensionAPI) { content: Type.String({ description: "The full markdown content of the artifact" }), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - // Ensure DB is available (auto-initialize if needed) - if (!await ensureDbAvailable()) { + let dbAvailable = false; + try { + const db = await import("./gsd-db.js"); + dbAvailable = db.isDbAvailable(); + } catch { /* dynamic import failed */ } + + if (!dbAvailable) { return { content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save artifact." }], isError: true, @@ -531,7 +472,7 @@ export default function (pi: ExtensionAPI) { relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`; } - const { saveArtifactToDb } = await importExtensionModule(import.meta.url, "./db-writer.js"); + const { saveArtifactToDb } = await import("./db-writer.js"); await saveArtifactToDb( { path: relativePath, @@ -549,7 +490,7 @@ export default function (pi: ExtensionAPI) { details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type }, }; } catch (err) { - const msg = getErrorMessage(err); + const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`gsd-db: gsd_save_summary tool failed: ${msg}\n`); return { content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }], @@ -586,10 +527,6 @@ export default function (pi: ExtensionAPI) { parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { try { - const [{ findMilestoneIds, nextMilestoneId }, { loadEffectiveGSDPreferences }] = await Promise.all([ - loadGuidedFlowModule(), - loadPreferencesModule(), - ]); const basePath = process.cwd(); const existingIds = findMilestoneIds(basePath); const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; @@ -602,7 +539,7 @@ export default function (pi: ExtensionAPI) { details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, reservedCount: reservedMilestoneIds.size, uniqueEnabled }, }; } catch (err) { - const msg = getErrorMessage(err); + const msg = err instanceof Error ? err.message : String(err); return { content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }], isError: true, @@ -614,9 +551,8 @@ export default function (pi: ExtensionAPI) { // ── session_start: render branded GSD header + load tool keys + remote status ── pi.on("session_start", async (_event, ctx) => { - // Clear depth verification and queue phase state from any prior session - depthVerifiedMilestones.clear(); - activeQueuePhase = false; + // Clear per-session state that must not leak across sessions (e.g. RPC mode) + depthVerificationDone = false; // Theme access throws in RPC mode (no TUI) — header is decorative, skip it try { @@ -635,17 +571,11 @@ export default function (pi: ExtensionAPI) { // Load tool API keys from auth.json into environment loadToolApiKeys(); - // Always-on health widget — ambient system health signal below the editor - try { - const { initHealthWidget } = await importExtensionModule(import.meta.url, "./health-widget.js"); - initHealthWidget(ctx); - } catch { /* non-fatal — widget is best-effort */ } - // Notify remote questions status if configured try { const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ - importExtensionModule(import.meta.url, "../remote-questions/config.js"), - importExtensionModule(import.meta.url, "../remote-questions/status.js"), + import("../remote-questions/config.js"), + import("../remote-questions/status.js"), ]); const status = getRemoteConfigStatus(); const latest = getLatestPromptSummary(); @@ -663,13 +593,12 @@ export default function (pi: ExtensionAPI) { description: shortcutDesc("Open GSD dashboard", "/gsd status"), handler: async (ctx) => { // Only show if .gsd/ exists - if (!existsSync(gsdRoot(process.cwd()))) { + if (!existsSync(join(process.cwd(), ".gsd"))) { ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info"); return; } - const { GSDDashboardOverlay } = await loadDashboardOverlayModule(); - const result = await ctx.ui.custom( + await ctx.ui.custom( (tui, theme, _kb, done) => { return new GSDDashboardOverlay(tui, theme, () => done()); }, @@ -683,23 +612,15 @@ export default function (pi: ExtensionAPI) { }, }, ); - - // Fallback for RPC mode where ctx.ui.custom() returns undefined. - if (result === undefined) { - const { fireStatusViaCommand } = await importExtensionModule(import.meta.url, "./commands.js"); - await fireStatusViaCommand(ctx); - } }, }); // ── before_agent_start: inject GSD contract into true system prompt ───── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { - if (!existsSync(gsdRoot(process.cwd()))) return; + if (!existsSync(join(process.cwd(), ".gsd"))) return; const stopContextTimer = debugTime("context-inject"); const systemContent = loadPrompt("system"); - const { loadEffectiveGSDPreferences, resolveAllSkillReferences, renderPreferencesForSystemPrompt } = - await loadPreferencesModule(); const loadedPreferences = loadEffectiveGSDPreferences(); let preferenceBlock = ""; if (loadedPreferences) { @@ -733,7 +654,7 @@ export default function (pi: ExtensionAPI) { // Inject auto-learned project memories let memoryBlock = ""; try { - const { getActiveMemoriesRanked, formatMemoriesForPrompt } = await importExtensionModule(import.meta.url, "./memory-store.js"); + const { getActiveMemoriesRanked, formatMemoriesForPrompt } = await import("./memory-store.js"); const memories = getActiveMemoriesRanked(30); if (memories.length > 0) { const formatted = formatMemoriesForPrompt(memories, 2000); @@ -763,10 +684,6 @@ export default function (pi: ExtensionAPI) { // Worktree context — override the static CWD in the system prompt let worktreeBlock = ""; - const [{ getActiveWorktreeName, getWorktreeOriginalCwd }, { getActiveAutoWorktreeContext }] = await Promise.all([ - loadWorktreeCommandModule(), - loadAutoWorktreeModule(), - ]); const worktreeName = getActiveWorktreeName(); const worktreeMainCwd = getWorktreeOriginalCwd(); const autoWorktree = getActiveAutoWorktreeContext(); @@ -830,37 +747,9 @@ export default function (pi: ExtensionAPI) { // ── agent_end: auto-mode advancement or auto-start after discuss ─────────── pi.on("agent_end", async (event, ctx: ExtensionContext) => { - const [ - { - isAutoActive, - pauseAuto, - getAutoDashboardData, - getAutoModeStartModel, - handleAgentEnd, - }, - { checkAutoStartAfterDiscuss }, - { - isTransientNetworkError, - resolveModelWithFallbacksForUnit, - getNextFallbackModel, - }, - { classifyProviderError, pauseAutoForProviderError }, - ] = await Promise.all([ - loadAutoModule(), - loadGuidedFlowModule(), - loadPreferencesModule(), - loadProviderErrorPauseModule(), - ]); - - // Clean up quick-task branch if one just completed (#1269) - try { - const { cleanupQuickBranch } = await importExtensionModule(import.meta.url, "./quick.js"); - cleanupQuickBranch(); - } catch { /* non-fatal */ } - // If discuss phase just finished, start auto-mode if (checkAutoStartAfterDiscuss()) { - depthVerifiedMilestones.clear(); + depthVerificationDone = false; activeQueuePhase = false; return; } @@ -868,6 +757,13 @@ export default function (pi: ExtensionAPI) { // If auto-mode is already running, advance to next unit if (!isAutoActive()) return; + // Fresh-session auto-mode intentionally aborts the previous session during + // cmdCtx.newSession(). Ignore that agent_end so we neither pause nor + // resolve the new unit with an event from the old session. + if (isSessionSwitchInFlight()) { + return; + } + // If the agent was aborted (user pressed Escape) or hit a provider // error (fetch failure, rate limit, etc.), pause auto-mode instead of // advancing. This preserves the conversation so the user can inspect @@ -1007,50 +903,46 @@ export default function (pi: ExtensionAPI) { const explicitRetryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number") ? lastMsg.retryAfterMs : undefined; - let retryAfterMs = explicitRetryAfterMs ?? classification.suggestedDelayMs; - - // ── Escalating backoff for repeated transient errors ────────────── - // Each consecutive transient auto-resume doubles the delay. After - // MAX_TRANSIENT_AUTO_RESUMES consecutive failures, treat as permanent - // to avoid infinite rapid-fire retries (#1166). - let effectiveTransient = classification.isTransient; if (classification.isTransient) { - consecutiveTransientErrors++; - if (consecutiveTransientErrors > MAX_TRANSIENT_AUTO_RESUMES) { - effectiveTransient = false; - ctx.ui.notify( - `${consecutiveTransientErrors} consecutive transient errors. Pausing indefinitely — resume manually with /gsd auto.`, - "error", - ); - consecutiveTransientErrors = 0; - } else { - // Escalate: base delay × 2^(consecutive-1) → 30s, 60s, 120s, 240s, 480s - retryAfterMs = retryAfterMs * 2 ** (consecutiveTransientErrors - 1); - } + consecutiveTransientErrors += 1; + } else { + consecutiveTransientErrors = 0; + } + const baseRetryAfterMs = explicitRetryAfterMs ?? classification.suggestedDelayMs; + const retryAfterMs = classification.isTransient ? baseRetryAfterMs * 2 ** Math.max(0, consecutiveTransientErrors - 1) : baseRetryAfterMs; + const allowAutoResume = classification.isTransient + && consecutiveTransientErrors <= MAX_TRANSIENT_AUTO_RESUMES; + + if (classification.isTransient && !allowAutoResume) { + ctx.ui.notify( + `Transient provider errors persisted after ${MAX_TRANSIENT_AUTO_RESUMES} auto-resume attempts. Pausing for manual review.`, + "warning", + ); } await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi), { isRateLimit: classification.isRateLimit, - isTransient: effectiveTransient, + isTransient: allowAutoResume, retryAfterMs, - resume: () => { - pi.sendMessage( - { customType: "gsd-auto-timeout-recovery", content: "Continue execution \u2014 provider error recovery delay elapsed.", display: false }, - { triggerTurn: true }, - ); - }, + resume: allowAutoResume + ? () => { + pi.sendMessage( + { customType: "gsd-auto-timeout-recovery", content: "Continue execution \u2014 provider error recovery delay elapsed.", display: false }, + { triggerTurn: true }, + ); + } + : undefined, }); return; } try { + consecutiveTransientErrors = 0; networkRetryCounters.clear(); // Clear network retry state on successful unit completion - consecutiveTransientErrors = 0; // Reset escalating backoff on success - await handleAgentEnd(ctx, pi); + resolveAgentEnd(event); } catch (err) { - // Safety net: if handleAgentEnd throws despite its internal try-catch, - // ensure auto-mode stops gracefully instead of silently stalling (#381). - const message = getErrorMessage(err); + // Safety net: if resolveAgentEnd throws, ensure auto-mode stops gracefully (#381). + const message = err instanceof Error ? err.message : String(err); ctx.ui.notify( `Auto-mode error in agent_end handler: ${message}. Stopping auto-mode.`, "error", @@ -1065,11 +957,6 @@ export default function (pi: ExtensionAPI) { // ── session_before_compact ──────────────────────────────────────────────── pi.on("session_before_compact", async (_event, _ctx: ExtensionContext) => { - const [{ isAutoActive, isAutoPaused }, { deriveState }] = await Promise.all([ - loadAutoModule(), - loadStateModule(), - ]); - // Block compaction during auto-mode — each unit is a fresh session // Also block during paused state — context is valuable for the user if (isAutoActive() || isAutoPaused()) { @@ -1116,31 +1003,12 @@ export default function (pi: ExtensionAPI) { // ── session_shutdown: save activity log on Ctrl+C / SIGTERM ───────────── pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => { - const [{ isParallelActive, shutdownParallel }, { isAutoActive, isAutoPaused, getAutoDashboardData }] = - await Promise.all([ - loadParallelOrchestratorModule(), - loadAutoModule(), - ]); - if (isParallelActive()) { try { await shutdownParallel(process.cwd()); } catch { /* best-effort */ } } - // Auto-commit dirty work in CLI-spawned worktrees so nothing is lost. - // The CLI sets GSD_CLI_WORKTREE when launched with -w. - const cliWorktree = process.env.GSD_CLI_WORKTREE; - if (cliWorktree) { - try { - const { autoCommitCurrentBranch } = await importExtensionModule(import.meta.url, "./worktree.js"); - const msg = autoCommitCurrentBranch(process.cwd(), "session-end", cliWorktree); - if (msg) { - ctx.ui.notify(`Auto-committed worktree ${cliWorktree} before exit.`, "info"); - } - } catch { /* best-effort */ } - } - if (!isAutoActive() && !isAutoPaused()) return; // Save the current session — the lock file stays on disk @@ -1151,14 +1019,9 @@ export default function (pi: ExtensionAPI) { } }); - // ── tool_call: block CONTEXT.md writes without depth verification ── - // Active during both discussion flows (pendingAutoStart set) and - // queue flows (activeQueuePhase set). For multi-milestone queue flows, - // each milestone must pass its own depth verification before its - // CONTEXT.md can be written. + // ── tool_call: block CONTEXT.md writes during discussion without depth verification ── pi.on("tool_call", async (event) => { if (!isToolCallEventType("write", event)) return; - const { getDiscussionMilestoneId } = await loadGuidedFlowModule(); const result = shouldBlockContextWrite( event.toolName, event.input.path, @@ -1170,43 +1033,24 @@ export default function (pi: ExtensionAPI) { }); // ── tool_result: persist discussion exchanges & detect depth gate ────── - // Handles both discussion flows and queue flows. For queue flows, - // depth verification question IDs may include milestone IDs - // (e.g., "depth_verification_M001") for per-milestone gating. pi.on("tool_result", async (event) => { if (event.toolName !== "ask_user_questions") return; - const { getDiscussionMilestoneId } = await loadGuidedFlowModule(); const milestoneId = getDiscussionMilestoneId(); - // Queue flows don't set pendingAutoStart, so milestoneId may be null. - // Depth gate detection still applies — it sets per-milestone flags. - const inQueue = activeQueuePhase; + if (!milestoneId) return; const details = event.details as any; if (details?.cancelled || !details?.response) return; // ── Depth gate detection ────────────────────────────────────────── - // Supports two patterns: - // 1. "depth_verification" — wildcard, marks all milestones verified - // 2. "depth_verification_M001" — per-milestone verification const questions: any[] = (event.input as any)?.questions ?? []; for (const q of questions) { if (typeof q.id === "string" && q.id.includes("depth_verification")) { - // Extract milestone ID from question ID if present - const midMatch = q.id.match(/depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i); - if (midMatch) { - depthVerifiedMilestones.add(midMatch[1]); - } else { - // Wildcard — all milestones verified (backward compat for single-milestone) - depthVerifiedMilestones.add("*"); - } + depthVerificationDone = true; break; } } - // Discussion persistence only applies when in a discussion flow with a known milestone - if (!milestoneId) return; - // ── Persist exchange to DISCUSSION.md ────────────────────────────── const basePath = process.cwd(); const milestoneDir = resolveMilestonePath(basePath, milestoneId); @@ -1252,13 +1096,11 @@ export default function (pi: ExtensionAPI) { // ── tool_execution_start/end: track in-flight tools for idle detection ── pi.on("tool_execution_start", async (event) => { - const { isAutoActive, markToolStart } = await loadAutoModule(); if (!isAutoActive()) return; markToolStart(event.toolCallId); }); pi.on("tool_execution_end", async (event) => { - const { markToolEnd } = await loadAutoModule(); markToolEnd(event.toolCallId); }); } @@ -1273,7 +1115,6 @@ async function buildGuidedExecuteContextInjection(prompt: string, basePath: stri const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i); if (resumeMatch) { const [, sliceId, milestoneId] = resumeMatch; - const { deriveState } = await loadStateModule(); const state = await deriveState(basePath); if ( state.activeMilestone?.id === milestoneId && diff --git a/src/resources/extensions/gsd/mechanical-completion.ts b/src/resources/extensions/gsd/mechanical-completion.ts deleted file mode 100644 index f00a4cb32..000000000 --- a/src/resources/extensions/gsd/mechanical-completion.ts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Mechanical Completion — deterministic post-verification artifact generation. - * - * Pure functions that aggregate task-level outputs into slice/milestone summaries, - * UAT stubs, roadmap checkbox updates, and validation reports. Zero orchestration - * dependencies — operates on filesystem paths and parsed structures only. - * - * ADR-003: replaces LLM-driven complete-slice and validate-milestone units with - * mechanical aggregation when the data is sufficient. - */ - -import { readFileSync, existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { atomicWriteSync } from "./atomic-write.js"; -import { loadFile, parseSummary } from "./files.js"; -import { extractMarkdownSection } from "./auto-prompts.js"; -import { - resolveTaskFiles, - resolveTaskJsonFiles, - resolveTasksDir, - resolveSliceFile, - resolveSlicePath, - resolveMilestoneFile, - resolveMilestonePath, - resolveGsdRootFile, -} from "./paths.js"; -import type { Summary, SummaryFrontmatter } from "./types.js"; -import type { EvidenceJSON } from "./verification-evidence.js"; - -// ─── Slice Completion ──────────────────────────────────────────────────────── - -/** - * Mechanically complete a slice by aggregating task summaries into: - * - S##-SUMMARY.md (aggregated frontmatter + task one-liners) - * - S##-UAT.md (extracted from plan Verification section) - * - Roadmap checkbox [x] update - * - * Returns true if completion succeeded, false if data is insufficient - * (serves as quality gate — caller falls back to LLM completion). - */ -export async function mechanicalSliceCompletion( - base: string, mid: string, sid: string, -): Promise { - const tDir = resolveTasksDir(base, mid, sid); - if (!tDir) return false; - - // Read all task summaries - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); - if (summaryFiles.length === 0) return false; - - const taskSummaries: Array<{ taskId: string; summary: Summary }> = []; - for (const file of summaryFiles) { - const content = readFileSync(join(tDir, file), "utf-8"); - if (!content.trim()) continue; - const summary = parseSummary(content); - const taskId = file.match(/^(T\d+)/)?.[1] ?? file; - taskSummaries.push({ taskId, summary }); - } - - if (taskSummaries.length === 0) return false; - - // Quality gate: multi-task slices need substantive summaries - if (taskSummaries.length > 1) { - const totalContent = taskSummaries - .map(ts => ts.summary.whatHappened || ts.summary.oneLiner || "") - .join(""); - if (totalContent.length < 200) return false; - } - - // Aggregate frontmatter - const aggregated = aggregateFrontmatter(taskSummaries.map(ts => ts.summary.frontmatter)); - - // Build SUMMARY.md - const summaryLines: string[] = [ - "---", - `id: ${sid}`, - `parent: ${mid}`, - `milestone: ${mid}`, - ]; - if (aggregated.provides.length > 0) - summaryLines.push(`provides:\n${aggregated.provides.map(p => ` - ${p}`).join("\n")}`); - if (aggregated.key_files.length > 0) - summaryLines.push(`key_files:\n${aggregated.key_files.map(f => ` - ${f}`).join("\n")}`); - if (aggregated.key_decisions.length > 0) - summaryLines.push(`key_decisions:\n${aggregated.key_decisions.map(d => ` - ${d}`).join("\n")}`); - if (aggregated.patterns_established.length > 0) - summaryLines.push(`patterns_established:\n${aggregated.patterns_established.map(p => ` - ${p}`).join("\n")}`); - if (aggregated.affects.length > 0) - summaryLines.push(`affects:\n${aggregated.affects.map(a => ` - ${a}`).join("\n")}`); - if (aggregated.observability_surfaces.length > 0) - summaryLines.push(`observability_surfaces:\n${aggregated.observability_surfaces.map(o => ` - ${o}`).join("\n")}`); - const allPassed = taskSummaries.every(ts => ts.summary.frontmatter.verification_result === "passed"); - summaryLines.push(`verification_result: ${allPassed ? "passed" : "mixed"}`); - summaryLines.push(`completed_at: ${new Date().toISOString()}`); - summaryLines.push("---"); - summaryLines.push(""); - summaryLines.push(`# ${sid}: Slice Summary`); - summaryLines.push(""); - - // Task one-liners - for (const { taskId, summary } of taskSummaries) { - const line = summary.oneLiner || summary.title || taskId; - summaryLines.push(`- **${taskId}**: ${line}`); - } - summaryLines.push(""); - - const sDir = resolveSlicePath(base, mid, sid); - if (!sDir) return false; - - const summaryPath = join(sDir, `${sid}-SUMMARY.md`); - atomicWriteSync(summaryPath, summaryLines.join("\n")); - process.stderr.write(`gsd-mechanical: wrote ${summaryPath}\n`); - - // Build UAT.md from plan's Verification section - const planPath = resolveSliceFile(base, mid, sid, "PLAN"); - if (planPath) { - const planContent = readFileSync(planPath, "utf-8"); - const verification = extractMarkdownSection(planContent, "Verification"); - if (verification) { - const uatContent = [ - "---", - `id: ${sid}`, - `parent: ${mid}`, - "type: artifact-driven", - "---", - "", - `# ${sid}: UAT`, - "", - verification, - "", - ].join("\n"); - const uatPath = join(sDir, `${sid}-UAT.md`); - atomicWriteSync(uatPath, uatContent); - process.stderr.write(`gsd-mechanical: wrote ${uatPath}\n`); - } - } - - // Mark slice [x] in ROADMAP - await markSliceInRoadmap(base, mid, sid); - - // Append new decisions if any - await appendNewDecisions(base, taskSummaries.map(ts => ts.summary)); - - // Update requirements if all passed - if (allPassed) { - await mechanicalRequirementsUpdate(base, mid, sid, taskSummaries.map(ts => ts.summary)); - } - - return true; -} - -// ─── Requirements Update ───────────────────────────────────────────────────── - -/** - * Conservative requirements update: mark requirements Validated only if - * all tasks' verification passed. - */ -export async function mechanicalRequirementsUpdate( - _base: string, _mid: string, _sid: string, _taskSummaries: Summary[], -): Promise { - // Conservative: requirements validation requires human or LLM judgment - // about whether the requirement is truly met. Mechanical completion only - // marks the slice done — requirement status updates are left to the - // existing validation pipeline. -} - -// ─── Decision Aggregation ──────────────────────────────────────────────────── - -/** - * Collect key_decisions from task summaries, deduplicate against existing - * DECISIONS.md, and append new ones. - */ -export async function appendNewDecisions( - base: string, taskSummaries: Summary[], -): Promise { - const allDecisions = taskSummaries.flatMap(s => s.frontmatter.key_decisions); - if (allDecisions.length === 0) return; - - const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); - const existing = existsSync(decisionsPath) - ? readFileSync(decisionsPath, "utf-8") - : ""; - - // Deduplicate — skip decisions whose text already appears in the file - const newDecisions = allDecisions.filter(d => - d.trim() && !existing.includes(d.trim()), - ); - if (newDecisions.length === 0) return; - - const entries = newDecisions - .map(d => `- ${d} _(auto-aggregated from task summaries)_`) - .join("\n"); - - const updated = existing.trimEnd() + "\n\n### Auto-aggregated Decisions\n\n" + entries + "\n"; - atomicWriteSync(decisionsPath, updated); - process.stderr.write(`gsd-mechanical: appended ${newDecisions.length} decision(s) to DECISIONS.md\n`); -} - -// ─── Milestone Verification ────────────────────────────────────────────────── - -export interface MilestoneVerificationResult { - verdict: "passed" | "failed" | "mixed"; - checks: EvidenceJSON[]; - uatResults: string[]; - markdown: string; -} - -/** - * Aggregate T##-VERIFY.json files and S##-UAT-RESULT.md files across all - * slices in a milestone to produce VALIDATION.md. - */ -export async function aggregateMilestoneVerification( - base: string, mid: string, -): Promise { - const mDir = resolveMilestonePath(base, mid); - if (!mDir) return { verdict: "failed", checks: [], uatResults: [], markdown: "" }; - - const allChecks: EvidenceJSON[] = []; - const allUatResults: string[] = []; - - // Scan all slices - const slicesDir = join(mDir, "slices"); - if (!existsSync(slicesDir)) return { verdict: "failed", checks: [], uatResults: [], markdown: "" }; - - const sliceDirs = readdirSyncSafe(slicesDir).filter(name => /^S\d+/i.test(name)).sort(); - - for (const sliceName of sliceDirs) { - const sid = sliceName.match(/^(S\d+)/i)?.[1] ?? sliceName; - const tDir = resolveTasksDir(base, mid, sid); - if (tDir) { - const verifyFiles = resolveTaskJsonFiles(tDir, "VERIFY"); - for (const vf of verifyFiles) { - try { - const content = readFileSync(join(tDir, vf), "utf-8"); - const evidence = JSON.parse(content) as EvidenceJSON; - allChecks.push(evidence); - } catch { - // Skip malformed JSON - } - } - } - - // Check for UAT result - const uatResultPath = resolveSliceFile(base, mid, sid, "UAT-RESULT"); - if (uatResultPath) { - try { - const uatContent = readFileSync(uatResultPath, "utf-8"); - allUatResults.push(`### ${sid}\n\n${uatContent}`); - } catch { - // Non-fatal - } - } - } - - // Determine verdict - const allPassed = allChecks.length > 0 && allChecks.every(c => c.passed); - const anyFailed = allChecks.some(c => !c.passed); - const verdict: "passed" | "failed" | "mixed" = allPassed - ? "passed" - : anyFailed - ? (allChecks.some(c => c.passed) ? "mixed" : "failed") - : "passed"; // No checks = vacuously passed - - // Build VALIDATION.md - const mdLines: string[] = [ - "---", - `milestone: ${mid}`, - `verdict: ${verdict}`, - "remediation_round: 0", - `validated_at: ${new Date().toISOString()}`, - "---", - "", - `# ${mid}: Milestone Validation`, - "", - `**Verdict:** ${verdict}`, - "", - "## Verification Results", - "", - ]; - - if (allChecks.length === 0) { - mdLines.push("_No verification evidence found._"); - } else { - mdLines.push("| Task | Passed | Checks | Failed |"); - mdLines.push("|------|--------|--------|--------|"); - for (const check of allChecks) { - const failedCount = check.checks.filter(c => c.verdict === "fail").length; - mdLines.push( - `| ${check.taskId} | ${check.passed ? "yes" : "no"} | ${check.checks.length} | ${failedCount} |`, - ); - } - } - - if (allUatResults.length > 0) { - mdLines.push(""); - mdLines.push("## UAT Results"); - mdLines.push(""); - mdLines.push(...allUatResults); - } - - mdLines.push(""); - - const markdown = mdLines.join("\n"); - - // Write VALIDATION.md - const validationPath = join(mDir, `${mid}-VALIDATION.md`); - atomicWriteSync(validationPath, markdown); - process.stderr.write(`gsd-mechanical: wrote ${validationPath}\n`); - - return { verdict, checks: allChecks, uatResults: allUatResults, markdown }; -} - -// ─── Milestone Summary ────────────────────────────────────────────────────── - -/** - * Read all S##-SUMMARY.md files and produce M##-SUMMARY.md. - */ -export async function generateMilestoneSummary( - base: string, mid: string, -): Promise { - const mDir = resolveMilestonePath(base, mid); - if (!mDir) return ""; - - const slicesDir = join(mDir, "slices"); - if (!existsSync(slicesDir)) return ""; - - const sliceDirs = readdirSyncSafe(slicesDir).filter(name => /^S\d+/i.test(name)).sort(); - - const aggregatedProvides: string[] = []; - const aggregatedKeyFiles: string[] = []; - const aggregatedKeyDecisions: string[] = []; - const aggregatedPatterns: string[] = []; - const sliceOneLinerList: string[] = []; - - for (const sliceName of sliceDirs) { - const sid = sliceName.match(/^(S\d+)/i)?.[1] ?? sliceName; - const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY"); - if (!summaryPath) continue; - - try { - const content = readFileSync(summaryPath, "utf-8"); - const summary = parseSummary(content); - aggregatedProvides.push(...summary.frontmatter.provides); - aggregatedKeyFiles.push(...summary.frontmatter.key_files); - aggregatedKeyDecisions.push(...summary.frontmatter.key_decisions); - aggregatedPatterns.push(...summary.frontmatter.patterns_established); - sliceOneLinerList.push(`- **${sid}**: ${summary.oneLiner || summary.title || sid}`); - } catch { - sliceOneLinerList.push(`- **${sid}**: _(summary unavailable)_`); - } - } - - const mdLines: string[] = [ - "---", - `id: ${mid}`, - ]; - if (dedup(aggregatedProvides).length > 0) - mdLines.push(`provides:\n${dedup(aggregatedProvides).map(p => ` - ${p}`).join("\n")}`); - if (dedup(aggregatedKeyFiles).length > 0) - mdLines.push(`key_files:\n${dedup(aggregatedKeyFiles).map(f => ` - ${f}`).join("\n")}`); - if (dedup(aggregatedKeyDecisions).length > 0) - mdLines.push(`key_decisions:\n${dedup(aggregatedKeyDecisions).map(d => ` - ${d}`).join("\n")}`); - if (dedup(aggregatedPatterns).length > 0) - mdLines.push(`patterns_established:\n${dedup(aggregatedPatterns).map(p => ` - ${p}`).join("\n")}`); - mdLines.push(`completed_at: ${new Date().toISOString()}`); - mdLines.push("---"); - mdLines.push(""); - mdLines.push(`# ${mid}: Milestone Summary`); - mdLines.push(""); - mdLines.push("## Slices"); - mdLines.push(""); - mdLines.push(...sliceOneLinerList); - mdLines.push(""); - - const content = mdLines.join("\n"); - - // Write M##-SUMMARY.md - const summaryPath = join(mDir, `${mid}-SUMMARY.md`); - atomicWriteSync(summaryPath, content); - process.stderr.write(`gsd-mechanical: wrote ${summaryPath}\n`); - - return content; -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function aggregateFrontmatter(fms: SummaryFrontmatter[]): { - provides: string[]; - key_files: string[]; - key_decisions: string[]; - patterns_established: string[]; - affects: string[]; - observability_surfaces: string[]; -} { - return { - provides: dedup(fms.flatMap(f => f.provides)), - key_files: dedup(fms.flatMap(f => f.key_files)), - key_decisions: dedup(fms.flatMap(f => f.key_decisions)), - patterns_established: dedup(fms.flatMap(f => f.patterns_established)), - affects: dedup(fms.flatMap(f => f.affects)), - observability_surfaces: dedup(fms.flatMap(f => f.observability_surfaces)), - }; -} - -function dedup(arr: string[]): string[] { - return [...new Set(arr.filter(s => s.trim()))]; -} - -async function markSliceInRoadmap(base: string, mid: string, sid: string): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath) return; - const content = await loadFile(roadmapPath); - if (!content) return; - const updated = content.replace( - new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"), - `$1[x] **${sid}:`, - ); - if (updated !== content) { - atomicWriteSync(roadmapPath, updated); - process.stderr.write(`gsd-mechanical: marked ${sid} done in ROADMAP\n`); - } -} - -function readdirSyncSafe(dir: string): string[] { - try { - return readdirSync(dir); - } catch { - return []; - } -} diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index 05dcb5286..649566259 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -14,8 +14,6 @@ import type { import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; -import { parseUnitId } from "./unit-id.js"; // ─── Hook Queue State ────────────────────────────────────────────────────── @@ -150,7 +148,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null { }; // Build the prompt with variable substitution - const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId); + const [mid, sid, tid] = triggerUnitId.split("/"); const prompt = config.prompt .replace(/\{milestoneId\}/g, mid ?? "") .replace(/\{sliceId\}/g, sid ?? "") @@ -209,14 +207,16 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null { * - Milestone-level (M001): .gsd/M001/{artifact} */ export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - if (mid && sid && tid) { - return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`); + const parts = unitId.split("/"); + if (parts.length === 3) { + const [mid, sid, tid] = parts; + return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`); } - if (mid && sid) { - return join(gsdRoot(basePath), mid, "slices", sid, artifactName); + if (parts.length === 2) { + const [mid, sid] = parts; + return join(basePath, ".gsd", mid, "slices", sid, artifactName); } - return join(gsdRoot(basePath), mid, artifactName); + return join(basePath, ".gsd", parts[0], artifactName); } // ═══════════════════════════════════════════════════════════════════════════ @@ -252,7 +252,7 @@ export function runPreDispatchHooks( return { action: "proceed", prompt, firedHooks: [] }; } - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const [mid, sid, tid] = unitId.split("/"); const substitute = (text: string): string => text .replace(/\{milestoneId\}/g, mid ?? "") @@ -310,7 +310,7 @@ export function runPreDispatchHooks( const HOOK_STATE_FILE = "hook-state.json"; function hookStatePath(basePath: string): string { - return join(gsdRoot(basePath), HOOK_STATE_FILE); + return join(basePath, ".gsd", HOOK_STATE_FILE); } /** @@ -323,7 +323,7 @@ export function persistHookState(basePath: string): void { savedAt: new Date().toISOString(), }; try { - const dir = gsdRoot(basePath); + const dir = join(basePath, ".gsd"); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8"); } catch { @@ -465,7 +465,7 @@ export function triggerHookManually( activeHook.cycle = currentCycle; // Build the prompt with variable substitution - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const [mid, sid, tid] = unitId.split("/"); const prompt = hook.prompt .replace(/\{milestoneId\}/g, mid ?? "") .replace(/\{sliceId\}/g, sid ?? "") diff --git a/src/resources/extensions/gsd/progress-score.ts b/src/resources/extensions/gsd/progress-score.ts index 8584763e8..59b71f602 100644 --- a/src/resources/extensions/gsd/progress-score.ts +++ b/src/resources/extensions/gsd/progress-score.ts @@ -28,246 +28,111 @@ export interface ProgressScore { } export interface ProgressSignal { - name: string; - level: ProgressLevel; - detail: string; + kind: "positive" | "negative" | "neutral"; + label: string; } -// ── Signal Evaluators ────────────────────────────────────────────────────── - -function evaluateHealthTrend(): ProgressSignal { - const trend = getHealthTrend(); - - switch (trend) { - case "improving": - return { name: "health_trend", level: "green", detail: "Health improving" }; - case "stable": - return { name: "health_trend", level: "green", detail: "Health stable" }; - case "degrading": - return { name: "health_trend", level: "red", detail: "Health degrading" }; - case "unknown": - return { name: "health_trend", level: "green", detail: "Insufficient data" }; - } +function escalateLevel(level: ProgressLevel, next: ProgressLevel): ProgressLevel { + const ranks: Record = { + green: 0, + yellow: 1, + red: 2, + }; + return ranks[next] > ranks[level] ? next : level; } -function evaluateErrorStreak(): ProgressSignal { - const streak = getConsecutiveErrorUnits(); - - if (streak === 0) { - return { name: "error_streak", level: "green", detail: "No consecutive errors" }; - } - if (streak <= 2) { - return { name: "error_streak", level: "yellow", detail: `${streak} consecutive error unit(s)` }; - } - return { name: "error_streak", level: "red", detail: `${streak} consecutive error units` }; -} - -function evaluateRecentErrors(): ProgressSignal { - const history = getHealthHistory(); - if (history.length === 0) { - return { name: "recent_errors", level: "green", detail: "No health data yet" }; - } - - const latest = history[history.length - 1]!; - - if (latest.errors === 0 && latest.warnings <= 1) { - return { name: "recent_errors", level: "green", detail: `${latest.errors}E/${latest.warnings}W` }; - } - if (latest.errors === 0) { - return { name: "recent_errors", level: "yellow", detail: `${latest.warnings} warning(s)` }; - } - if (latest.errors <= 2) { - return { name: "recent_errors", level: "yellow", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` }; - } - return { name: "recent_errors", level: "red", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` }; -} - -function evaluateArtifactProduction(): ProgressSignal { - const history = getHealthHistory(); - if (history.length < 2) { - return { name: "artifact_production", level: "green", detail: "Insufficient data" }; - } - - const totalFixes = history.reduce((sum, s) => sum + s.fixesApplied, 0); - const recent = history.slice(-3); - const recentFixes = recent.reduce((sum, s) => sum + s.fixesApplied, 0); - - // If recent units are all producing fixes but errors aren't decreasing, - // doctor is fighting fires but not making headway - if (recentFixes > 3 && recent.every(s => s.errors > 0)) { - return { name: "artifact_production", level: "yellow", detail: "Doctor applying fixes but errors persist" }; - } - - return { name: "artifact_production", level: "green", detail: `${totalFixes} total fixes applied` }; -} - -function evaluateDispatchVelocity(): ProgressSignal { - const history = getHealthHistory(); - if (history.length < 3) { - return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" }; - } - - // Check time between recent snapshots — are units completing at a reasonable rate? - const recent = history.slice(-5); - if (recent.length < 2) { - return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" }; - } - - const timeDiffs: number[] = []; - for (let i = 1; i < recent.length; i++) { - timeDiffs.push(recent[i]!.timestamp - recent[i - 1]!.timestamp); - } - - const avgTimeMs = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length; - const avgTimeMins = Math.round(avgTimeMs / 60_000); - - // If average unit time is > 15 minutes, something might be wrong - if (avgTimeMins > 15) { - return { name: "dispatch_velocity", level: "yellow", detail: `Units averaging ${avgTimeMins}min each` }; - } - - return { name: "dispatch_velocity", level: "green", detail: `Units averaging ${avgTimeMins || "<1"}min each` }; -} - -// ── Main API ─────────────────────────────────────────────────────────────── +// ── Public API ────────────────────────────────────────────────────────────── /** - * Compute the current progress score by evaluating all available signals. - * Returns a composite score with individual signal details. + * Compute the current progress score from health signals. */ export function computeProgressScore(): ProgressScore { - const signals: ProgressSignal[] = [ - evaluateHealthTrend(), - evaluateErrorStreak(), - evaluateRecentErrors(), - evaluateArtifactProduction(), - evaluateDispatchVelocity(), - ]; + const signals: ProgressSignal[] = []; + let level: ProgressLevel = "green"; - // Overall level: worst of all signals - const level = signals.some(s => s.level === "red") - ? "red" - : signals.some(s => s.level === "yellow") - ? "yellow" - : "green"; + // Check consecutive errors + const consecutiveErrors = getConsecutiveErrorUnits(); + if (consecutiveErrors >= 3) { + signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error units` }); + level = escalateLevel(level, "red"); + } else if (consecutiveErrors >= 1) { + signals.push({ kind: "negative", label: `${consecutiveErrors} consecutive error unit(s)` }); + level = escalateLevel(level, "yellow"); + } - // Build summary from the most important signals - const summary = buildSummary(level, signals); + // Check health trend + const trend = getHealthTrend(); + if (trend === "degrading") { + signals.push({ kind: "negative", label: "Health trend declining" }); + level = escalateLevel(level, "yellow"); + } else if (trend === "improving") { + signals.push({ kind: "positive", label: "Health trend improving" }); + } else if (trend === "stable") { + signals.push({ kind: "neutral", label: "Health trend stable" }); + } + + // Check recent history + const history = getHealthHistory(); + if (history.length === 0) { + signals.push({ kind: "neutral", label: "No health data yet" }); + } + + const summary = level === "green" + ? "Progressing well" + : level === "yellow" + ? "Some issues detected" + : "Stuck or erroring"; return { level, summary, signals }; } /** - * Compute progress score with additional context from the current unit. + * Compute progress score with additional context for dashboard display. */ export function computeProgressScoreWithContext(context: { - currentUnitType?: string; - currentUnitId?: string; - completedUnits?: number; - totalUnits?: number; - retryCount?: number; - maxRetries?: number; + sameUnitCount?: number; + recoveryCount?: number; + completedCount?: number; }): ProgressScore { const base = computeProgressScore(); - // Add retry signal if available - if (context.retryCount !== undefined && context.maxRetries !== undefined) { - const retrySignal: ProgressSignal = context.retryCount === 0 - ? { name: "retry_count", level: "green", detail: "No retries" } - : context.retryCount <= 2 - ? { name: "retry_count", level: "yellow", detail: `Retry ${context.retryCount}/${context.maxRetries}` } - : { name: "retry_count", level: "red", detail: `Retry ${context.retryCount}/${context.maxRetries} — looping` }; - - base.signals.push(retrySignal); - - // Re-evaluate level - if (retrySignal.level === "red") base.level = "red"; - else if (retrySignal.level === "yellow" && base.level === "green") base.level = "yellow"; + if (context.sameUnitCount && context.sameUnitCount >= 3) { + base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}× consecutively` }); + base.level = escalateLevel(base.level, "red"); + base.summary = "Stuck on same unit"; + } else if (context.sameUnitCount && context.sameUnitCount >= 2) { + base.signals.push({ kind: "negative", label: `Same unit dispatched ${context.sameUnitCount}×` }); + base.level = escalateLevel(base.level, "yellow"); } - // Build richer summary with context - base.summary = buildSummaryWithContext(base.level, base.signals, context); + if (context.recoveryCount && context.recoveryCount > 0) { + base.signals.push({ kind: "negative", label: `${context.recoveryCount} recovery attempts` }); + base.level = escalateLevel(base.level, "yellow"); + } + + if (context.completedCount && context.completedCount > 0) { + base.signals.push({ kind: "positive", label: `${context.completedCount} units completed` }); + } return base; } -// ── Formatting ───────────────────────────────────────────────────────────── - -function buildSummary(level: ProgressLevel, signals: ProgressSignal[]): string { - switch (level) { - case "green": - return "Progressing well"; - case "yellow": { - const issues = signals.filter(s => s.level === "yellow").map(s => s.detail); - return `Struggling — ${issues[0] ?? "minor issues detected"}`; - } - case "red": { - const issues = signals.filter(s => s.level === "red").map(s => s.detail); - return `Stuck — ${issues[0] ?? "critical issues detected"}`; - } - } -} - -function buildSummaryWithContext( - level: ProgressLevel, - signals: ProgressSignal[], - context: { - currentUnitType?: string; - currentUnitId?: string; - completedUnits?: number; - totalUnits?: number; - retryCount?: number; - maxRetries?: number; - }, -): string { - const unitLabel = context.currentUnitId - ? ` ${context.currentUnitId}` - : ""; - const progressLabel = context.completedUnits !== undefined && context.totalUnits !== undefined - ? ` (${context.completedUnits} of ${context.totalUnits} done)` - : ""; - - switch (level) { - case "green": - return `Progressing well —${unitLabel}${progressLabel}`; - case "yellow": { - const issues = signals.filter(s => s.level === "yellow").map(s => s.detail); - const retryInfo = context.retryCount ? `, attempt ${context.retryCount}/${context.maxRetries}` : ""; - return `Struggling —${unitLabel}${retryInfo}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "issues detected"}`; - } - case "red": { - const issues = signals.filter(s => s.level === "red").map(s => s.detail); - return `Stuck —${unitLabel}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "critical issues"}`; - } - } -} - /** - * Format progress score as a single-line traffic light for TUI display. + * Format a one-line progress indicator for dashboard/status display. */ export function formatProgressLine(score: ProgressScore): string { - const icon = score.level === "green" ? "\uD83D\uDFE2" - : score.level === "yellow" ? "\uD83D\uDFE1" - : "\uD83D\uDD34"; + const icon = score.level === "green" ? "●" : score.level === "yellow" ? "◐" : "○"; return `${icon} ${score.summary}`; } /** - * Format a detailed progress report showing all signals. + * Format a multi-line progress report. */ export function formatProgressReport(score: ProgressScore): string { - const lines: string[] = []; - - lines.push(formatProgressLine(score)); - lines.push(""); - lines.push("Signals:"); - + const lines = [formatProgressLine(score)]; for (const signal of score.signals) { - const icon = signal.level === "green" ? "\u2705" - : signal.level === "yellow" ? "\u26A0\uFE0F" - : "\uD83D\uDED1"; - lines.push(` ${icon} ${signal.name}: ${signal.detail}`); + const prefix = signal.kind === "positive" ? " ✓" : signal.kind === "negative" ? " ✗" : " ·"; + lines.push(`${prefix} ${signal.label}`); } - return lines.join("\n"); } diff --git a/src/resources/extensions/gsd/quick.ts b/src/resources/extensions/gsd/quick.ts index 51755c689..aa83a5553 100644 --- a/src/resources/extensions/gsd/quick.ts +++ b/src/resources/extensions/gsd/quick.ts @@ -10,12 +10,24 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadPrompt } from "./prompt-loader.js"; import { gsdRoot } from "./paths.js"; -import { createGitService, runGit } from "./git-service.js"; -import { getErrorMessage } from "./error-utils.js"; +import { GitServiceImpl, runGit } from "./git-service.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { nativeHasStagedChanges } from "./native-git-bridge.js"; + +interface QuickReturnState { + basePath: string; + originalBranch: string; + quickBranch: string; + taskNum: number; + slug: string; + description: string; +} + +let pendingQuickReturn: QuickReturnState | null = null; // ─── Quick Task Helpers ─────────────────────────────────────────────────────── @@ -65,6 +77,84 @@ function ensureQuickDir(basePath: string, taskNum: number, slug: string): string return taskDir; } +function quickReturnStatePath(basePath: string): string { + return join(gsdRoot(basePath), "runtime", "quick-return.json"); +} + +function persistPendingReturn(state: QuickReturnState): void { + pendingQuickReturn = state; + mkdirSync(join(gsdRoot(state.basePath), "runtime"), { recursive: true }); + writeFileSync(quickReturnStatePath(state.basePath), JSON.stringify(state) + "\n", "utf-8"); +} + +function readPendingReturn(basePath: string): QuickReturnState | null { + if (pendingQuickReturn && pendingQuickReturn.basePath === basePath) { + return pendingQuickReturn; + } + + try { + const raw = readFileSync(quickReturnStatePath(basePath), "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.basePath === "string" + && typeof parsed.originalBranch === "string" + && typeof parsed.quickBranch === "string" + && typeof parsed.taskNum === "number" + && typeof parsed.slug === "string" + && typeof parsed.description === "string" + ) { + pendingQuickReturn = parsed as QuickReturnState; + return pendingQuickReturn; + } + } catch { + // No persisted quick-return state + } + + return null; +} + +function clearPendingReturn(basePath: string): void { + if (pendingQuickReturn?.basePath === basePath) { + pendingQuickReturn = null; + } + rmSync(quickReturnStatePath(basePath), { force: true }); +} + +function hasStagedChanges(basePath: string): boolean { + return nativeHasStagedChanges(basePath); +} + +export function cleanupQuickBranch(basePath = process.cwd()): boolean { + const state = readPendingReturn(basePath); + if (!state) return false; + + const repoPath = state.basePath; + const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; + const git = new GitServiceImpl(repoPath, gitPrefs); + + if (git.getCurrentBranch() === state.quickBranch) { + try { + git.autoCommit("quick-task", `Q${state.taskNum}`, []); + } catch { + // Best-effort: quick work may already be committed. + } + } + + if (git.getCurrentBranch() !== state.originalBranch) { + runGit(repoPath, ["checkout", state.originalBranch]); + } + + runGit(repoPath, ["merge", "--squash", state.quickBranch]); + + if (hasStagedChanges(repoPath)) { + runGit(repoPath, ["commit", "-m", `quick(Q${state.taskNum}): ${state.slug}`]); + } + + runGit(repoPath, ["branch", "-D", state.quickBranch], { allowFailure: true }); + clearPendingReturn(repoPath); + return true; +} + // ─── Main Handler ───────────────────────────────────────────────────────────── export async function handleQuick( @@ -102,33 +192,41 @@ export async function handleQuick( const taskDirRel = `.gsd/quick/${taskNum}-${slug}`; const date = new Date().toISOString().split("T")[0]; - // Create git branch for the quick task (unless isolation: none) - const git = createGitService(basePath); + // Create git branch for the quick task + const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; + const git = new GitServiceImpl(basePath, gitPrefs); const branchName = `gsd/quick/${taskNum}-${slug}`; - const skipBranch = git.prefs.isolation === "none"; + let originalBranch = git.getCurrentBranch(); let branchCreated = false; - let originalBranch: string | undefined; - if (!skipBranch) { - try { - originalBranch = git.getCurrentBranch(); - if (originalBranch !== branchName) { - // Auto-commit any dirty state before switching - try { - git.autoCommit("quick-task", `Q${taskNum}`, []); - } catch { /* nothing to commit — fine */ } + try { + const current = originalBranch; + if (current !== branchName) { + // Auto-commit any dirty state before switching + try { + git.autoCommit("quick-task", `Q${taskNum}`, []); + } catch { /* nothing to commit — fine */ } - runGit(basePath, ["checkout", "-b", branchName]); - branchCreated = true; - } - } catch (err) { - // Branch creation failed — continue on current branch - const message = getErrorMessage(err); - ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning"); + runGit(basePath, ["checkout", "-b", branchName]); + branchCreated = true; } + } catch (err) { + // Branch creation failed — continue on current branch + const message = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning"); } const actualBranch = branchCreated ? branchName : git.getCurrentBranch(); + if (actualBranch === branchName && originalBranch !== branchName) { + persistPendingReturn({ + basePath, + originalBranch, + quickBranch: branchName, + taskNum, + slug, + description, + }); + } // Notify user ctx.ui.notify( @@ -156,106 +254,4 @@ export async function handleQuick( }, { triggerTurn: true }, ); - - // Schedule branch merge-back after the quick task agent session ends. - // Without this, auto-mode resumes on the quick-task branch (#1269). - if (branchCreated && originalBranch) { - _pendingQuickBranchReturn = { - basePath, - originalBranch, - quickBranch: branchName, - taskNum, - slug, - description, - }; - // Persist to disk so recovery works across session crashes (#1293). - persistPendingReturn(_pendingQuickBranchReturn, basePath); - } -} - -/** Pending quick-task branch return — consumed by cleanupQuickBranch(). */ -let _pendingQuickBranchReturn: { - basePath: string; - originalBranch: string; - quickBranch: string; - taskNum: number; - slug: string; - description: string; -} | null = null; - -// ─── Disk Persistence ───────────────────────────────────────────────────── - -/** Path to the pending quick-task return file. */ -function pendingReturnPath(basePath: string): string { - return join(gsdRoot(basePath), "runtime", "quick-return.json"); -} - -/** Write pending return state to disk. */ -function persistPendingReturn(state: NonNullable, basePath: string): void { - const filePath = pendingReturnPath(basePath); - mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); - writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); -} - -/** Remove pending return file from disk. */ -function clearPendingReturn(basePath: string): void { - try { unlinkSync(pendingReturnPath(basePath)); } catch { /* already gone */ } -} - -/** Load pending return from disk (cross-session recovery). */ -function loadPendingReturn(basePath: string): NonNullable | null { - const filePath = pendingReturnPath(basePath); - if (!existsSync(filePath)) return null; - try { - return JSON.parse(readFileSync(filePath, "utf-8")); - } catch { - return null; - } -} - -/** - * Merge the quick-task branch back to the original branch and switch. - * Called from the agent_end handler after a quick task completes. - * - * Checks both in-memory state (same session) and disk state (cross-session - * recovery for crashed/interrupted sessions). - * - * Returns true if a branch return was performed. - */ -export function cleanupQuickBranch(): boolean { - // Prefer in-memory state; fall back to disk for cross-session recovery - let state = _pendingQuickBranchReturn; - if (!state) { - // Try loading from disk — handles the case where the session that - // started the quick task crashed before agent_end could run (#1293). - const basePath = process.cwd(); - state = loadPendingReturn(basePath); - } - if (!state) return false; - - _pendingQuickBranchReturn = null; - const { basePath, originalBranch, quickBranch, taskNum, slug, description } = state; - - try { - // Auto-commit any remaining work - try { runGit(basePath, ["add", "-A"]); } catch {} - try { runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${slug}`]); } catch {} - - // Switch back and merge - runGit(basePath, ["checkout", originalBranch]); - try { - runGit(basePath, ["merge", "--squash", quickBranch]); - runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${description.slice(0, 72)}`]); - } catch { /* merge conflict or nothing — non-fatal */ } - - // Clean up quick branch - try { runGit(basePath, ["branch", "-D", quickBranch]); } catch {} - - // Clean up disk state - clearPendingReturn(basePath); - return true; - } catch { - // Cleanup failed — leave disk state for next attempt - return false; - } } diff --git a/src/resources/extensions/gsd/templates/preferences.md b/src/resources/extensions/gsd/templates/preferences.md index feb81c7af..c0ce5aec6 100644 --- a/src/resources/extensions/gsd/templates/preferences.md +++ b/src/resources/extensions/gsd/templates/preferences.md @@ -30,6 +30,7 @@ token_profile: phases: skip_research: skip_reassess: + reassess_after_slice: skip_slice_research: dynamic_routing: enabled: diff --git a/src/resources/extensions/gsd/tests/agent-end-retry.test.ts b/src/resources/extensions/gsd/tests/agent-end-retry.test.ts index 85704d62c..15f74eee7 100644 --- a/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +++ b/src/resources/extensions/gsd/tests/agent-end-retry.test.ts @@ -1,14 +1,9 @@ /** - * agent-end-retry.test.ts — Verifies the deferred agent_end retry mechanism (#1072). + * agent-end-retry.test.ts — Regression checks for the post-#1419 agent_end model. * - * When handleAgentEnd is already running and a second agent_end event fires - * (e.g. a hook/triage/quick-task unit dispatched inside handleAgentEnd completes - * before it returns), the reentrancy guard must not silently drop the event. - * Instead, it should queue a retry via pendingAgentEndRetry so the completed - * unit's agent_end is processed after the current handler finishes. - * - * Without this, auto-mode can stall permanently in the "summarizing" phase - * with no unit running and no watchdog set. + * The old recursive handleAgentEnd retry path is gone. The loop now keeps + * pendingResolve + pendingAgentEndQueue on AutoSession, and handleAgentEnd is + * only a thin compatibility wrapper around resolveAgentEnd(). */ import test from "node:test"; @@ -29,79 +24,57 @@ function getSessionTsSource(): string { return readFileSync(SESSION_TS_PATH, "utf-8"); } -// ── AutoSession must declare pendingAgentEndRetry ──────────────────────────── - -test("AutoSession declares pendingAgentEndRetry field", () => { +test("AutoSession declares pending agent_end queue state", () => { const source = getSessionTsSource(); assert.ok( - source.includes("pendingAgentEndRetry"), - "AutoSession (auto/session.ts) must declare pendingAgentEndRetry field for deferred retry", + source.includes("pendingResolve"), + "AutoSession must declare pendingResolve for the in-flight unit promise", + ); + assert.ok( + source.includes("pendingAgentEndQueue"), + "AutoSession must declare pendingAgentEndQueue for between-iteration agent_end events", ); }); -test("AutoSession resets pendingAgentEndRetry in reset()", () => { +test("AutoSession reset clears pending agent_end queue state", () => { const source = getSessionTsSource(); - // Find the reset() method — it's declared as "reset(): void {" const resetIdx = source.indexOf("reset(): void"); assert.ok(resetIdx > -1, "AutoSession must have a reset() method"); - const resetBlock = source.slice(resetIdx, resetIdx + 3000); + const resetBlock = source.slice(resetIdx, resetIdx + 4000); assert.ok( - resetBlock.includes("pendingAgentEndRetry"), - "reset() must clear pendingAgentEndRetry", + resetBlock.includes("this.pendingResolve = null"), + "reset() must clear pendingResolve", + ); + assert.ok( + resetBlock.includes("this.pendingAgentEndQueue = []"), + "reset() must clear pendingAgentEndQueue", ); }); -// ── handleAgentEnd reentrancy guard must queue retry ───────────────────────── +test("legacy pendingAgentEndRetry state is gone", () => { + const source = getSessionTsSource(); + assert.ok( + !source.includes("pendingAgentEndRetry"), + "AutoSession should no longer use legacy pendingAgentEndRetry state", + ); +}); -test("handleAgentEnd sets pendingAgentEndRetry when reentrant", () => { +test("handleAgentEnd is a thin compatibility wrapper", () => { const source = getAutoTsSource(); - // Find the handleAgentEnd function const fnIdx = source.indexOf("export async function handleAgentEnd"); assert.ok(fnIdx > -1, "handleAgentEnd must exist in auto.ts"); - - // The reentrancy guard section (within ~500 chars of the function start) - const guardBlock = source.slice(fnIdx, fnIdx + 800); - assert.ok( - guardBlock.includes("s.handlingAgentEnd"), - "handleAgentEnd must check s.handlingAgentEnd", - ); - assert.ok( - guardBlock.includes("pendingAgentEndRetry = true"), - "reentrancy guard must set pendingAgentEndRetry = true instead of silently dropping (#1072)", - ); -}); - -// ── finally block must process pendingAgentEndRetry ────────────────────────── - -test("handleAgentEnd finally block retries if pendingAgentEndRetry is set", () => { - const source = getAutoTsSource(); - const fnIdx = source.indexOf("export async function handleAgentEnd"); - assert.ok(fnIdx > -1, "handleAgentEnd must exist"); - - // Find the finally block within handleAgentEnd (search for the closing pattern) const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── ", fnIdx + 100)); + assert.ok( - fnBlock.includes("pendingAgentEndRetry"), - "handleAgentEnd finally block must check pendingAgentEndRetry", + fnBlock.includes("resolveAgentEnd("), + "handleAgentEnd must delegate to resolveAgentEnd", ); assert.ok( - fnBlock.includes("setImmediate"), - "deferred retry must use setImmediate to avoid stack overflow (#1072)", + !fnBlock.includes("pendingAgentEndRetry"), + "handleAgentEnd must not use legacy retry state", ); assert.ok( - fnBlock.includes("handleAgentEnd(ctx, pi)"), - "deferred retry must call handleAgentEnd recursively (#1072)", - ); -}); - -// ── Regression: reentrancy guard must NOT silently return ───────────────────── - -test("reentrancy guard references issue #1072", () => { - const source = getAutoTsSource(); - const fnIdx = source.indexOf("export async function handleAgentEnd"); - const guardBlock = source.slice(fnIdx, fnIdx + 800); - assert.ok( - guardBlock.includes("1072"), - "reentrancy guard comment must reference #1072 for traceability", + !fnBlock.includes("dispatchNextUnit"), + "handleAgentEnd must not dispatch recursively", ); }); diff --git a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts index 126f15cbe..ff8c393f2 100644 --- a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +++ b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts @@ -12,7 +12,15 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, realpathSync, readFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + existsSync, + realpathSync, + readFileSync, +} from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; @@ -28,11 +36,17 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); function run(command: string, cwd: string): string { - return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); + return execSync(command, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); } function createTempRepo(): string { - const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-all-complete-test-"))); + const dir = realpathSync( + mkdtempSync(join(tmpdir(), "gsd-all-complete-test-")), + ); run("git init", dir); run("git config user.email test@test.com", dir); run("git config user.name Test", dir); @@ -63,41 +77,54 @@ function createMilestoneArtifacts(dir: string, mid: string): void { // ─── Source-level: verify the merge code exists in the "all complete" path ──── -test("auto.ts 'all milestones complete' path merges before stopping (#962)", () => { - const autoSrc = readFileSync(join(__dirname, "..", "auto.ts"), "utf-8"); +test("auto-loop 'all milestones complete' path merges before stopping (#962)", () => { + const loopSrc = readFileSync(join(__dirname, "..", "auto-loop.ts"), "utf-8"); + const resolverSrc = readFileSync( + join(__dirname, "..", "worktree-resolver.ts"), + "utf-8", + ); // Find the "incomplete.length === 0" block - const incompleteIdx = autoSrc.indexOf("incomplete.length === 0"); - assert.ok(incompleteIdx > -1, "auto.ts should have 'incomplete.length === 0' check"); + const incompleteIdx = loopSrc.indexOf("incomplete.length === 0"); + assert.ok( + incompleteIdx > -1, + "auto-loop.ts should have 'incomplete.length === 0' check", + ); // The merge call must appear BETWEEN the incomplete check and the stopAuto call. - // After the #1308 refactor, the merge is delegated to tryMergeMilestone. - const blockAfterIncomplete = autoSrc.slice(incompleteIdx, incompleteIdx + 3000); + const blockAfterIncomplete = loopSrc.slice( + incompleteIdx, + incompleteIdx + 3000, + ); assert.ok( - blockAfterIncomplete.includes("tryMergeMilestone"), - "auto.ts should call tryMergeMilestone in the 'all milestones complete' path", + blockAfterIncomplete.includes("deps.resolver.mergeAndExit"), + "auto-loop.ts should call resolver.mergeAndExit in the 'all milestones complete' path", ); // The merge should come before stopAuto in this block - const mergePos = blockAfterIncomplete.indexOf("tryMergeMilestone"); + const mergePos = blockAfterIncomplete.indexOf("deps.resolver.mergeAndExit"); const stopPos = blockAfterIncomplete.indexOf("stopAuto"); assert.ok( mergePos < stopPos, - "tryMergeMilestone should be called before stopAuto in the 'all complete' path", + "resolver.mergeAndExit should be called before stopAuto in the 'all complete' path", ); - // Verify tryMergeMilestone handles both worktree and branch isolation - const helperIdx = autoSrc.indexOf("function tryMergeMilestone"); - assert.ok(helperIdx > -1, "tryMergeMilestone helper should exist"); - const helperBlock = autoSrc.slice(helperIdx, helperIdx + 2000); + const helperIdx = resolverSrc.indexOf("mergeAndExit(milestoneId"); assert.ok( - helperBlock.includes("isInAutoWorktree"), - "tryMergeMilestone should check isInAutoWorktree for worktree mode", + helperIdx > -1, + "WorktreeResolver.mergeAndExit helper should exist", + ); + const helperBlock = resolverSrc.slice(helperIdx, helperIdx + 2600); + assert.ok( + helperBlock.includes('mode === "worktree"') || + helperBlock.includes('mode: "worktree"'), + "WorktreeResolver.mergeAndExit should handle worktree mode", ); assert.ok( - helperBlock.includes("getIsolationMode") || helperBlock.includes("isolationMode"), - "tryMergeMilestone should check isolation mode for branch mode", + helperBlock.includes('mode === "branch"') || + helperBlock.includes('mode: "branch"'), + "WorktreeResolver.mergeAndExit should handle branch mode", ); }); @@ -124,23 +151,38 @@ test("single milestone worktree is merged to main when all complete (#962)", () run('git commit -m "feat(M001): add feature"', wt); // Simulate the fix: merge before stopping (what the "all complete" path now does) - const roadmapPath = join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + const roadmapPath = join( + tempDir, + ".gsd", + "milestones", + "M001", + "M001-ROADMAP.md", + ); const roadmapContent = readFileSync(roadmapPath, "utf-8"); const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent); // Verify work is on main - assert.ok(existsSync(join(tempDir, "feature.ts")), "feature.ts should be on main after merge"); + assert.ok( + existsSync(join(tempDir, "feature.ts")), + "feature.ts should be on main after merge", + ); assert.equal(process.cwd(), tempDir, "cwd restored to project root"); assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree"); assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared"); // Verify milestone branch was cleaned up const branches = run("git branch", tempDir); - assert.ok(!branches.includes("milestone/M001"), "milestone branch should be deleted"); + assert.ok( + !branches.includes("milestone/M001"), + "milestone branch should be deleted", + ); // Verify squash commit on main const log = run("git log --oneline -3", tempDir); - assert.ok(log.includes("M001"), "squash commit on main should reference M001"); + assert.ok( + log.includes("M001"), + "squash commit on main should reference M001", + ); assert.ok(mergeResult.commitMessage.length > 0, "commit message returned"); } finally { @@ -171,7 +213,10 @@ test("last milestone worktree is merged when it's the final one (#962)", () => { writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n"); run("git add .", wt1); run('git commit -m "feat(M001): m001 work"', wt1); - const roadmap1 = readFileSync(join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf-8"); + const roadmap1 = readFileSync( + join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + "utf-8", + ); mergeMilestoneToMain(tempDir, "M001", roadmap1); // Now complete M002 (the LAST milestone — this is the #962 scenario) @@ -179,7 +224,10 @@ test("last milestone worktree is merged when it's the final one (#962)", () => { writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n"); run("git add .", wt2); run('git commit -m "feat(M002): m002 work"', wt2); - const roadmap2 = readFileSync(join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "utf-8"); + const roadmap2 = readFileSync( + join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), + "utf-8", + ); mergeMilestoneToMain(tempDir, "M002", roadmap2); // Both features should now be on main diff --git a/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts b/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts index 119972b31..aba05d5cf 100644 --- a/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +++ b/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts @@ -5,7 +5,7 @@ import { getBudgetAlertLevel, getBudgetEnforcementAction, getNewBudgetAlertLevel, -} from "../auto-budget.js"; +} from "../auto.js"; test("getBudgetAlertLevel returns the expected threshold bucket", () => { assert.equal(getBudgetAlertLevel(0.10), 0); diff --git a/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts b/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts deleted file mode 100644 index 3503e2ee0..000000000 --- a/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +++ /dev/null @@ -1,691 +0,0 @@ -/** - * auto-dispatch-loop.test.ts — End-to-end regression tests for the - * auto-mode dispatch loop: deriveState() → resolveDispatch() - * - * Exercises the full state-machine chain WITHOUT an LLM. Each test - * creates a .gsd/ filesystem fixture, derives state, runs the dispatch - * table, and verifies the correct unit type/id is produced. - * - * Regression coverage for: - * #1270 Replaying completed run-uat units - * #1277 Non-artifact UATs dispatched, blocking progression - * #1241 Slice progression gated on file existence, not verdict content - * #909 Missing task plan files → infinite plan-slice loop - * #807 Prose slice headers not parsed → "No slice eligible" block - * #1248 Prose header regex only matched H2 with colon separator - * #1289 Crash recovery false-positive on own PID - * #1217 (orphaned processes — tested via post-unit, not dispatch) - * - * Pattern: create fixture → deriveState → resolveDispatch → assert - */ - -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { deriveState, invalidateStateCache } from '../state.ts'; -import { resolveDispatch, type DispatchContext } from '../auto-dispatch.ts'; -import { parseRoadmapSlices } from '../roadmap-slices.ts'; -import { checkNeedsRunUat } from '../auto-prompts.ts'; -import { checkIdempotency, type IdempotencyContext } from '../auto-idempotency.ts'; -import { invalidateAllCaches } from '../cache.ts'; -import { AutoSession } from '../auto/session.ts'; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - -// ═══════════════════════════════════════════════════════════════════════════ -// Fixture Helpers -// ═══════════════════════════════════════════════════════════════════════════ - -function createBase(): string { - const base = mkdtempSync(join(tmpdir(), 'gsd-dispatch-loop-')); - mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); - return base; -} - -function cleanup(base: string): void { - rmSync(base, { recursive: true, force: true }); -} - -function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void { - const dir = join(base, '.gsd', 'milestones', mid); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, `${mid}-${suffix}.md`), content); -} - -function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void { - const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, `${sid}-${suffix}.md`), content); -} - -function writeTaskFile(base: string, mid: string, sid: string, tid: string, suffix: string, content: string): void { - const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks'); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, `${tid}-${suffix}.md`), content); -} - -/** Standard machine-readable roadmap with checkbox slices */ -function standardRoadmap(mid: string, title: string, slices: Array<{ id: string; title: string; done: boolean; risk?: string; depends?: string[] }>): string { - const lines = [ - `# ${mid}: ${title}`, - '', - '## Slices', - '', - ]; - for (const s of slices) { - const check = s.done ? 'x' : ' '; - const risk = s.risk ?? 'low'; - const deps = s.depends ?? []; - lines.push(`- [${check}] **${s.id}: ${s.title}** \`risk:${risk}\` \`depends:[${deps.join(',')}]\``); - } - lines.push('', '## Boundary Map', ''); - return lines.join('\n'); -} - -/** Standard slice plan with tasks */ -function standardPlan(sid: string, title: string, tasks: Array<{ id: string; title: string; done: boolean; est?: string }>): string { - const lines = [ - `# ${sid}: ${title}`, - '', - '## Tasks', - '', - ]; - for (const t of tasks) { - const check = t.done ? 'x' : ' '; - const est = t.est ?? '1h'; - lines.push(`- [${check}] **${t.id}: ${t.title}** \`est:${est}\``); - } - return lines.join('\n'); -} - -function freshState(): void { - invalidateAllCaches(); - invalidateStateCache(); -} - -async function dispatchFor(base: string): Promise> { - freshState(); - const state = await deriveState(base); - const mid = state.activeMilestone?.id; - if (!mid) return { action: 'stop', reason: 'No active milestone', level: 'info' }; - const midTitle = state.activeMilestone?.title ?? mid; - const ctx: DispatchContext = { basePath: base, mid, midTitle, state, prefs: undefined }; - return resolveDispatch(ctx); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Tests -// ═══════════════════════════════════════════════════════════════════════════ - -async function main(): Promise { - - // ─── 1. Basic state derivation: pre-planning → plan-milestone ───────── - console.log('\n=== 1. pre-planning with context → plan-milestone (or research) ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test Project\n\nBuild a thing.\n'); - const result = await dispatchFor(base); - assertTrue( - result.action === 'dispatch', - 'pre-planning with context dispatches a unit', - ); - if (result.action === 'dispatch') { - assertTrue( - result.unitType === 'research-milestone' || result.unitType === 'plan-milestone', - `dispatches research-milestone or plan-milestone, got ${result.unitType}`, - ); - assertEq(result.unitId, 'M001', 'unit ID is M001'); - } - } finally { - cleanup(base); - } - } - - // ─── 2. Planning → plan-slice ───────────────────────────────────────── - console.log('\n=== 2. has roadmap, no slice plan → plan-slice ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First Slice', done: false }, - { id: 'S02', title: 'Second Slice', done: false, depends: ['S01'] }, - ])); - const result = await dispatchFor(base); - assertTrue(result.action === 'dispatch', 'planning phase dispatches'); - if (result.action === 'dispatch') { - assertTrue( - result.unitType === 'plan-slice' || result.unitType === 'research-slice', - `dispatches plan-slice or research-slice, got ${result.unitType}`, - ); - assertMatch(result.unitId, /M001\/S01/, 'targets S01'); - } - } finally { - cleanup(base); - } - } - - // ─── 3. Executing → execute-task ────────────────────────────────────── - console.log('\n=== 3. has plan with incomplete task → execute-task ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First Slice', done: false }, - ])); - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [ - { id: 'T01', title: 'First Task', done: false }, - { id: 'T02', title: 'Second Task', done: false }, - ])); - writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01: First Task\n\nDo the thing.\n'); - writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02: Second Task\n\nDo more.\n'); - - const result = await dispatchFor(base); - assertTrue(result.action === 'dispatch', 'executing phase dispatches'); - if (result.action === 'dispatch') { - assertEq(result.unitType, 'execute-task', 'dispatches execute-task'); - assertEq(result.unitId, 'M001/S01/T01', 'targets T01'); - } - } finally { - cleanup(base); - } - } - - // ─── 4. All tasks done → complete-slice (summarizing) ───────────────── - console.log('\n=== 4. all tasks done → summarizing → complete-slice ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First Slice', done: false }, - ])); - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [ - { id: 'T01', title: 'First Task', done: true }, - { id: 'T02', title: 'Second Task', done: true }, - ])); - writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDone.'); - writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDone.'); - - const result = await dispatchFor(base); - assertTrue(result.action === 'dispatch', 'summarizing phase dispatches'); - if (result.action === 'dispatch') { - assertEq(result.unitType, 'complete-slice', 'dispatches complete-slice'); - assertEq(result.unitId, 'M001/S01', 'targets S01'); - } - } finally { - cleanup(base); - } - } - - // ─── 5. Regression #909: Missing task plan files → plan-slice ───────── - console.log('\n=== 5. #909: tasks in plan but empty tasks/ dir → plan-slice (not stuck loop) ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - // Add milestone research so research-slice doesn't fire first - writeMilestoneFile(base, 'M001', 'RESEARCH', '# Research\n\nDone.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First Slice', done: false }, - ])); - // Also write slice research so research-slice is skipped - writeSliceFile(base, 'M001', 'S01', 'RESEARCH', '# Slice Research\n\nDone.\n'); - // Plan references tasks but tasks/ dir has no files - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [ - { id: 'T01', title: 'First Task', done: false }, - ])); - // Create empty tasks directory (no task plan files) - mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true }); - - freshState(); - const state = await deriveState(base); - // Should fall back to planning phase since tasks dir is empty - assertEq(state.phase, 'planning', '#909: empty tasks dir → planning phase (not executing)'); - - const result = await dispatchFor(base); - assertTrue(result.action === 'dispatch', '#909: dispatches'); - if (result.action === 'dispatch') { - assertEq(result.unitType, 'plan-slice', '#909: dispatches plan-slice to regenerate task plans'); - } - } finally { - cleanup(base); - } - } - - // ─── 6. Regression #1277: Non-artifact UAT not dispatched ───────────── - console.log('\n=== 6. #1277: human-experience UAT → null (skip, not dispatch) ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Done Slice', done: true }, - { id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] }, - ])); - writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: human-experience\n'); - - const state = { - activeMilestone: { id: 'M001', title: 'Test' }, - activeSlice: { id: 'S02', title: 'Next Slice' }, - activeTask: null, - phase: 'planning', - recentDecisions: [], - blockers: [], - nextAction: 'Plan S02', - registry: [], - }; - - freshState(); - const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); - assertEq(result, null, '#1277: human-experience UAT returns null (not dispatched)'); - } finally { - cleanup(base); - } - } - - // ─── 7. Regression #1277: artifact-driven UAT without result → dispatch ── - console.log('\n=== 7. artifact-driven UAT without result → dispatch ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Done Slice', done: true }, - { id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] }, - ])); - writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n'); - // No UAT-RESULT file - - const state = { - activeMilestone: { id: 'M001', title: 'Test' }, - activeSlice: { id: 'S02', title: 'Next Slice' }, - activeTask: null, - phase: 'planning', - recentDecisions: [], - blockers: [], - nextAction: 'Plan S02', - registry: [], - }; - - freshState(); - const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); - assertTrue(result !== null, 'artifact-driven UAT without result → dispatch (not null)'); - if (result) { - assertEq(result.sliceId, 'S01', 'targets S01'); - } - } finally { - cleanup(base); - } - } - - // ─── 8. Regression #1270: Existing UAT-RESULT never re-dispatches ───── - console.log('\n=== 8. #1270: UAT-RESULT exists → no re-dispatch (any verdict) ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Done Slice', done: true }, - { id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] }, - ])); - writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n'); - writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed.\n'); - - const state = { - activeMilestone: { id: 'M001', title: 'Test' }, - activeSlice: { id: 'S02', title: 'Next Slice' }, - activeTask: null, - phase: 'planning', - recentDecisions: [], - blockers: [], - nextAction: 'Plan S02', - registry: [], - }; - - freshState(); - const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); - assertEq(result, null, '#1270: existing UAT-RESULT with FAIL → null (no re-dispatch)'); - } finally { - cleanup(base); - } - } - - // ─── 9. Regression #1241: UAT verdict gate blocks non-PASS ──────────── - console.log('\n=== 9. #1241: UAT verdict gate blocks progression on non-PASS verdict ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Done Slice', done: true }, - { id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] }, - ])); - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Done Slice', [ - { id: 'T01', title: 'Task', done: true }, - ])); - writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n'); - writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed some check.\n'); - - freshState(); - const state = await deriveState(base); - const ctx: DispatchContext = { - basePath: base, - mid: 'M001', - midTitle: 'Test', - state, - prefs: { uat_dispatch: true } as any, - }; - const result = await resolveDispatch(ctx); - // The uat-verdict-gate rule should stop progression - assertEq(result.action, 'stop', '#1241: non-PASS verdict → stop (blocks progression)'); - } finally { - cleanup(base); - } - } - - // ─── 10. #1241: UAT verdict PASS allows progression ─────────────────── - console.log('\n=== 10. UAT verdict PASS → allows progression ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Done Slice', done: true }, - { id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] }, - ])); - writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n'); - writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: PASS\n---\nAll good.\n'); - - freshState(); - const state = await deriveState(base); - const ctx: DispatchContext = { - basePath: base, - mid: 'M001', - midTitle: 'Test', - state, - prefs: { uat_dispatch: true } as any, - }; - const result = await resolveDispatch(ctx); - // PASS verdict should NOT block — dispatch should continue to plan-slice for S02 - assertTrue(result.action !== 'stop' || !('reason' in result && result.reason.includes('verdict')), 'PASS verdict does not block progression'); - } finally { - cleanup(base); - } - } - - // ─── 11. Complete state derivation: all slices done → completing ─────── - console.log('\n=== 11. all slices done, no validation → validating-milestone ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First Slice', done: true }, - ])); - - freshState(); - const state = await deriveState(base); - assertEq(state.phase, 'validating-milestone', 'all slices done → validating-milestone'); - } finally { - cleanup(base); - } - } - - // ─── 12. Complete milestone → complete phase ────────────────────────── - console.log('\n=== 12. validated + summarized milestone → complete ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First Slice', done: true }, - ])); - writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nAll good.\n'); - writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\nDone.\n'); - - freshState(); - const state = await deriveState(base); - assertEq(state.phase, 'complete', 'validated+summarized → complete'); - } finally { - cleanup(base); - } - } - - // ─── 13. Multi-milestone: M001 complete, M002 active ───────────────── - console.log('\n=== 13. multi-milestone: M001 complete, M002 becomes active ==='); - { - const base = createBase(); - try { - // M001 — complete - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDone.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'First', [ - { id: 'S01', title: 'Slice', done: true }, - ])); - writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n'); - writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\n'); - - // M002 — active - writeMilestoneFile(base, 'M002', 'CONTEXT', '# M002\n\nNext.\n'); - - freshState(); - const state = await deriveState(base); - assertEq(state.activeMilestone?.id, 'M002', 'M002 is the active milestone'); - assertEq(state.phase, 'pre-planning', 'M002 is in pre-planning'); - assertEq(state.registry.length, 2, 'registry has 2 milestones'); - assertEq(state.registry[0].status, 'complete', 'M001 is complete'); - assertEq(state.registry[1].status, 'active', 'M002 is active'); - } finally { - cleanup(base); - } - } - - // ─── 14. Dependency blocking: S02 depends on S01 ───────────────────── - console.log('\n=== 14. slice dependency: S02 blocked until S01 done ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'First', done: false }, - { id: 'S02', title: 'Second', done: false, depends: ['S01'] }, - ])); - - freshState(); - const state = await deriveState(base); - // Active slice should be S01, not S02 - assertEq(state.activeSlice?.id, 'S01', 'S01 is the active slice (S02 is dep-blocked)'); - } finally { - cleanup(base); - } - } - - // ─── 15. Blocker detection: task with blocker_discovered → replan ───── - console.log('\n=== 15. blocker_discovered in task summary → replanning-slice ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Slice', done: false }, - ])); - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [ - { id: 'T01', title: 'Task One', done: true }, - { id: 'T02', title: 'Task Two', done: false }, - ])); - writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.'); - writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.'); - writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01 Summary\nFound a blocker.'); - - freshState(); - const state = await deriveState(base); - assertEq(state.phase, 'replanning-slice', 'blocker_discovered → replanning-slice'); - } finally { - cleanup(base); - } - } - - // ─── 16. Blocker + REPLAN exists → loop protection, resume executing ── - console.log('\n=== 16. blocker_discovered + REPLAN exists → loop protection (executing) ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Slice', done: false }, - ])); - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [ - { id: 'T01', title: 'Task One', done: true }, - { id: 'T02', title: 'Task Two', done: false }, - ])); - writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.'); - writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.'); - writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01\nBlocker.'); - // REPLAN.md exists → loop protection - writeSliceFile(base, 'M001', 'S01', 'REPLAN', '# Replan\nAlready replanned.\n'); - - freshState(); - const state = await deriveState(base); - assertEq(state.phase, 'executing', 'blocker + REPLAN exists → executing (loop protection)'); - } finally { - cleanup(base); - } - } - - // ─── 17. Needs-discussion phase ─────────────────────────────────────── - console.log('\n=== 17. CONTEXT-DRAFT without CONTEXT → needs-discussion ==='); - { - const base = createBase(); - try { - const mDir = join(base, '.gsd', 'milestones', 'M001'); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome rough ideas.\n'); - - freshState(); - const state = await deriveState(base); - assertEq(state.phase, 'needs-discussion', 'CONTEXT-DRAFT without CONTEXT → needs-discussion'); - } finally { - cleanup(base); - } - } - - // ─── 18. Idempotency: completed key → skip ─────────────────────────── - console.log('\n=== 18. idempotency: completed key → skip ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Slice', done: false }, - ])); - // Task must be marked [x] in the plan for verifyExpectedArtifact to return true - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [ - { id: 'T01', title: 'Task', done: true }, - { id: 'T02', title: 'Next Task', done: false }, - ])); - writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.'); - writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nNext.'); - // Write SUMMARY as the expected artifact for execute-task - writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# T01 Summary\nDone.'); - - // Force cache clearance so verifyExpectedArtifact finds the file - freshState(); - - const session = new AutoSession(); - session.basePath = base; - session.completedKeySet.add('execute-task/M001/S01/T01'); - - const notifications: string[] = []; - const result = checkIdempotency({ - s: session, - unitType: 'execute-task', - unitId: 'M001/S01/T01', - basePath: base, - notify: (msg) => notifications.push(msg), - }); - - assertEq(result.action, 'skip', 'completed key → skip'); - assertTrue('reason' in result && result.reason === 'completed', 'reason is completed'); - } finally { - cleanup(base); - } - } - - // ─── 19. Idempotency: stale key (artifact missing) → rerun ─────────── - console.log('\n=== 19. idempotency: stale key (no artifact) → rerun ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n'); - writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [ - { id: 'S01', title: 'Slice', done: false }, - ])); - writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [ - { id: 'T01', title: 'Task', done: false }, - ])); - writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.'); - // NO summary file — artifact missing - - const session = new AutoSession(); - session.basePath = base; - session.completedKeySet.add('execute-task/M001/S01/T01'); - - const notifications: string[] = []; - const result = checkIdempotency({ - s: session, - unitType: 'execute-task', - unitId: 'M001/S01/T01', - basePath: base, - notify: (msg) => notifications.push(msg), - }); - - assertEq(result.action, 'rerun', 'stale key (no artifact) → rerun'); - assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'stale key removed from set'); - } finally { - cleanup(base); - } - } - - // ─── 20. Idempotency: consecutive skip loop → evict ────────────────── - console.log('\n=== 20. idempotency: consecutive skip loop → evict ==='); - { - const base = createBase(); - try { - writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n'); - writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# Done'); - - const session = new AutoSession(); - session.basePath = base; - session.completedKeySet.add('execute-task/M001/S01/T01'); - // Pre-fill skip count to just below threshold - session.unitConsecutiveSkips.set('execute-task/M001/S01/T01', 3); - - const notifications: string[] = []; - const result = checkIdempotency({ - s: session, - unitType: 'execute-task', - unitId: 'M001/S01/T01', - basePath: base, - notify: (msg) => notifications.push(msg), - }); - - assertEq(result.action, 'skip', 'exceeds consecutive skip threshold → skip with eviction'); - assertTrue('reason' in result && result.reason === 'evicted', 'reason is evicted'); - assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'key evicted from completed set'); - assertTrue(session.recentlyEvictedKeys.has('execute-task/M001/S01/T01'), 'key tracked in evicted set'); - } finally { - cleanup(base); - } - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts new file mode 100644 index 000000000..e018b3cd6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -0,0 +1,1458 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + resolveAgentEnd, + runUnit, + autoLoop, + _resetPendingResolve, + _setActiveSession, + isSessionSwitchInFlight, + type UnitResult, + type AgentEndEvent, + type LoopDeps, +} from "../auto-loop.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeEvent( + messages: unknown[] = [{ role: "assistant" }], +): AgentEndEvent { + return { messages }; +} + +/** + * Build a minimal mock AutoSession with controllable newSession behavior. + */ +function makeMockSession(opts?: { + newSessionResult?: { cancelled: boolean }; + newSessionThrows?: string; + newSessionDelayMs?: number; + onNewSessionStart?: (session: any) => void; + onNewSessionSettle?: (session: any) => void; +}) { + const session = { + active: true, + verbose: false, + sessionSwitchInFlight: false, + pendingResolve: null, + pendingAgentEndQueue: [], + cmdCtx: { + newSession: () => { + opts?.onNewSessionStart?.(session); + if (opts?.newSessionThrows) { + return Promise.reject(new Error(opts.newSessionThrows)); + } + const result = opts?.newSessionResult ?? { cancelled: false }; + const delay = opts?.newSessionDelayMs ?? 0; + if (delay > 0) { + return new Promise<{ cancelled: boolean }>((res) => + setTimeout(() => { + opts?.onNewSessionSettle?.(session); + res(result); + }, delay), + ); + } + opts?.onNewSessionSettle?.(session); + return Promise.resolve(result); + }, + }, + clearTimers: () => {}, + } as any; + return session; +} + +/** + * Build a minimal mock ExtensionContext. + */ +function makeMockCtx() { + return { + ui: { notify: () => {} }, + model: { id: "test-model" }, + } as any; +} + +/** + * Build a minimal mock ExtensionAPI that records sendMessage calls. + */ +function makeMockPi() { + const calls: unknown[] = []; + return { + sendMessage: (...args: unknown[]) => { + calls.push(args); + }, + calls, + } as any; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test("resolveAgentEnd resolves a pending runUnit promise", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + _setActiveSession(s); + const event = makeEvent(); + + // Start runUnit — it will create the promise and send a message, + // then block awaiting agent_end + const resultPromise = runUnit( + ctx, + pi, + s, + "task", + "T01", + "do stuff", + undefined, + ); + + // Give the microtask queue a tick so runUnit reaches the await + await new Promise((r) => setTimeout(r, 10)); + + // Now resolve the agent_end + resolveAgentEnd(event); + + const result = await resultPromise; + assert.equal(result.status, "completed"); + assert.deepEqual(result.event, event); +}); + +test("resolveAgentEnd queues event when no promise is pending", () => { + _resetPendingResolve(); + const s = makeMockSession(); + _setActiveSession(s); + + // Should not throw — queues the event for the next runUnit + assert.doesNotThrow(() => { + resolveAgentEnd(makeEvent()); + }); + assert.equal(s.pendingAgentEndQueue.length, 1, "event should be queued"); +}); + +test("double resolveAgentEnd only resolves once (second is queued)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + _setActiveSession(s); + const event1 = makeEvent([{ id: 1 }]); + const event2 = makeEvent([{ id: 2 }]); + + const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt", undefined); + + await new Promise((r) => setTimeout(r, 10)); + + // First resolve — should work + resolveAgentEnd(event1); + + // Second resolve — should be queued (no pending promise) + assert.doesNotThrow(() => { + resolveAgentEnd(event2); + }); + assert.equal( + s.pendingAgentEndQueue.length, + 1, + "second event should be queued", + ); + + const result = await resultPromise; + assert.equal(result.status, "completed"); + // Should have the first event, not the second + assert.deepEqual(result.event, event1); +}); + +test("runUnit returns cancelled when session creation fails", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession({ newSessionThrows: "connection refused" }); + + const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined); + + assert.equal(result.status, "cancelled"); + assert.equal(result.event, undefined); + // sendMessage should NOT have been called + assert.equal(pi.calls.length, 0); +}); + +test("runUnit returns cancelled when session creation times out", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + // Session returns cancelled: true (simulates the timeout race outcome) + const s = makeMockSession({ newSessionResult: { cancelled: true } }); + + const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined); + + assert.equal(result.status, "cancelled"); + assert.equal(result.event, undefined); + assert.equal(pi.calls.length, 0); +}); + +test("runUnit returns cancelled when s.active is false before sendMessage", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + s.active = false; + + const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined); + + assert.equal(result.status, "cancelled"); + assert.equal(pi.calls.length, 0); +}); + +test("runUnit only arms pendingResolve after newSession completes", async () => { + _resetPendingResolve(); + + let sawSwitchFlag = false; + let sawPendingResolve: unknown = "unset"; + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession({ + newSessionDelayMs: 20, + onNewSessionStart: (session) => { + sawSwitchFlag = session.sessionSwitchInFlight; + sawPendingResolve = session.pendingResolve; + }, + }); + _setActiveSession(s); + + const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt", undefined); + + await new Promise((r) => setTimeout(r, 30)); + + assert.equal(sawSwitchFlag, true, "session switch guard should be active during newSession"); + assert.equal(sawPendingResolve, null, "pendingResolve should not be armed before newSession completes"); + assert.equal(isSessionSwitchInFlight(), false, "session switch guard should clear after newSession settles"); + + resolveAgentEnd(makeEvent()); + + const result = await resultPromise; + assert.equal(result.status, "completed"); + assert.equal(pi.calls.length, 1); +}); + +// ─── Structural assertions ─────────────────────────────────────────────────── + +test("auto-loop.ts exports autoLoop, runUnit, resolveAgentEnd", async () => { + const mod = await import("../auto-loop.js"); + assert.equal( + typeof mod.autoLoop, + "function", + "autoLoop should be exported as a function", + ); + assert.equal( + typeof mod.runUnit, + "function", + "runUnit should be exported as a function", + ); + assert.equal( + typeof mod.resolveAgentEnd, + "function", + "resolveAgentEnd should be exported as a function", + ); +}); + +test("auto-loop.ts contains a while keyword", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + assert.ok( + src.includes("while"), + "auto-loop.ts should contain a while keyword (loop or placeholder)", + ); +}); + +test("auto-loop.ts one-shot pattern: pendingResolve is nulled before calling resolver", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + // The one-shot pattern requires: save ref, null the variable, then call + // Look for the pattern: s.pendingResolve = null appearing before r( + const resolveBlock = src.slice( + src.indexOf("export function resolveAgentEnd"), + src.indexOf("export function resolveAgentEnd") + 600, + ); + const nullIdx = resolveBlock.indexOf("pendingResolve = null"); + const callIdx = resolveBlock.indexOf("r({"); + assert.ok(nullIdx > 0, "should null pendingResolve in resolveAgentEnd"); + assert.ok(callIdx > 0, "should call resolver in resolveAgentEnd"); + assert.ok( + nullIdx < callIdx, + "pendingResolve should be nulled before calling the resolver (one-shot)", + ); +}); + +// ─── autoLoop tests (T02) ───────────────────────────────────────────────── + +/** + * Build a mock LoopDeps that tracks call order and allows controlling + * behavior via overrides. + */ +function makeMockDeps( + overrides?: Partial, +): LoopDeps & { callLog: string[] } { + const callLog: string[] = []; + + const baseDeps: LoopDeps = { + lockBase: () => "/tmp/test-lock", + buildSnapshotOpts: () => ({}), + stopAuto: async () => { + callLog.push("stopAuto"); + }, + pauseAuto: async () => { + callLog.push("pauseAuto"); + }, + clearUnitTimeout: () => {}, + updateProgressWidget: () => {}, + invalidateAllCaches: () => { + callLog.push("invalidateAllCaches"); + }, + deriveState: async () => { + callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { + id: "M001", + title: "Test Milestone", + status: "active", + }, + activeSlice: { id: "S01", title: "Test Slice" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + loadEffectiveGSDPreferences: () => ({ preferences: {} }), + preDispatchHealthGate: async () => ({ proceed: true, fixesApplied: [] }), + syncProjectRootToWorktree: () => {}, + checkResourcesStale: () => null, + validateSessionLock: () => true, + updateSessionLock: () => { + callLog.push("updateSessionLock"); + }, + handleLostSessionLock: () => { + callLog.push("handleLostSessionLock"); + }, + sendDesktopNotification: () => {}, + setActiveMilestoneId: () => {}, + pruneQueueOrder: () => {}, + isInAutoWorktree: () => false, + shouldUseWorktreeIsolation: () => false, + mergeMilestoneToMain: () => ({ pushed: false }), + teardownAutoWorktree: () => {}, + createAutoWorktree: () => "/tmp/wt", + captureIntegrationBranch: () => {}, + getIsolationMode: () => "none", + getCurrentBranch: () => "main", + autoWorktreeBranch: () => "auto/M001", + resolveMilestoneFile: () => null, + reconcileMergeState: () => false, + getLedger: () => null, + getProjectTotals: () => ({ cost: 0 }), + formatCost: (c: number) => `$${c.toFixed(2)}`, + getBudgetAlertLevel: () => 0, + getNewBudgetAlertLevel: () => 0, + getBudgetEnforcementAction: () => "none", + getManifestStatus: async () => null, + collectSecretsFromManifest: async () => null, + resolveDispatch: async () => { + callLog.push("resolveDispatch"); + return { + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + }; + }, + runPreDispatchHooks: () => ({ firedHooks: [], action: "proceed" }), + getPriorSliceCompletionBlocker: () => null, + getMainBranch: () => "main", + collectObservabilityWarnings: async () => [], + buildObservabilityRepairBlock: () => null, + closeoutUnit: async () => {}, + verifyExpectedArtifact: () => true, + clearUnitRuntimeRecord: () => {}, + writeUnitRuntimeRecord: () => {}, + recordOutcome: () => {}, + writeLock: () => {}, + captureAvailableSkills: () => {}, + ensurePreconditions: () => {}, + updateSliceProgressCache: () => {}, + selectAndApplyModel: async () => ({ routing: null }), + startUnitSupervision: () => {}, + getDeepDiagnostic: () => null, + isDbAvailable: () => false, + reorderForCaching: (p: string) => p, + existsSync: () => false, + readFileSync: () => "", + atomicWriteSync: () => {}, + GitServiceImpl: class {} as any, + resolver: { + get workPath() { + return "/tmp/project"; + }, + get projectRoot() { + return "/tmp/project"; + }, + get lockPath() { + return "/tmp/project"; + }, + enterMilestone: () => {}, + exitMilestone: () => {}, + mergeAndExit: () => {}, + mergeAndEnterNext: () => {}, + } as any, + postUnitPreVerification: async () => { + callLog.push("postUnitPreVerification"); + return "continue" as const; + }, + runPostUnitVerification: async () => { + callLog.push("runPostUnitVerification"); + return "continue" as const; + }, + postUnitPostVerification: async () => { + callLog.push("postUnitPostVerification"); + return "continue" as const; + }, + getSessionFile: () => "/tmp/session.json", + }; + + const merged = { ...baseDeps, ...overrides, callLog }; + return merged; +} + +/** + * Build a mock session for autoLoop testing — needs more fields than the + * runUnit mock (dispatch counters, milestone state, etc.). + */ +function makeLoopSession(overrides?: Partial>) { + return { + active: true, + verbose: false, + stepMode: false, + paused: false, + basePath: "/tmp/project", + originalBasePath: "", + currentMilestoneId: "M001", + currentUnit: null, + currentUnitRouting: null, + completedUnits: [], + resourceVersionOnStart: null, + lastPromptCharCount: undefined, + lastBaselineCharCount: undefined, + lastBudgetAlertLevel: 0, + pendingVerificationRetry: null, + pendingCrashRecovery: null, + pendingQuickTasks: [], + sidecarQueue: [], + autoModeStartModel: null, + pendingResolve: null, + pendingAgentEndQueue: [], + unitDispatchCount: new Map(), + unitLifetimeDispatches: new Map(), + unitRecoveryCount: new Map(), + verificationRetryCount: new Map(), + gitService: null, + autoStartTime: Date.now(), + cmdCtx: { + newSession: () => Promise.resolve({ cancelled: false }), + getContextUsage: () => ({ percent: 10, tokens: 1000, limit: 10000 }), + }, + clearTimers: () => {}, + ...overrides, + } as any; +} + +test("autoLoop exits when s.active is set to false", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession({ active: false }); + + const deps = makeMockDeps(); + await autoLoop(ctx, pi, s, deps); + + // Loop body should not have executed (deriveState never called) + assert.ok( + !deps.callLog.includes("deriveState"), + "loop should not have iterated", + ); +}); + +test("autoLoop exits on terminal complete state", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "complete", + activeMilestone: { id: "M001", title: "Test", status: "complete" }, + activeSlice: null, + activeTask: null, + registry: [{ id: "M001", status: "complete" }], + blockers: [], + } as any; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok(deps.callLog.includes("deriveState"), "should have derived state"); + assert.ok( + deps.callLog.includes("stopAuto"), + "should have called stopAuto for complete state", + ); + // Should NOT have dispatched a unit + assert.ok( + !deps.callLog.includes("resolveDispatch"), + "should not dispatch when complete", + ); +}); + +test("autoLoop exits on terminal blocked state", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "blocked", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: null, + activeTask: null, + registry: [{ id: "M001", status: "active" }], + blockers: ["Missing API key"], + } as any; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok(deps.callLog.includes("deriveState"), "should have derived state"); + assert.ok( + deps.callLog.includes("stopAuto"), + "should have called stopAuto for blocked state", + ); + assert.ok( + !deps.callLog.includes("resolveDispatch"), + "should not dispatch when blocked", + ); +}); + +test("autoLoop calls deriveState → resolveDispatch → runUnit in sequence", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + + let loopCount = 0; + const s = makeLoopSession(); + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + }; + }, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + loopCount++; + // After first iteration, deactivate to exit the loop + if (loopCount >= 1) { + s.active = false; + } + return "continue" as const; + }, + }); + + // Run autoLoop — it will call runUnit internally which creates a promise. + // We need to resolve the promise from outside via resolveAgentEnd. + const loopPromise = autoLoop(ctx, pi, s, deps); + + // Give the loop time to reach runUnit's await + await new Promise((r) => setTimeout(r, 50)); + + // Resolve the first unit's agent_end + resolveAgentEnd(makeEvent()); + + await loopPromise; + + // Verify the sequence: deriveState → resolveDispatch → then finalize callbacks + const deriveIdx = deps.callLog.indexOf("deriveState"); + const dispatchIdx = deps.callLog.indexOf("resolveDispatch"); + const preVerIdx = deps.callLog.indexOf("postUnitPreVerification"); + const verIdx = deps.callLog.indexOf("runPostUnitVerification"); + const postVerIdx = deps.callLog.indexOf("postUnitPostVerification"); + + assert.ok(deriveIdx >= 0, "deriveState should have been called"); + assert.ok( + dispatchIdx > deriveIdx, + "resolveDispatch should come after deriveState", + ); + assert.ok( + preVerIdx > dispatchIdx, + "postUnitPreVerification should come after resolveDispatch", + ); + assert.ok( + verIdx > preVerIdx, + "runPostUnitVerification should come after pre-verification", + ); + assert.ok( + postVerIdx > verIdx, + "postUnitPostVerification should come after verification", + ); +}); + +test("autoLoop handles verification retry by continuing loop", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + + let verifyCallCount = 0; + let deriveCallCount = 0; + const s = makeLoopSession(); + + const deps = makeMockDeps({ + deriveState: async () => { + deriveCallCount++; + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + runPostUnitVerification: async () => { + verifyCallCount++; + deps.callLog.push("runPostUnitVerification"); + if (verifyCallCount === 1) { + // First call: simulate retry — set pendingVerificationRetry on session + s.pendingVerificationRetry = { + unitId: "M001/S01/T01", + failureContext: "test failed: expected X got Y", + attempt: 1, + }; + return "retry" as const; + } + // Second call: pass + return "continue" as const; + }, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + // After the retry cycle completes, deactivate + s.active = false; + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // First iteration: runUnit → verification returns "retry" → loop continues + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); // resolve first unit + + // Second iteration: runUnit → verification returns "continue" + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); // resolve retry unit + + await loopPromise; + + // Verify deriveState was called twice (two iterations) + const deriveCount = deps.callLog.filter((c) => c === "deriveState").length; + assert.ok( + deriveCount >= 2, + `deriveState should be called at least 2 times (got ${deriveCount})`, + ); + + // Verify verification was called twice + assert.equal( + verifyCallCount, + 2, + "verification should have been called twice (once retry, once pass)", + ); +}); + +test("autoLoop handles dispatch stop action", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + const deps = makeMockDeps({ + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "stop" as const, + reason: "test-stop-reason", + level: "info" as const, + }; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok( + deps.callLog.includes("resolveDispatch"), + "should have called resolveDispatch", + ); + assert.ok( + deps.callLog.includes("stopAuto"), + "should have stopped on dispatch stop action", + ); +}); + +test("autoLoop handles dispatch skip action by continuing", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let dispatchCallCount = 0; + const deps = makeMockDeps({ + resolveDispatch: async () => { + dispatchCallCount++; + deps.callLog.push("resolveDispatch"); + if (dispatchCallCount === 1) { + return { action: "skip" as const }; + } + // Second time: stop to exit the loop + return { + action: "stop" as const, + reason: "done", + level: "info" as const, + }; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + // Should have called resolveDispatch twice (skip → re-derive → stop) + const dispatchCalls = deps.callLog.filter((c) => c === "resolveDispatch"); + assert.equal( + dispatchCalls.length, + 2, + "resolveDispatch should be called twice (skip then stop)", + ); + const deriveCalls = deps.callLog.filter((c) => c === "deriveState"); + assert.ok( + deriveCalls.length >= 2, + "deriveState should be called at least twice (one per iteration)", + ); +}); + +test("autoLoop drains sidecar queue after postUnitPostVerification enqueues items", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let postVerCallCount = 0; + const deps = makeMockDeps({ + postUnitPostVerification: async () => { + postVerCallCount++; + deps.callLog.push("postUnitPostVerification"); + if (postVerCallCount === 1) { + // First call (main unit): enqueue a sidecar item + s.sidecarQueue.push({ + kind: "hook" as const, + unitType: "hook/review", + unitId: "M001/S01/T01/review", + prompt: "review the code", + }); + return "continue" as const; + } + // Second call (sidecar unit completed): done + s.active = false; + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // Wait for main unit's runUnit to be awaiting + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); // resolve main unit + + // Wait for the sidecar unit's runUnit to be awaiting + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); // resolve sidecar unit + + await loopPromise; + + // postUnitPostVerification should have been called twice (main + sidecar) + assert.equal( + postVerCallCount, + 2, + "postUnitPostVerification should be called twice (main + sidecar)", + ); +}); + +test("autoLoop exits when no active milestone found", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession({ currentMilestoneId: null }); + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: null, + activeSlice: null, + activeTask: null, + registry: [], + blockers: [], + } as any; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok( + deps.callLog.includes("stopAuto"), + "should stop when no milestone and all complete", + ); +}); + +test("autoLoop exports LoopDeps type", async () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + assert.ok( + src.includes("export interface LoopDeps"), + "auto-loop.ts should export LoopDeps interface", + ); +}); + +test("autoLoop signature accepts deps parameter", async () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + assert.ok( + src.includes("deps: LoopDeps"), + "autoLoop should accept a deps: LoopDeps parameter", + ); +}); + +test("autoLoop contains while (s.active) loop", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + assert.ok( + src.includes("while (s.active)"), + "autoLoop should contain a while (s.active) loop", + ); +}); + +// ── T03: End-to-end wiring structural assertions ───────────────────────────── + +test("auto-loop.ts exports autoLoop, runUnit, and resolveAgentEnd", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + assert.ok( + src.includes("export async function autoLoop"), + "must export autoLoop", + ); + assert.ok( + src.includes("export async function runUnit"), + "must export runUnit", + ); + assert.ok( + src.includes("export function resolveAgentEnd"), + "must export resolveAgentEnd", + ); +}); + +test("auto.ts startAuto calls autoLoop (not dispatchNextUnit as first dispatch)", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto.ts"), + "utf-8", + ); + // Find the startAuto function body + const fnIdx = src.indexOf("export async function startAuto"); + assert.ok(fnIdx > -1, "startAuto must exist in auto.ts"); + const fnEnd = src.indexOf("\n// ─── ", fnIdx + 100); + const fnBlock = + fnEnd > -1 ? src.slice(fnIdx, fnEnd) : src.slice(fnIdx, fnIdx + 5000); + assert.ok( + fnBlock.includes("autoLoop("), + "startAuto must call autoLoop() instead of dispatchNextUnit()", + ); +}); + +test("index.ts agent_end handler calls resolveAgentEnd (not handleAgentEnd)", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "index.ts"), + "utf-8", + ); + // Find the agent_end handler success path + const handlerIdx = src.indexOf('pi.on("agent_end"'); + assert.ok(handlerIdx > -1, "index.ts must have an agent_end handler"); + const handlerBlock = src.slice(handlerIdx, handlerIdx + 10000); + assert.ok( + handlerBlock.includes("resolveAgentEnd(event)"), + "agent_end success path must call resolveAgentEnd(event) instead of handleAgentEnd(ctx, pi)", + ); + assert.ok( + handlerBlock.includes("isSessionSwitchInFlight()"), + "agent_end handler must ignore session-switch agent_end events from cmdCtx.newSession()", + ); +}); + +test("auto-verification.ts runPostUnitVerification does not take dispatchNextUnit callback", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-verification.ts"), + "utf-8", + ); + const fnIdx = src.indexOf("export async function runPostUnitVerification"); + assert.ok(fnIdx > -1, "runPostUnitVerification must exist"); + const sigEnd = src.indexOf("): Promise", fnIdx); + const signature = src.slice(fnIdx, sigEnd); + assert.ok( + !signature.includes("dispatchNextUnit"), + "runPostUnitVerification must not take a dispatchNextUnit callback parameter", + ); + assert.ok( + !signature.includes("startDispatchGapWatchdog"), + "runPostUnitVerification must not take a startDispatchGapWatchdog callback parameter", + ); +}); + +test("auto-timeout-recovery.ts calls resolveAgentEnd instead of dispatchNextUnit", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-timeout-recovery.ts"), + "utf-8", + ); + assert.ok( + !src.includes("await dispatchNextUnit"), + "auto-timeout-recovery.ts must not call dispatchNextUnit", + ); + assert.ok( + src.includes("resolveAgentEnd("), + "auto-timeout-recovery.ts must call resolveAgentEnd to re-iterate the loop on timeout recovery", + ); +}); + +test("handleAgentEnd in auto.ts is a thin wrapper calling resolveAgentEnd", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto.ts"), + "utf-8", + ); + const fnIdx = src.indexOf("export async function handleAgentEnd"); + assert.ok(fnIdx > -1, "handleAgentEnd must exist"); + const fnEnd = src.indexOf("\n// ─── ", fnIdx + 100); + const fnBlock = + fnEnd > -1 ? src.slice(fnIdx, fnEnd) : src.slice(fnIdx, fnIdx + 1000); + assert.ok( + fnBlock.includes("resolveAgentEnd("), + "handleAgentEnd must call resolveAgentEnd", + ); + // The function should be short — no reentrancy guard, no verification, no dispatch + assert.ok( + !fnBlock.includes("dispatchNextUnit"), + "handleAgentEnd must not call dispatchNextUnit (it's now a thin wrapper)", + ); + assert.ok( + !fnBlock.includes("postUnitPreVerification") && + !fnBlock.includes("postUnitPostVerification"), + "handleAgentEnd must not contain verification logic (moved to autoLoop)", + ); +}); + +// ── Stuck counter tests ────────────────────────────────────────────────────── + +test("stuck counter: stops when deriveState returns same unit 5 consecutive times", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.ui.notify = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let stopReason = ""; + const deps = makeMockDeps({ + deriveState: async () => + ({ + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + }) as any, + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + }), + stopAuto: async (_ctx?: any, _pi?: any, reason?: string) => { + deps.callLog.push("stopAuto"); + stopReason = reason ?? ""; + s.active = false; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // The loop will dispatch the same unit each iteration. On iteration 1, sameUnitCount + // starts at 0 and the unit key is set. On iterations 2-5, sameUnitCount increments. + // At sameUnitCount=5 (iteration 6), stopAuto is called. + // Each iteration requires resolving an agent_end event. + // But the stuck counter fires BEFORE runUnit, so we only need to resolve 4 times + // (iterations 1-4 each run a unit, iteration 5 increments to 5 and stops). + + // Actually: iteration 1 sets lastDerivedUnit (sameUnitCount=0). + // Iteration 2: derivedKey === lastDerivedUnit → sameUnitCount=1. + // Iteration 3: sameUnitCount=2. Iteration 4: sameUnitCount=3. + // Iteration 5: sameUnitCount=4. Iteration 6: sameUnitCount=5 → stop. + // So we need to resolve 5 agent_end events (iterations 1-5 each run a unit). + + for (let i = 0; i < 5; i++) { + await new Promise((r) => setTimeout(r, 30)); + resolveAgentEnd(makeEvent()); + } + + await loopPromise; + + assert.ok( + deps.callLog.includes("stopAuto"), + "stopAuto should have been called", + ); + assert.ok( + stopReason.includes("Stuck"), + `stop reason should mention 'Stuck', got: ${stopReason}`, + ); + assert.ok( + stopReason.includes("execute-task"), + "stop reason should include unitType", + ); + assert.ok( + stopReason.includes("M001/S01/T01"), + "stop reason should include unitId", + ); +}); + +test("stuck counter: resets when deriveState returns a different unit", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.ui.notify = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let deriveCallCount = 0; + let stopCalled = false; + + const deps = makeMockDeps({ + deriveState: async () => { + deriveCallCount++; + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: deriveCallCount <= 3 ? "T01" : "T02" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + // Return dispatch matching the task from deriveState + const taskId = deriveCallCount <= 3 ? "T01" : "T02"; + return { + action: "dispatch" as const, + unitType: "execute-task", + unitId: `M001/S01/${taskId}`, + prompt: "do the thing", + }; + }, + stopAuto: async (_ctx?: any, _pi?: any, reason?: string) => { + deps.callLog.push("stopAuto"); + stopCalled = true; + s.active = false; + }, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + // After 4th iteration (unit changed on iter 4), exit + if (deriveCallCount >= 4) { + s.active = false; + } + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // Resolve agent_end for iterations 1-4 + for (let i = 0; i < 4; i++) { + await new Promise((r) => setTimeout(r, 30)); + resolveAgentEnd(makeEvent()); + } + + await loopPromise; + + // The counter should have reset when T02 was derived — no stuck stop + assert.ok( + !stopCalled, + "stopAuto should NOT have been called — counter reset on unit change", + ); + assert.ok( + deriveCallCount >= 4, + `deriveState should have been called at least 4 times (got ${deriveCallCount})`, + ); +}); + +test("stuck counter: does not increment during verification retry", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.ui.notify = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let verifyCallCount = 0; + let stopReason = ""; + + const deps = makeMockDeps({ + deriveState: async () => + ({ + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + }) as any, + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + }), + runPostUnitVerification: async () => { + verifyCallCount++; + deps.callLog.push("runPostUnitVerification"); + if (verifyCallCount <= 3) { + // Set pendingVerificationRetry — should prevent stuck counter increment + s.pendingVerificationRetry = { + unitId: "M001/S01/T01", + failureContext: "test failed", + attempt: verifyCallCount, + }; + return "retry" as const; + } + // After 3 retries, exit gracefully + s.active = false; + return "continue" as const; + }, + stopAuto: async (_ctx?: any, _pi?: any, reason?: string) => { + deps.callLog.push("stopAuto"); + stopReason = reason ?? ""; + s.active = false; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // Resolve agent_end for 4 iterations (1 initial + 3 retries) + for (let i = 0; i < 4; i++) { + await new Promise((r) => setTimeout(r, 30)); + resolveAgentEnd(makeEvent()); + } + + await loopPromise; + + // Even though same unit was derived 4 times, verification retries should + // not count, so stuck counter should not have fired + assert.ok( + !stopReason.includes("Stuck"), + `stuck counter should not fire during verification retries, got: ${stopReason}`, + ); + assert.equal( + verifyCallCount, + 4, + "verification should have been called 4 times (1 initial + 3 retries)", + ); +}); + +test("stuck counter: logs debug output with stuck-detected phase", () => { + // Structural test: verify the auto-loop.ts source contains both + // stuck-detected and stuck-counter-reset debug log phases + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto-loop.ts"), + "utf-8", + ); + assert.ok( + src.includes('"stuck-detected"'), + "auto-loop.ts must log phase: 'stuck-detected' when stuck counter fires", + ); + assert.ok( + src.includes('"stuck-counter-reset"'), + "auto-loop.ts must log phase: 'stuck-counter-reset' when counter resets on new unit", + ); + assert.ok( + src.includes("sameUnitCount"), + "auto-loop.ts must track sameUnitCount for stuck detection", + ); +}); + +// ── Lifecycle test (S05/T02) ───────────────────────────────────────────────── + +test("autoLoop lifecycle: advances through research → plan → execute → verify → complete across iterations", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.ui.notify = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let deriveCallCount = 0; + let dispatchCallCount = 0; + const dispatchedUnitTypes: string[] = []; + + // Phase sequence: each deriveState call returns a different phase. + // On the 6th call (start of iteration 6), we deactivate to exit. + const phases = [ + // Call 1: researching → dispatches research-slice + { + phase: "researching", + activeSlice: { id: "S01", title: "Research Slice" }, + activeTask: null, + }, + // Call 2: planning → dispatches plan-slice + { + phase: "planning", + activeSlice: { id: "S01", title: "Plan Slice" }, + activeTask: null, + }, + // Call 3: executing → dispatches execute-task + { + phase: "executing", + activeSlice: { id: "S01", title: "Execute Slice" }, + activeTask: { id: "T01" }, + }, + // Call 4: verifying → dispatches verify-slice + { + phase: "verifying", + activeSlice: { id: "S01", title: "Verify Slice" }, + activeTask: null, + }, + // Call 5: completing → dispatches complete-slice + { + phase: "completing", + activeSlice: { id: "S01", title: "Complete Slice" }, + activeTask: null, + }, + ]; + + const dispatches = [ + { unitType: "research-slice", unitId: "M001/S01", prompt: "research" }, + { unitType: "plan-slice", unitId: "M001/S01", prompt: "plan" }, + { unitType: "execute-task", unitId: "M001/S01/T01", prompt: "execute" }, + { unitType: "verify-slice", unitId: "M001/S01", prompt: "verify" }, + { unitType: "complete-slice", unitId: "M001/S01", prompt: "complete" }, + ]; + + const deps = makeMockDeps({ + deriveState: async () => { + deriveCallCount++; + deps.callLog.push("deriveState"); + + if (deriveCallCount > phases.length) { + // 6th+ call: deactivate to exit the loop + s.active = false; + return { + phase: "complete", + activeMilestone: { id: "M001", title: "Test", status: "complete" }, + activeSlice: null, + activeTask: null, + registry: [{ id: "M001", status: "complete" }], + blockers: [], + } as any; + } + + const p = phases[deriveCallCount - 1]; + return { + phase: p.phase, + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: p.activeSlice, + activeTask: p.activeTask, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + resolveDispatch: async () => { + dispatchCallCount++; + deps.callLog.push("resolveDispatch"); + + if (dispatchCallCount > dispatches.length) { + // Safety: shouldn't reach here, but stop if it does + return { + action: "stop" as const, + reason: "done", + level: "info" as const, + }; + } + + const d = dispatches[dispatchCallCount - 1]; + dispatchedUnitTypes.push(d.unitType); + return { + action: "dispatch" as const, + unitType: d.unitType, + unitId: d.unitId, + prompt: d.prompt, + }; + }, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // Resolve each iteration's agent_end — 5 iterations, each dispatches a unit + for (let i = 0; i < 5; i++) { + await new Promise((r) => setTimeout(r, 30)); + resolveAgentEnd(makeEvent()); + } + + await loopPromise; + + // Assert deriveState was called at least 5 times (once per iteration) + assert.ok( + deriveCallCount >= 5, + `deriveState should be called at least 5 times (got ${deriveCallCount})`, + ); + + // Assert the dispatched unit types cover the full lifecycle sequence + assert.ok( + dispatchedUnitTypes.includes("research-slice"), + `should have dispatched research-slice, got: ${dispatchedUnitTypes.join(", ")}`, + ); + assert.ok( + dispatchedUnitTypes.includes("plan-slice"), + `should have dispatched plan-slice, got: ${dispatchedUnitTypes.join(", ")}`, + ); + assert.ok( + dispatchedUnitTypes.includes("execute-task"), + `should have dispatched execute-task, got: ${dispatchedUnitTypes.join(", ")}`, + ); + assert.ok( + dispatchedUnitTypes.includes("verify-slice"), + `should have dispatched verify-slice, got: ${dispatchedUnitTypes.join(", ")}`, + ); + assert.ok( + dispatchedUnitTypes.includes("complete-slice"), + `should have dispatched complete-slice, got: ${dispatchedUnitTypes.join(", ")}`, + ); + + // Assert call sequence: deriveState and resolveDispatch entries are interleaved + const deriveEntries = deps.callLog.filter((c) => c === "deriveState"); + const dispatchEntries = deps.callLog.filter((c) => c === "resolveDispatch"); + assert.ok( + deriveEntries.length >= 5, + `callLog should have at least 5 deriveState entries (got ${deriveEntries.length})`, + ); + assert.ok( + dispatchEntries.length >= 5, + `callLog should have at least 5 resolveDispatch entries (got ${dispatchEntries.length})`, + ); + + // Verify interleaving: each resolveDispatch should follow a deriveState + let dispatchSeen = 0; + for (const entry of deps.callLog) { + if (entry === "resolveDispatch") { + dispatchSeen++; + } + if (entry === "deriveState" && dispatchSeen > 0) { + // A deriveState after a resolveDispatch confirms the loop advanced + break; + } + } + assert.ok(dispatchSeen > 0, "resolveDispatch should appear in callLog"); + + // Assert the exact sequence of dispatched unit types + assert.deepEqual( + dispatchedUnitTypes, + [ + "research-slice", + "plan-slice", + "execute-task", + "verify-slice", + "complete-slice", + ], + "dispatched unit types should follow the full lifecycle sequence", + ); +}); diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 011470e2c..2bd57caef 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -11,10 +11,6 @@ import { diagnoseExpectedArtifact, buildLoopRemediationSteps, selfHealRuntimeRecords, - completedKeysPath, - persistCompletedKey, - removePersistedKey, - loadPersistedKeys, } from "../auto-recovery.ts"; import { parseRoadmap, clearParseCache } from "../files.ts"; import { invalidateAllCaches } from "../cache.ts"; @@ -201,143 +197,6 @@ test("buildLoopRemediationSteps returns null for unknown type", () => { } }); -// ─── Completed-unit key persistence ─────────────────────────────────────── - -test("completedKeysPath returns path inside .gsd", () => { - const path = completedKeysPath("/project"); - assert.ok(path.includes(".gsd")); - assert.ok(path.includes("completed-units.json")); -}); - -test("persistCompletedKey and loadPersistedKeys round-trip", () => { - const base = makeTmpBase(); - try { - persistCompletedKey(base, "execute-task/M001/S01/T01"); - persistCompletedKey(base, "plan-slice/M001/S02"); - - const keys = new Set(); - loadPersistedKeys(base, keys); - - assert.ok(keys.has("execute-task/M001/S01/T01")); - assert.ok(keys.has("plan-slice/M001/S02")); - assert.equal(keys.size, 2); - } finally { - cleanup(base); - } -}); - -test("persistCompletedKey is idempotent", () => { - const base = makeTmpBase(); - try { - persistCompletedKey(base, "execute-task/M001/S01/T01"); - persistCompletedKey(base, "execute-task/M001/S01/T01"); - - const keys = new Set(); - loadPersistedKeys(base, keys); - assert.equal(keys.size, 1); - } finally { - cleanup(base); - } -}); - -test("removePersistedKey removes a key", () => { - const base = makeTmpBase(); - try { - persistCompletedKey(base, "a"); - persistCompletedKey(base, "b"); - removePersistedKey(base, "a"); - - const keys = new Set(); - loadPersistedKeys(base, keys); - assert.ok(!keys.has("a")); - assert.ok(keys.has("b")); - } finally { - cleanup(base); - } -}); - -test("loadPersistedKeys handles missing file gracefully", () => { - const base = makeTmpBase(); - try { - const keys = new Set(); - assert.doesNotThrow(() => loadPersistedKeys(base, keys)); - assert.equal(keys.size, 0); - } finally { - cleanup(base); - } -}); - -test("removePersistedKey is safe when file doesn't exist", () => { - const base = makeTmpBase(); - try { - assert.doesNotThrow(() => removePersistedKey(base, "nonexistent")); - } finally { - cleanup(base); - } -}); - -// ─── Dual-load across worktree boundary (#769) ─────────────────────────── - -test("loadPersistedKeys unions keys from project root and worktree", () => { - // Simulate two separate .gsd directories (project root + worktree) - // each with a different set of completed keys. Loading from both - // into the same Set should produce the union. - const projectRoot = makeTmpBase(); - const worktree = makeTmpBase(); - try { - // Persist different keys in each location - persistCompletedKey(projectRoot, "execute-task/M001/S01/T01"); - persistCompletedKey(projectRoot, "plan-slice/M001/S02"); - - persistCompletedKey(worktree, "execute-task/M001/S01/T02"); - persistCompletedKey(worktree, "plan-slice/M001/S02"); // overlap - - // Load from both into the same set (mimicking startup dual-load) - const keys = new Set(); - loadPersistedKeys(projectRoot, keys); - loadPersistedKeys(worktree, keys); - - assert.ok(keys.has("execute-task/M001/S01/T01"), "key from project root"); - assert.ok(keys.has("plan-slice/M001/S02"), "shared key"); - assert.ok(keys.has("execute-task/M001/S01/T02"), "key from worktree"); - assert.equal(keys.size, 3, "union should deduplicate overlapping keys"); - } finally { - cleanup(projectRoot); - cleanup(worktree); - } -}); - -test("completed-units.json set-union merge produces correct result", () => { - // Verify that a manual set-union merge correctly merges two JSON arrays - // of completed-unit keys. - const projectRoot = makeTmpBase(); - const worktree = makeTmpBase(); - try { - // Write keys to both locations - const prKeysFile = join(projectRoot, ".gsd", "completed-units.json"); - const wtKeysFile = join(worktree, ".gsd", "completed-units.json"); - - writeFileSync(prKeysFile, JSON.stringify(["a", "b"])); - writeFileSync(wtKeysFile, JSON.stringify(["b", "c", "d"])); - - // Perform a set-union merge of two JSON key arrays - const srcKeys: string[] = JSON.parse(readFileSync(wtKeysFile, "utf8")); - let dstKeys: string[] = []; - if (existsSync(prKeysFile)) { - dstKeys = JSON.parse(readFileSync(prKeysFile, "utf8")); - } - const merged = [...new Set([...dstKeys, ...srcKeys])]; - writeFileSync(prKeysFile, JSON.stringify(merged, null, 2)); - - // Verify the merged result - const result: string[] = JSON.parse(readFileSync(prKeysFile, "utf8")); - assert.deepStrictEqual(result.sort(), ["a", "b", "c", "d"]); - } finally { - cleanup(projectRoot); - cleanup(worktree); - } -}); - // ─── verifyExpectedArtifact: parse cache collision regression ───────────── test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => { @@ -528,9 +387,9 @@ test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", () // ─── selfHealRuntimeRecords — worktree base path (#769) ────────────────── -test("selfHealRuntimeRecords clears stale record when artifact exists at worktree base (#769)", async () => { - // Simulate worktree layout: the runtime record AND the artifact both live - // under the worktree's .gsd/, not the main project root. +test("selfHealRuntimeRecords clears stale dispatched records (#769)", async () => { + // selfHealRuntimeRecords now only clears stale dispatched records (>1h). + // No completedKeySet parameter — deriveState is sole authority. const worktreeBase = makeTmpBase(); const mainBase = makeTmpBase(); try { @@ -541,10 +400,6 @@ test("selfHealRuntimeRecords clears stale record when artifact exists at worktre phase: "dispatched", }); - // Write the UAT result artifact in the worktree .gsd/milestones/ - const uatPath = join(worktreeBase, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT-RESULT.md"); - writeFileSync(uatPath, "---\nresult: pass\n---\n# UAT Result\nAll tests passed.\n"); - // Verify the runtime record exists before heal const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01"); assert.ok(before, "runtime record should exist before heal"); @@ -555,32 +410,23 @@ test("selfHealRuntimeRecords clears stale record when artifact exists at worktre ui: { notify: (msg: string) => { notifications.push(msg); } }, } as any; - // Call selfHeal with worktreeBase — this is the fix: using the worktree path - // so both the runtime record and artifact are found - const completedKeys = new Set(); - await selfHealRuntimeRecords(worktreeBase, mockCtx, completedKeys); + // Call selfHeal with worktreeBase — should clear the stale record + await selfHealRuntimeRecords(worktreeBase, mockCtx); // The stale record should be cleared const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01"); assert.equal(after, null, "runtime record should be cleared after heal"); - - // The completion key should be persisted - assert.ok(completedKeys.has("run-uat/M001/S01"), "completion key should be added"); assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification"); - // Now verify that calling with mainBase does NOT find/clear anything (the old bug) - // Write a stale record at mainBase but NO artifact there + // Write a stale record at mainBase writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, { phase: "dispatched", }); - const mainKeys = new Set(); - await selfHealRuntimeRecords(mainBase, mockCtx, mainKeys); + await selfHealRuntimeRecords(mainBase, mockCtx); - // The record at mainBase should be cleared by the stale timeout (>1h), - // but the completion key should NOT be set (artifact doesn't exist at mainBase) + // The record at mainBase should also be cleared by the stale timeout (>1h) const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01"); assert.equal(afterMain, null, "stale record at main base should be cleared by timeout"); - assert.ok(!mainKeys.has("run-uat/M001/S01"), "completion key should NOT be set when artifact is missing"); } finally { cleanup(worktreeBase); cleanup(mainBase); diff --git a/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts b/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts deleted file mode 100644 index 1cac097b9..000000000 --- a/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * auto-reentrancy-guard.test.ts — Tests for the unconditional reentrancy guard. - * - * Regression for #1272: auto-mode stuck-loop where gap watchdog or - * pendingAgentEndRetry could enter dispatchNextUnit concurrently during - * recursive skip chains because the reentrancy guard was bypassed when - * skipDepth > 0. - * - * The fix makes the guard unconditional (`if (s.dispatching)` without - * `&& s.skipDepth === 0`), and defers recursive re-dispatch via - * setImmediate/setTimeout so s.dispatching is released first. - */ - -import { - _getDispatching, - _setDispatching, - _getSkipDepth, - _setSkipDepth, -} from "../auto.ts"; -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -async function main(): Promise { - // ─── Test-only accessors work ─────────────────────────────────────────── - console.log("\n=== reentrancy guard: test accessors round-trip ==="); - { - _setDispatching(false); - assertEq(_getDispatching(), false, "dispatching starts false"); - - _setDispatching(true); - assertEq(_getDispatching(), true, "dispatching set to true"); - - _setDispatching(false); - assertEq(_getDispatching(), false, "dispatching reset to false"); - } - - // ─── skipDepth accessors ──────────────────────────────────────────────── - console.log("\n=== reentrancy guard: skipDepth accessors round-trip ==="); - { - _setSkipDepth(0); - assertEq(_getSkipDepth(), 0, "skipDepth starts at 0"); - - _setSkipDepth(3); - assertEq(_getSkipDepth(), 3, "skipDepth set to 3"); - - _setSkipDepth(0); - assertEq(_getSkipDepth(), 0, "skipDepth reset to 0"); - } - - // ─── Guard blocks even when skipDepth > 0 (#1272 regression) ─────────── - console.log("\n=== reentrancy guard: blocks when dispatching=true regardless of skipDepth ==="); - { - // Simulate the scenario from #1272: dispatching=true + skipDepth>0 - // The old guard (`if (s.dispatching && s.skipDepth === 0)`) would allow - // concurrent entry when skipDepth > 0. The fix makes the check - // unconditional on skipDepth. - _setDispatching(true); - _setSkipDepth(2); - - // Verify dispatching is true — guard should block regardless of skipDepth - assertTrue( - _getDispatching() === true, - "dispatching flag is true during skip chain" - ); - - // The actual reentrancy guard in dispatchNextUnit checks: - // if (s.dispatching) { return; } - // We verify the state that would trigger the guard: - const wouldBlock = _getDispatching(); // unconditional check - const wouldBlockOld = _getDispatching() && _getSkipDepth() === 0; // old check - - assertTrue(wouldBlock === true, "new guard blocks when dispatching=true, skipDepth=2"); - assertTrue(wouldBlockOld === false, "old guard WOULD NOT block when dispatching=true, skipDepth=2 (the bug)"); - - // Clean up - _setDispatching(false); - _setSkipDepth(0); - } - - // ─── Guard allows entry when dispatching=false ────────────────────────── - console.log("\n=== reentrancy guard: allows entry when dispatching=false ==="); - { - _setDispatching(false); - _setSkipDepth(0); - assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=0"); - - _setDispatching(false); - _setSkipDepth(3); - assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=3"); - - _setSkipDepth(0); - } - - // ─── skipDepth does not affect guard decision (the fix) ───────────────── - console.log("\n=== reentrancy guard: skipDepth is irrelevant to guard decision ==="); - { - for (const depth of [0, 1, 2, 5]) { - _setDispatching(true); - _setSkipDepth(depth); - assertTrue( - _getDispatching() === true, - `guard blocks at skipDepth=${depth} when dispatching=true` - ); - } - - for (const depth of [0, 1, 2, 5]) { - _setDispatching(false); - _setSkipDepth(depth); - assertTrue( - _getDispatching() === false, - `guard allows at skipDepth=${depth} when dispatching=false` - ); - } - - // Clean up - _setDispatching(false); - _setSkipDepth(0); - } - - report(); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts index c4913d987..e73fe849c 100644 --- a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +++ b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts @@ -2,11 +2,10 @@ * Integration tests for the secrets collection gate in startAuto(). * * Exercises getManifestStatus() → collectSecretsFromManifest() composition - * end-to-end using real filesystem state. Proves the gate paths: + * end-to-end using real filesystem state. Proves the three gate paths: * 1. No manifest exists — gate skips silently - * 2. Pending keys exist — gate triggers collection (direct call) + * 2. Pending keys exist — gate triggers collection * 3. No pending keys — gate skips silently - * 4. Pending keys in auto-mode — session pauses instead of blocking (#1146) * * Uses temp directories with real .gsd/milestones/M001/ structure, mirroring * the pattern from manifest-status.test.ts. @@ -19,7 +18,6 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getManifestStatus } from '../files.ts'; import { collectSecretsFromManifest } from '../../get-secrets-from-user.ts'; -import { AutoSession } from '../auto/session.ts'; function makeTempDir(prefix: string): string { const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -148,110 +146,6 @@ test('secrets gate: pending keys exist — gate triggers collection, manifest up // ─── Scenario 3: No pending keys — all collected or in env ────────────────── -// ─── Scenario 4: Pending keys pause AutoSession instead of blocking (#1146) ── - -test('secrets gate: pending keys set pausedForSecrets on AutoSession', async () => { - const tmp = makeTempDir('gate-pause-session'); - try { - // Ensure pending keys are NOT in env - delete process.env.GSD_PAUSE_TEST_KEY_A; - delete process.env.GSD_PAUSE_TEST_KEY_B; - - writeManifest(tmp, `# Secrets Manifest - -**Milestone:** M001 -**Generated:** 2025-06-20T10:00:00Z - -### GSD_PAUSE_TEST_KEY_A - -**Service:** ServiceA -**Status:** pending -**Destination:** dotenv - -1. Get key A from dashboard - -### GSD_PAUSE_TEST_KEY_B - -**Service:** ServiceB -**Status:** pending -**Destination:** dotenv - -1. Get key B from dashboard -`); - - // Verify manifest has pending keys - const status = await getManifestStatus(tmp, 'M001'); - assert.notStrictEqual(status, null, 'manifest should exist'); - assert.deepStrictEqual(status!.pending, ['GSD_PAUSE_TEST_KEY_A', 'GSD_PAUSE_TEST_KEY_B']); - - // Simulate what auto-start.ts now does: set pause flags on session - const session = new AutoSession(); - session.active = true; - session.currentMilestoneId = 'M001'; - - // The new gate logic: if pending keys exist, pause instead of collecting - if (status!.pending.length > 0) { - session.paused = true; - session.pausedForSecrets = true; - } - - assert.strictEqual(session.paused, true, 'session should be paused'); - assert.strictEqual(session.pausedForSecrets, true, 'pausedForSecrets flag should be set'); - - // Verify reset() clears pausedForSecrets - session.reset(); - assert.strictEqual(session.pausedForSecrets, false, 'reset() should clear pausedForSecrets'); - } finally { - delete process.env.GSD_PAUSE_TEST_KEY_A; - delete process.env.GSD_PAUSE_TEST_KEY_B; - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test('secrets gate: no pending keys do not set pausedForSecrets', async () => { - const tmp = makeTempDir('gate-no-pause'); - const savedKey = process.env.GSD_NO_PAUSE_TEST_KEY; - try { - process.env.GSD_NO_PAUSE_TEST_KEY = 'already-set'; - - writeManifest(tmp, `# Secrets Manifest - -**Milestone:** M001 -**Generated:** 2025-06-20T10:00:00Z - -### GSD_NO_PAUSE_TEST_KEY - -**Service:** ServiceX -**Status:** pending -**Destination:** dotenv - -1. Already in env -`); - - const status = await getManifestStatus(tmp, 'M001'); - assert.notStrictEqual(status, null, 'manifest should exist'); - assert.deepStrictEqual(status!.pending, [], 'no pending keys — already in env'); - - // Simulate gate logic — no pending keys, no pause - const session = new AutoSession(); - session.active = true; - - if (status!.pending.length > 0) { - session.paused = true; - session.pausedForSecrets = true; - } - - assert.strictEqual(session.paused, false, 'session should NOT be paused'); - assert.strictEqual(session.pausedForSecrets, false, 'pausedForSecrets should NOT be set'); - } finally { - delete process.env.GSD_NO_PAUSE_TEST_KEY; - if (savedKey !== undefined) process.env.GSD_NO_PAUSE_TEST_KEY = savedKey; - rmSync(tmp, { recursive: true, force: true }); - } -}); - -// ─── Scenario 3: No pending keys — all collected or in env ────────────────── - test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => { const tmp = makeTempDir('gate-no-pending'); const savedKey = process.env.GSD_GATE_TEST_ENVKEY; diff --git a/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts b/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts index b51a8d5fb..a6b3e9f87 100644 --- a/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +++ b/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts @@ -145,8 +145,7 @@ test("AutoSession.reset() references every instance property", () => { assert.ok(resetMatch, "AutoSession.reset() method not found"); const resetBody = resetMatch![1]!; - // completedKeySet is intentionally not cleared (documented in reset()) - const intentionallySkipped = new Set(["completedKeySet"]); + const intentionallySkipped = new Set([]); const missingFromReset: string[] = []; for (const prop of properties) { @@ -182,7 +181,6 @@ test("AutoSession.toJSON() includes key diagnostic properties", () => { "basePath", "currentMilestoneId", "currentUnit", - "dispatching", ]; const missing = requiredDiagnostics.filter(prop => !toJSONBody.includes(prop)); diff --git a/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts b/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts deleted file mode 100644 index b612dd6e1..000000000 --- a/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * auto-skip-loop.test.ts — Tests for the consecutive-skip loop breaker. - * - * Regression for #728: auto-mode infinite skip loop on previously completed - * plan-slice units when deriveState keeps returning the same unit. - * - * The skip paths in dispatchNextUnit track consecutive skips per unit via - * unitConsecutiveSkips. When the same unit is skipped > MAX_CONSECUTIVE_SKIPS - * times without a real dispatch in between, the completion record is evicted - * so deriveState can reconcile. - */ - -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -import { - _getUnitConsecutiveSkips, - _resetUnitConsecutiveSkips, -} from "../auto.ts"; -import { MAX_CONSECUTIVE_SKIPS } from "../auto/session.ts"; -import { persistCompletedKey, removePersistedKey, loadPersistedKeys } from "../auto-recovery.ts"; -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -function makeTmpBase(): string { - const dir = mkdtempSync(join(tmpdir(), "gsd-skip-loop-test-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - return dir; -} - -async function main(): Promise { - // ─── Counter starts at zero ──────────────────────────────────────────── - console.log("\n=== skip loop counter: initial state ==="); - { - _resetUnitConsecutiveSkips(); - const map = _getUnitConsecutiveSkips(); - assertEq(map.size, 0, "counter map starts empty after reset"); - } - - // ─── Counter increments correctly ──────────────────────────────────── - console.log("\n=== skip loop counter: increments on repeated calls ==="); - { - _resetUnitConsecutiveSkips(); - const map = _getUnitConsecutiveSkips(); - const key = "plan-slice/M001/S04"; - - for (let i = 1; i <= MAX_CONSECUTIVE_SKIPS; i++) { - const prev = map.get(key) ?? 0; - map.set(key, prev + 1); - } - - assertEq(map.get(key), MAX_CONSECUTIVE_SKIPS, `counter reaches MAX_CONSECUTIVE_SKIPS (${MAX_CONSECUTIVE_SKIPS})`); - } - - // ─── Threshold constant is sane ────────────────────────────────────── - console.log("\n=== skip loop counter: threshold is reasonable ==="); - { - assertTrue(MAX_CONSECUTIVE_SKIPS >= 3, "threshold allows a few legitimate skips"); - assertTrue(MAX_CONSECUTIVE_SKIPS <= 10, "threshold catches loops quickly"); - } - - // ─── Reset clears all keys ──────────────────────────────────────────── - console.log("\n=== skip loop counter: reset clears all keys ==="); - { - _resetUnitConsecutiveSkips(); - const map = _getUnitConsecutiveSkips(); - map.set("plan-slice/M001/S01", 2); - map.set("plan-slice/M001/S02", 1); - assertEq(map.size, 2, "map has 2 entries before reset"); - - _resetUnitConsecutiveSkips(); - assertEq(_getUnitConsecutiveSkips().size, 0, "map empty after reset"); - } - - // ─── Eviction path: persistCompletedKey + removePersistedKey round-trip - // (simulates what the loop-breaker does) ─────────────────────────── - console.log("\n=== skip loop counter: eviction removes persisted key ==="); - { - _resetUnitConsecutiveSkips(); - const base = makeTmpBase(); - try { - const key = "plan-slice/M001/S04"; - const keySet = new Set(); - - persistCompletedKey(base, key); - loadPersistedKeys(base, keySet); - assertTrue(keySet.has(key), "key persisted before eviction"); - - // Simulate loop-breaker eviction - keySet.delete(key); - removePersistedKey(base, key); - const keySet2 = new Set(); - loadPersistedKeys(base, keySet2); - assertTrue(!keySet2.has(key), "key absent after eviction"); - } finally { - rmSync(base, { recursive: true, force: true }); - } - } - - // ─── Counter resets per-key, not globally ───────────────────────────── - console.log("\n=== skip loop counter: per-key isolation ==="); - { - _resetUnitConsecutiveSkips(); - const map = _getUnitConsecutiveSkips(); - map.set("plan-slice/M001/S04", MAX_CONSECUTIVE_SKIPS + 1); - map.set("plan-slice/M001/S05", 1); - - // Deleting S04 (eviction) should not affect S05 - map.delete("plan-slice/M001/S04"); - assertTrue(!map.has("plan-slice/M001/S04"), "S04 evicted"); - assertEq(map.get("plan-slice/M001/S05"), 1, "S05 counter unaffected"); - } - - _resetUnitConsecutiveSkips(); - report(); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 385476902..806f56097 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -32,9 +32,6 @@ function createTempRepo(): string { run("git config user.email test@test.com", dir); run("git config user.name Test", dir); writeFileSync(join(dir, "README.md"), "# test\n"); - // Mirror production: GSD runtime dirs are gitignored so autoCommitDirtyState - // doesn't pick up the worktrees directory as dirty state (#1127 fix). - writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n"); mkdirSync(join(dir, ".gsd"), { recursive: true }); writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n"); run("git add .", dir); diff --git a/src/resources/extensions/gsd/tests/auto-worktree.test.ts b/src/resources/extensions/gsd/tests/auto-worktree.test.ts index abb93baa2..5ddd3c0f1 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree.test.ts @@ -153,6 +153,64 @@ async function main(): Promise { // After teardown, originalBase should be null assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared"); + // ─── #778: reconcile plan checkboxes on re-attach ───────────────── + console.log("\n=== #778: reconcile plan checkboxes on re-attach ==="); + { + // Simulate: T01 [x] was committed to milestone branch, T02 [x] was + // written to project root by syncStateToProjectRoot() but the + // auto-commit crashed before it fired. On restart the worktree is + // re-created from the milestone branch HEAD (T02 still [ ]). + // reconcilePlanCheckboxes should forward-apply T02 [x] from the root. + + const planRelPath = join(".gsd", "milestones", "M004", "slices", "S01", "S01-PLAN.md"); + const planDir = join(tempDir, ".gsd", "milestones", "M004", "slices", "S01"); + const { mkdirSync: mkdir, writeFileSync: write, readFileSync: read } = await import("node:fs"); + + // Plan on integration branch (project root): T01 [x], T02 [x] + mkdir(planDir, { recursive: true }); + write( + join(tempDir, planRelPath), + "# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n", + ); + + // Write integration-branch plan to git so milestone branch starts from it + run(`git add .`, tempDir); + run(`git commit -m "add plan with T01 and T02 checked" --allow-empty`, tempDir); + + // Create milestone branch with only T01 [x] (simulating crash before T02 commit) + const milestoneBranch = "milestone/M004"; + run(`git checkout -b ${milestoneBranch}`, tempDir); + mkdir(planDir, { recursive: true }); + write( + join(tempDir, planRelPath), + "# S01 Plan\n- [x] **T01:** task one\n- [ ] **T02:** task two\n- [ ] **T03:** task three\n", + ); + run(`git add .`, tempDir); + run(`git commit -m "milestone: only T01 checked"`, tempDir); + run(`git checkout main`, tempDir); + + // Restore project root plan (T01+T02 [x]) — simulates syncStateToProjectRoot + write( + join(tempDir, planRelPath), + "# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n", + ); + + // Create worktree re-attached to existing milestone branch (T02 still [ ] in branch) + const wtPath = createAutoWorktree(tempDir, "M004"); + + try { + const wtPlanPath = join(wtPath, planRelPath); + assertTrue(existsSync(wtPlanPath), "plan file exists in worktree after re-attach"); + + const wtPlan = read(wtPlanPath, "utf-8"); + assertTrue(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)"); + assertTrue(wtPlan.includes("- [x] **T01:"), "T01 stays [x]"); + assertTrue(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)"); + } finally { + teardownAutoWorktree(tempDir, "M004"); + } + } + } finally { // Always restore cwd and clean up process.chdir(savedCwd); diff --git a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts index 08a15411d..5d40b0e21 100644 --- a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts @@ -71,58 +71,3 @@ test("dispatch guard works without git repo", () => { rmSync(repo, { recursive: true, force: true }); } }); - -test("dispatch guard skips parked milestones — they do not block later milestones", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-parked-")); - try { - // M004 is parked with incomplete slices - mkdirSync(join(repo, ".gsd", "milestones", "M004"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M004", "M004-ROADMAP.md"), - "# M004: Parked Milestone\n\n## Slices\n- [ ] **S01: Unfinished** `risk:high` `depends:[]`\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M004", "M004-PARKED.md"), - "---\nparked_at: 2026-03-18T09:00:00.000Z\nreason: \"Parked via /gsd park\"\n---\n\n# M004 — Parked\n"); - - // M010 is the target milestone - mkdirSync(join(repo, ".gsd", "milestones", "M010"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M010", "M010-ROADMAP.md"), - "# M010: Active Milestone\n\n## Slices\n- [ ] **S01: First** `risk:high` `depends:[]`\n"); - - // M004's incomplete S01 should NOT block M010/S01 because M004 is parked - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M010/S01"), - null, - ); - } finally { - rmSync(repo, { recursive: true, force: true }); - } -}); - -test("dispatch guard still blocks on non-parked incomplete milestones", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-mixed-")); - try { - // M003 is parked — should be skipped - mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), - "# M003: Parked\n\n## Slices\n- [ ] **S01: Unfinished** `risk:high` `depends:[]`\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-PARKED.md"), - "---\nparked_at: 2026-03-18T09:00:00.000Z\nreason: \"Parked\"\n---\n"); - - // M005 is NOT parked and has incomplete slices — should block - mkdirSync(join(repo, ".gsd", "milestones", "M005"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M005", "M005-ROADMAP.md"), - "# M005: Active Incomplete\n\n## Slices\n- [ ] **S01: Pending** `risk:low` `depends:[]`\n"); - - // M010 is the target - mkdirSync(join(repo, ".gsd", "milestones", "M010"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M010", "M010-ROADMAP.md"), - "# M010: Target\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n"); - - // M005/S01 should block M010/S01 (M003 is parked, so skipped) - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M010/S01"), - "Cannot dispatch plan-slice M010/S01: earlier slice M005/S01 is not complete.", - ); - } finally { - rmSync(repo, { recursive: true, force: true }); - } -}); diff --git a/src/resources/extensions/gsd/tests/dispatch-stall-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-stall-guard.test.ts deleted file mode 100644 index 7798f75b9..000000000 --- a/src/resources/extensions/gsd/tests/dispatch-stall-guard.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * dispatch-stall-guard.test.ts — Verifies defensive guards against dispatch stalls (#1073). - * - * After a slice completes, dispatchNextUnit must reliably dispatch the next unit. - * These tests verify: - * 1. newSession() has timeout protection (prevents permanent hang if session creation stalls) - * 2. handleAgentEnd has a dispatch hang guard (catches dispatchNextUnit itself hanging) - * 3. Session timeout constants are exported for configurability - */ - -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const AUTO_TS_PATH = join(__dirname, "..", "auto.ts"); -const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts"); - -function getAutoTsSource(): string { - return readFileSync(AUTO_TS_PATH, "utf-8"); -} - -function getSessionTsSource(): string { - return readFileSync(SESSION_TS_PATH, "utf-8"); -} - -// ── Session timeout constants ─────────────────────────────────────────────── - -test("AutoSession exports NEW_SESSION_TIMEOUT_MS constant", () => { - const source = getSessionTsSource(); - assert.ok( - source.includes("NEW_SESSION_TIMEOUT_MS"), - "auto/session.ts must export NEW_SESSION_TIMEOUT_MS for newSession() timeout", - ); -}); - -test("AutoSession exports DISPATCH_HANG_TIMEOUT_MS constant", () => { - const source = getSessionTsSource(); - assert.ok( - source.includes("DISPATCH_HANG_TIMEOUT_MS"), - "auto/session.ts must export DISPATCH_HANG_TIMEOUT_MS for dispatch hang detection", - ); -}); - -test("NEW_SESSION_TIMEOUT_MS is a reasonable value (15-120 seconds)", () => { - const source = getSessionTsSource(); - const match = source.match(/NEW_SESSION_TIMEOUT_MS\s*=\s*(\d[\d_]*)/); - assert.ok(match, "NEW_SESSION_TIMEOUT_MS must have a numeric value"); - const value = parseInt(match![1]!.replace(/_/g, ""), 10); - assert.ok(value >= 15_000 && value <= 120_000, - `NEW_SESSION_TIMEOUT_MS must be 15-120s, got ${value}ms`, - ); -}); - -test("DISPATCH_HANG_TIMEOUT_MS is greater than NEW_SESSION_TIMEOUT_MS", () => { - const source = getSessionTsSource(); - const sessionMatch = source.match(/NEW_SESSION_TIMEOUT_MS\s*=\s*(\d[\d_]*)/); - const dispatchMatch = source.match(/DISPATCH_HANG_TIMEOUT_MS\s*=\s*(\d[\d_]*)/); - assert.ok(sessionMatch && dispatchMatch, "Both timeout constants must exist"); - const sessionTimeout = parseInt(sessionMatch![1]!.replace(/_/g, ""), 10); - const dispatchTimeout = parseInt(dispatchMatch![1]!.replace(/_/g, ""), 10); - assert.ok(dispatchTimeout > sessionTimeout, - `DISPATCH_HANG_TIMEOUT_MS (${dispatchTimeout}) must be > NEW_SESSION_TIMEOUT_MS (${sessionTimeout})`, - ); -}); - -// ── newSession() timeout in dispatchNextUnit ───────────────────────────────── - -test("dispatchNextUnit wraps newSession() with Promise.race timeout", () => { - const source = getAutoTsSource(); - // Search the full file — dispatchNextUnit is very large - assert.ok( - source.includes("Promise.race") && source.includes("NEW_SESSION_TIMEOUT_MS"), - "dispatchNextUnit must use Promise.race with NEW_SESSION_TIMEOUT_MS to timeout newSession() (#1073)", - ); -}); - -test("dispatchNextUnit handles newSession() timeout gracefully", () => { - const source = getAutoTsSource(); - // Must notify user when session times out - assert.ok( - source.includes("Session creation timed out") || source.includes("Session creation failed"), - "dispatchNextUnit must notify user when newSession() times out or fails (#1073)", - ); -}); - -// ── Dispatch hang guard in handleAgentEnd ──────────────────────────────────── - -test("handleAgentEnd has a dispatch hang guard before dispatchNextUnit", () => { - const source = getAutoTsSource(); - const fnIdx = source.indexOf("export async function handleAgentEnd"); - assert.ok(fnIdx > -1, "handleAgentEnd must exist"); - - // Find the section between step mode check and dispatchNextUnit call - const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── Step Mode", fnIdx + 100)); - assert.ok( - fnBlock.includes("DISPATCH_HANG_TIMEOUT_MS") || fnBlock.includes("dispatchHangGuard"), - "handleAgentEnd must have a dispatch hang guard before calling dispatchNextUnit (#1073)", - ); -}); - -test("dispatch hang guard is cleared in finally block", () => { - const source = getAutoTsSource(); - const fnIdx = source.indexOf("export async function handleAgentEnd"); - const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── Step Mode", fnIdx + 100)); - assert.ok( - fnBlock.includes("clearTimeout(dispatchHangGuard)"), - "dispatch hang guard must be cleared in finally block to prevent false alarms (#1073)", - ); -}); - -// ── Constants are imported in auto.ts ──────────────────────────────────────── - -test("auto.ts imports NEW_SESSION_TIMEOUT_MS and DISPATCH_HANG_TIMEOUT_MS", () => { - const source = getAutoTsSource(); - assert.ok( - source.includes("NEW_SESSION_TIMEOUT_MS"), - "auto.ts must import NEW_SESSION_TIMEOUT_MS from session.ts", - ); - assert.ok( - source.includes("DISPATCH_HANG_TIMEOUT_MS"), - "auto.ts must import DISPATCH_HANG_TIMEOUT_MS from session.ts", - ); -}); diff --git a/src/resources/extensions/gsd/tests/headless-query.test.ts b/src/resources/extensions/gsd/tests/headless-query.test.ts index 2d95bdf46..f15d5264e 100644 --- a/src/resources/extensions/gsd/tests/headless-query.test.ts +++ b/src/resources/extensions/gsd/tests/headless-query.test.ts @@ -159,4 +159,26 @@ describe('headless query', () => { assert.equal(snap.state.activeMilestone!.id, 'M001') assert.equal(snap.next.action, 'dispatch') }) + + it('reports all milestones complete with a clean stop reason', async () => { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +## Slices + +- [x] **S01: First Slice** \`risk:low\` \`depends:[]\` + > Done. +`) + writeFileSync( + join(base, '.gsd', 'milestones', 'M001', 'M001-SUMMARY.md'), + '# M001 Summary\n\nComplete.', + ) + + const result = await handleQuery(base) + const snap = result.data as QuerySnapshot + + assert.equal(result.exitCode, 0) + assert.equal(snap.state.phase, 'complete') + assert.equal(snap.next.action, 'stop') + assert.equal(snap.next.reason, 'All milestones complete.') + }) }) diff --git a/src/resources/extensions/gsd/tests/loop-regression.test.ts b/src/resources/extensions/gsd/tests/loop-regression.test.ts deleted file mode 100644 index fbb39d1c5..000000000 --- a/src/resources/extensions/gsd/tests/loop-regression.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -/** - * Regression test suite for the auto-mode dispatch loop. - * Covers phase transitions, dispatch rule matching, state derivation edge cases, - * and every fix from the #1308 issue catalog. - * - * Run: node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \ - * --experimental-strip-types --test src/resources/extensions/gsd/tests/loop-regression.test.ts - */ - -import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import test from "node:test"; -import assert from "node:assert/strict"; -import { deriveState } from "../state.ts"; -import { resolveDispatch, getDispatchRuleNames } from "../auto-dispatch.ts"; -import type { GSDState } from "../types.ts"; - -// ─── Helpers ────────────────────────────────────────────────────────────── - -function makeTmp(name: string): string { - const dir = join(tmpdir(), `loop-regression-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function writeGsdFile(base: string, ...pathParts: string[]): void { - const fullPath = join(base, ".gsd", ...pathParts); - mkdirSync(join(fullPath, ".."), { recursive: true }); - // Default to empty content; callers use writeGsdFileContent for real content -} - -function writeGsdFileContent(base: string, relativePath: string, content: string): void { - const fullPath = join(base, ".gsd", relativePath); - mkdirSync(join(fullPath, ".."), { recursive: true }); - writeFileSync(fullPath, content, "utf-8"); -} - -function buildMinimalRoadmap(slices: Array<{ id: string; title: string; done: boolean; depends?: string[] }>): string { - const lines = ["# M001: Test Milestone", "", "## Slices", ""]; - for (const s of slices) { - const cb = s.done ? "x" : " "; - const deps = s.depends?.length ? ` \`depends:[${s.depends.join(",")}]\`` : " `depends:[]`"; - lines.push(`- [${cb}] **${s.id}: ${s.title}** \`risk:low\`${deps}`); - lines.push(` > Demo text for ${s.id}`); - lines.push(""); - } - return lines.join("\n"); -} - -function buildMinimalPlan(tasks: Array<{ id: string; title: string; done: boolean }>): string { - const lines = ["# S01: Test Slice", "", "**Goal:** test", "", "## Tasks", ""]; - for (const t of tasks) { - const cb = t.done ? "x" : " "; - lines.push(`- [${cb}] **${t.id}: ${t.title}** \`est:5m\``); - } - return lines.join("\n"); -} - -function buildMinimalSummary(id: string): string { - return [ - "---", - `id: ${id}`, - "parent: S01", - "milestone: M001", - "duration: 5m", - "verification_result: passed", - `completed_at: ${new Date().toISOString()}`, - "---", - "", - `# ${id}: Done`, - "", - "Completed.", - ].join("\n"); -} - -// ─── Phase 1: Dispatch Rule Ordering ────────────────────────────────────── - -test("dispatch rules are in the expected order", () => { - const names = getDispatchRuleNames(); - assert.ok(names.length >= 15, `expected ≥15 rules, got ${names.length}`); - - // Verify critical ordering: override gate first, complete-slice before UAT, - // needs-discussion before pre-planning, executing last - const overrideIdx = names.indexOf("rewrite-docs (override gate)"); - const completeSliceIdx = names.indexOf("summarizing → complete-slice"); - const uatGateIdx = names.indexOf("uat-verdict-gate (non-PASS blocks progression)"); - const needsDiscussIdx = names.indexOf("needs-discussion → stop"); - const prePlanNoCtxIdx = names.indexOf("pre-planning (no context) → stop"); - const executeIdx = names.indexOf("executing → execute-task"); - - assert.ok(overrideIdx === 0, "override gate should be first rule"); - assert.ok(completeSliceIdx < uatGateIdx, "complete-slice should fire before UAT gate"); - assert.ok(needsDiscussIdx < prePlanNoCtxIdx, "needs-discussion should fire before pre-planning"); - assert.ok(executeIdx > prePlanNoCtxIdx, "execute-task should fire after pre-planning rules"); -}); - -// ─── Phase 2: State Derivation — Phase Transitions ─────────────────────── - -test("deriveState: empty project → pre-planning with no milestones", async () => { - const tmp = makeTmp("empty"); - try { - mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); - const state = await deriveState(tmp); - assert.equal(state.phase, "pre-planning"); - assert.equal(state.activeMilestone, null); - assert.deepEqual(state.registry, []); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: milestone with context but no roadmap → pre-planning", async () => { - const tmp = makeTmp("no-roadmap"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\nContext here."); - const state = await deriveState(tmp); - assert.equal(state.phase, "pre-planning"); - assert.equal(state.activeMilestone?.id, "M001"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: milestone with CONTEXT-DRAFT.md → needs-discussion", async () => { - const tmp = makeTmp("draft"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nSome ideas."); - const state = await deriveState(tmp); - assert.equal(state.phase, "needs-discussion"); - assert.equal(state.activeMilestone?.id, "M001"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: roadmap with no plan → planning", async () => { - const tmp = makeTmp("planning"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(join(mDir, "slices", "S01"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "First Slice", done: false }, - ])); - const state = await deriveState(tmp); - assert.equal(state.phase, "planning"); - assert.equal(state.activeSlice?.id, "S01"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: plan with incomplete tasks → executing", async () => { - const tmp = makeTmp("executing"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "First Slice", done: false }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Task One", done: false }, - { id: "T02", title: "Task Two", done: false }, - ])); - writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01 Plan\n\nDo stuff."); - writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02 Plan\n\nDo more."); - const state = await deriveState(tmp); - assert.equal(state.phase, "executing"); - assert.equal(state.activeTask?.id, "T01"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: all tasks done → summarizing", async () => { - const tmp = makeTmp("summarizing"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "First Slice", done: false }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Task One", done: true }, - ])); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); - const state = await deriveState(tmp); - assert.equal(state.phase, "summarizing"); - assert.equal(state.activeSlice?.id, "S01"); - assert.equal(state.activeTask, null); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: all slices done → validating-milestone", async () => { - const tmp = makeTmp("validating"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "First Slice", done: true }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Task One", done: true }, - ])); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); - writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone."); - const state = await deriveState(tmp); - assert.equal(state.phase, "validating-milestone"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: validation terminal → completing-milestone", async () => { - const tmp = makeTmp("completing"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "First Slice", done: true }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Task One", done: true }, - ])); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); - writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone."); - writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\n\nAll good."); - const state = await deriveState(tmp); - assert.equal(state.phase, "completing-milestone"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: milestone with summary → complete", async () => { - const tmp = makeTmp("complete"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "First Slice", done: true }, - ])); - writeFileSync(join(mDir, "M001-SUMMARY.md"), "# M001 Summary\n\nMilestone complete."); - const state = await deriveState(tmp); - assert.equal(state.phase, "complete"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -// ─── Phase 3: Regression Tests for Specific Bug Fixes ──────────────────── - -test("#1155: completion-transition codes should NOT be fixable at task level", async () => { - // Verify COMPLETION_TRANSITION_CODES exists and contains expected codes - const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts"); - assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_summary")); - assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_uat")); - assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_roadmap_not_checked")); -}); - -test("#1170: needs-discussion phase is correctly derived from CONTEXT-DRAFT.md", async () => { - const tmp = makeTmp("needs-discussion"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nDraft context."); - const state = await deriveState(tmp); - assert.equal(state.phase, "needs-discussion"); - // Verify the dispatch table returns stop for needs-discussion - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "stop"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("#1176: state.registry is always an array even with corrupt/missing state", async () => { - const tmp = makeTmp("empty-registry"); - try { - mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); - const state = await deriveState(tmp); - assert.ok(Array.isArray(state.registry), "registry should be an array"); - assert.equal(state.registry.length, 0); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("#1243: prose H3 slice headers are parsed correctly", async () => { - const { parseRoadmapSlices } = await import("../roadmap-slices.ts"); - const content = `# M001: Test - -## Roadmap - -### S01: First Feature -Depends on: none - -### S02: Second Feature -Depends on: S01 - -### S03: Third Feature -`; - const slices = parseRoadmapSlices(content); - assert.equal(slices.length, 3, "should parse 3 H3 slices"); - assert.equal(slices[0]!.id, "S01"); - assert.equal(slices[1]!.id, "S02"); - assert.equal(slices[2]!.id, "S03"); - assert.deepEqual(slices[1]!.depends, ["S01"]); -}); - -test("#1243: bold-wrapped and dot-separator slice headers are parsed", async () => { - const { parseRoadmapSlices } = await import("../roadmap-slices.ts"); - const content = `# M001 - -## **S01: Bold Wrapped** -> Demo - -## S02. Dot Separator Title -> Demo -`; - const slices = parseRoadmapSlices(content); - assert.equal(slices.length, 2); - assert.equal(slices[0]!.id, "S01"); - assert.ok(slices[0]!.title.includes("Bold"), `title should contain Bold, got: ${slices[0]!.title}`); - assert.equal(slices[1]!.id, "S02"); -}); - -test("slice dependency blocking → phase: blocked", async () => { - const tmp = makeTmp("dep-blocked"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(join(mDir, "slices"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - // S01 depends on S02 and S02 depends on S01 — circular! - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Slice A", done: false, depends: ["S02"] }, - { id: "S02", title: "Slice B", done: false, depends: ["S01"] }, - ])); - const state = await deriveState(tmp); - assert.equal(state.phase, "blocked"); - assert.ok(state.blockers.length > 0, "should have blockers"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("multi-milestone: M001 complete, M002 active", async () => { - const tmp = makeTmp("multi-milestone"); - try { - // M001 — complete - const m1Dir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(m1Dir, { recursive: true }); - writeFileSync(join(m1Dir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Done", done: true }, - ])); - writeFileSync(join(m1Dir, "M001-SUMMARY.md"), "# M001 Summary\n\nComplete."); - - // M002 — active, needs planning - const m2Dir = join(tmp, ".gsd", "milestones", "M002"); - mkdirSync(m2Dir, { recursive: true }); - writeFileSync(join(m2Dir, "M002-CONTEXT.md"), "# M002\n\nNew work."); - - const state = await deriveState(tmp); - assert.equal(state.activeMilestone?.id, "M002"); - assert.equal(state.phase, "pre-planning"); - assert.equal(state.registry.length, 2); - assert.equal(state.registry[0]!.status, "complete"); - assert.equal(state.registry[1]!.status, "active"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("blocker_discovered in task summary → replanning-slice", async () => { - const tmp = makeTmp("replan"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: false }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Done", done: true }, - { id: "T02", title: "Todo", done: false }, - ])); - writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nPlan."); - writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02\nPlan."); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), [ - "---", - "id: T01", - "parent: S01", - "milestone: M001", - "blocker_discovered: true", - "---", - "", - "# T01: Blocker found", - "", - "API doesn't support this.", - ].join("\n")); - - const state = await deriveState(tmp); - assert.equal(state.phase, "replanning-slice"); - assert.ok(state.blockers[0]!.includes("T01"), "blocker should reference T01"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -// ─── Phase 4: Edge Cases ───────────────────────────────────────────────── - -test("empty plan file (0 tasks) → stays in planning", async () => { - const tmp = makeTmp("empty-plan"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: false }, - ])); - // Plan file exists but has no task entries - writeFileSync(join(sDir, "S01-PLAN.md"), "# S01: Test\n\n**Goal:** test\n\n## Tasks\n"); - - const state = await deriveState(tmp); - assert.equal(state.phase, "planning"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("parked milestone is not treated as active or complete", async () => { - const tmp = makeTmp("parked"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: false }, - ])); - writeFileSync(join(mDir, "M001-PARKED.md"), "Parked for later."); - - const state = await deriveState(tmp); - assert.equal(state.registry[0]!.status, "parked"); - assert.equal(state.activeMilestone, null); - // Phase should be pre-planning (all milestones parked, not complete) - assert.equal(state.phase, "pre-planning"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -// ─── Phase 5: Defensive Guards ─────────────────────────────────────────── - -test("dispatch returns stop when phase=summarizing but activeSlice is null (corrupt state)", async () => { - const corruptState: GSDState = { - activeMilestone: { id: "M001", title: "Test" }, - activeSlice: null, // BUG: summarizing should always have activeSlice - activeTask: null, - phase: "summarizing", - recentDecisions: [], - blockers: [], - nextAction: "", - registry: [{ id: "M001", title: "Test", status: "active" }], - requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 }, - progress: { milestones: { done: 0, total: 1 } }, - }; - const result = await resolveDispatch({ - basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined, - }); - assert.equal(result.action, "stop", "should stop instead of crashing"); - assert.ok((result as any).reason.includes("no active slice"), `reason should mention missing slice: ${(result as any).reason}`); -}); - -test("dispatch returns stop when phase=executing but activeSlice is null (corrupt state)", async () => { - const corruptState: GSDState = { - activeMilestone: { id: "M001", title: "Test" }, - activeSlice: null, - activeTask: { id: "T01", title: "Task" }, - phase: "executing", - recentDecisions: [], - blockers: [], - nextAction: "", - registry: [{ id: "M001", title: "Test", status: "active" }], - requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 }, - progress: { milestones: { done: 0, total: 1 } }, - }; - const result = await resolveDispatch({ - basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined, - }); - assert.equal(result.action, "stop", "should stop instead of crashing"); -}); - -// ─── Phase 6: Worktree & Lock Consistency ──────────────────────────────── - -test("repoIdentity returns a 12-char hex hash", async () => { - const { repoIdentity } = await import("../repo-identity.ts"); - const hash = repoIdentity(process.cwd()); - assert.ok(hash.length === 12, `hash should be 12 hex chars, got: ${hash}`); - assert.match(hash, /^[a-f0-9]{12}$/, `hash should be hex, got: ${hash}`); -}); - -test("session lock settings: retry path matches primary stale timeout", async () => { - // Verify the fix for #1304 — retry lock must use same settings as primary - const lockSource = (await import("node:fs")).readFileSync( - "src/resources/extensions/gsd/session-lock.ts", "utf-8" - ); - // Find all stale: settings - const staleMatches = [...lockSource.matchAll(/stale:\s*(\d[\d_]*)/g)]; - const staleValues = staleMatches.map(m => parseInt(m[1]!.replace(/_/g, ""), 10)); - // All stale values should be the same (primary and retry aligned) - const uniqueStale = [...new Set(staleValues)]; - assert.equal(uniqueStale.length, 1, `all stale timeouts should be identical, got: ${staleValues.join(", ")}`); -}); - -test("COMPLETION_TRANSITION_CODES are a subset of DoctorIssueCode", async () => { - const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts"); - // Just verify the set is non-empty and contains expected codes - assert.ok(COMPLETION_TRANSITION_CODES.size >= 3, "should have at least 3 transition codes"); - for (const code of COMPLETION_TRANSITION_CODES) { - assert.ok(typeof code === "string", `code should be string: ${code}`); - assert.ok(code.startsWith("all_tasks_done_"), `code should start with all_tasks_done_: ${code}`); - } -}); - -// ─── Scope 2: State Derivation — Array Safety ──────────────────────────── - -test("deriveState: registry is always an array with malformed roadmap", async () => { - const tmp = makeTmp("malformed-roadmap"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - // Roadmap exists but is completely empty - writeFileSync(join(mDir, "M001-ROADMAP.md"), ""); - const state = await deriveState(tmp); - assert.ok(Array.isArray(state.registry), "registry must be array"); - assert.equal(state.activeMilestone?.id, "M001"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("deriveState: plan with garbled content still returns valid state", async () => { - const tmp = makeTmp("garbled-plan"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: false }, - ])); - // Plan file exists but contains garbage - writeFileSync(join(sDir, "S01-PLAN.md"), "just some random text\nno tasks here\n!!!"); - const state = await deriveState(tmp); - // Should fall back to planning since no tasks parsed - assert.equal(state.phase, "planning"); - assert.equal(state.activeSlice?.id, "S01"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -// ─── Scope 4: Lock Management — Exit Handler Verification ──────────────── - -test("session lock: releaseSessionLock removes auto.lock file", async () => { - const tmp = makeTmp("lock-release"); - try { - const gsd = join(tmp, ".gsd"); - mkdirSync(gsd, { recursive: true }); - const lockFile = join(gsd, "auto.lock"); - writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); - assert.ok(existsSync(lockFile), "lock file should exist before release"); - - const { releaseSessionLock } = await import("../session-lock.ts"); - releaseSessionLock(tmp); - - assert.ok(!existsSync(lockFile), "lock file should be removed after release"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("session lock: onCompromised handler exists in both primary and retry paths", async () => { - const lockSource = readFileSync( - "src/resources/extensions/gsd/session-lock.ts", "utf-8" - ); - const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)]; - // Should have at least 2 onCompromised handlers (primary + retry) - // plus the flag declaration and the check in validateSessionLock - assert.ok(compromisedMatches.length >= 3, - `expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`); -}); - -test("session lock: both onCompromised handlers null _releaseFunction (#1315)", async () => { - const lockSource = readFileSync( - "src/resources/extensions/gsd/session-lock.ts", "utf-8" - ); - // Extract onCompromised handler blocks — both should set _releaseFunction = null - const handlers = lockSource.match(/onCompromised:\s*\(\)\s*=>\s*\{[^}]+\}/g) || []; - assert.ok(handlers.length >= 2, `expected ≥2 onCompromised handlers, got ${handlers.length}`); - for (const h of handlers) { - assert.ok(h.includes("_releaseFunction = null"), - `onCompromised handler should null _releaseFunction: ${h}`); - } -}); - -test("session lock: exit handler uses ensureExitHandler to prevent double-registration (#1315)", async () => { - const lockSource = readFileSync( - "src/resources/extensions/gsd/session-lock.ts", "utf-8" - ); - // Should use ensureExitHandler instead of direct process.once("exit") in acquire paths - const directExitHandlers = (lockSource.match(/process\.once\("exit"/g) || []).length; - const ensureExitCalls = (lockSource.match(/ensureExitHandler\(/g) || []).length; - // Only 1 direct process.once("exit") allowed — inside ensureExitHandler itself - assert.ok(directExitHandlers <= 1, - `expected ≤1 direct process.once("exit") (inside ensureExitHandler), got ${directExitHandlers}`); - assert.ok(ensureExitCalls >= 2, - `expected ≥2 ensureExitHandler calls (primary + retry path), got ${ensureExitCalls}`); -}); - -test("signal handler: SIGINT handler registered alongside SIGTERM (#1315)", async () => { - const supervisorSource = readFileSync( - "src/resources/extensions/gsd/auto-supervisor.ts", "utf-8" - ); - // registerSigtermHandler should register on both SIGTERM and SIGINT - assert.ok(supervisorSource.includes('process.on("SIGINT"') || supervisorSource.includes("process.on('SIGINT'"), - "registerSigtermHandler should register SIGINT handler"); - assert.ok(supervisorSource.includes('process.off("SIGINT"') || supervisorSource.includes("process.off('SIGINT'"), - "deregisterSigtermHandler should deregister SIGINT handler"); -}); - -// ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ──────────── - -test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => { - const { formatCrashInfo } = await import("../crash-recovery.ts"); - const info = formatCrashInfo({ - pid: 12345, - startedAt: new Date().toISOString(), - unitType: "starting", - unitId: "bootstrap", - unitStartedAt: new Date().toISOString(), - completedUnits: 0, - }); - assert.ok(info.includes("bootstrap"), "should mention bootstrap"); - assert.ok(info.includes("No work was lost") || info.includes("/gsd auto"), - "should include recovery guidance for bootstrap crash"); -}); - -test("crash recovery: formatCrashInfo includes guidance for execute-task crash", async () => { - const { formatCrashInfo } = await import("../crash-recovery.ts"); - const info = formatCrashInfo({ - pid: 12345, - startedAt: new Date().toISOString(), - unitType: "execute-task", - unitId: "M001/S01/T02", - unitStartedAt: new Date().toISOString(), - completedUnits: 5, - }); - assert.ok(info.includes("execute"), "should mention execute"); - assert.ok(info.includes("resume") || info.includes("preserved") || info.includes("/gsd auto"), - "should include recovery guidance for task crash"); -}); - -test("crash recovery: formatCrashInfo includes guidance for complete-slice crash", async () => { - const { formatCrashInfo } = await import("../crash-recovery.ts"); - const info = formatCrashInfo({ - pid: 12345, - startedAt: new Date().toISOString(), - unitType: "complete-slice", - unitId: "M001/S01", - unitStartedAt: new Date().toISOString(), - completedUnits: 10, - }); - assert.ok(info.includes("complete"), "should mention complete"); - assert.ok(info.includes("finish") || info.includes("/gsd auto"), - "should include recovery guidance for completion crash"); -}); - -test("crash recovery: formatCrashInfo includes guidance for research crash", async () => { - const { formatCrashInfo } = await import("../crash-recovery.ts"); - const info = formatCrashInfo({ - pid: 12345, - startedAt: new Date().toISOString(), - unitType: "research-milestone", - unitId: "M001", - unitStartedAt: new Date().toISOString(), - completedUnits: 1, - }); - assert.ok(info.includes("research"), "should mention research"); - assert.ok(info.includes("incomplete") || info.includes("re-run") || info.includes("/gsd auto"), - "should include recovery guidance for research crash"); -}); - -// ─── Scope 6: Milestone Transitions — Dispatch Flow ───────────────────── - -test("dispatch: needs-discussion stops with discussion guidance", async () => { - const tmp = makeTmp("dispatch-discussion"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nIdeas."); - const state = await deriveState(tmp); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "stop"); - assert.ok((result as any).reason.includes("discussion") || (result as any).reason.includes("discuss"), - "stop reason should mention discussion"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("dispatch: pre-planning without context stops with guidance", async () => { - const tmp = makeTmp("dispatch-no-context"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - // No context, no roadmap — just a bare milestone directory - const state = await deriveState(tmp); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "stop"); - assert.ok((result as any).reason.includes("context") || (result as any).reason.includes("discuss"), - "stop reason should mention missing context"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("dispatch: pre-planning with context dispatches research-milestone", async () => { - const tmp = makeTmp("dispatch-research"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - mkdirSync(mDir, { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nBuild a thing."); - const state = await deriveState(tmp); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "dispatch"); - assert.equal((result as any).unitType, "research-milestone"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("dispatch: executing phase dispatches execute-task", async () => { - const tmp = makeTmp("dispatch-execute"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: false }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Do work", done: false }, - ])); - writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nDo the thing."); - const state = await deriveState(tmp); - assert.equal(state.phase, "executing"); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "dispatch"); - assert.equal((result as any).unitType, "execute-task"); - assert.equal((result as any).unitId, "M001/S01/T01"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("dispatch: summarizing phase dispatches complete-slice", async () => { - const tmp = makeTmp("dispatch-complete-slice"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: false }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Done task", done: true }, - ])); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); - const state = await deriveState(tmp); - assert.equal(state.phase, "summarizing"); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "dispatch"); - assert.equal((result as any).unitType, "complete-slice"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("dispatch: validating-milestone dispatches validate-milestone", async () => { - const tmp = makeTmp("dispatch-validate"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: true }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Done", done: true }, - ])); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); - writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone."); - const state = await deriveState(tmp); - assert.equal(state.phase, "validating-milestone"); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "dispatch"); - assert.equal((result as any).unitType, "validate-milestone"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("dispatch: completing-milestone dispatches complete-milestone", async () => { - const tmp = makeTmp("dispatch-complete-ms"); - try { - const mDir = join(tmp, ".gsd", "milestones", "M001"); - const sDir = join(mDir, "slices", "S01"); - mkdirSync(join(sDir, "tasks"), { recursive: true }); - writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); - writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ - { id: "S01", title: "Test", done: true }, - ])); - writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ - { id: "T01", title: "Done", done: true }, - ])); - writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); - writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone."); - writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nPassed."); - const state = await deriveState(tmp); - assert.equal(state.phase, "completing-milestone"); - const result = await resolveDispatch({ - basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, - }); - assert.equal(result.action, "dispatch"); - assert.equal((result as any).unitType, "complete-milestone"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); diff --git a/src/resources/extensions/gsd/tests/mechanical-completion.test.ts b/src/resources/extensions/gsd/tests/mechanical-completion.test.ts deleted file mode 100644 index 91bcf9cec..000000000 --- a/src/resources/extensions/gsd/tests/mechanical-completion.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Mechanical Completion — unit tests (ADR-003). - * - * Tests deterministic slice/milestone completion using fixture data. - * Uses node:test + node:assert for consistency with token-profile.test.ts. - */ - -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { randomBytes } from "node:crypto"; - -// ─── Fixture Helpers ────────────────────────────────────────────────────────── - -function createTmpBase(): string { - const base = join(tmpdir(), `gsd-mech-test-${randomBytes(4).toString("hex")}`); - mkdirSync(base, { recursive: true }); - return base; -} - -function scaffold(base: string, mid: string, sid: string, taskSummaries: Array<{ tid: string; content: string }>) { - const gsdRoot = join(base, ".gsd"); - const mDir = join(gsdRoot, "milestones", mid); - const sDir = join(mDir, "slices", sid); - const tDir = join(sDir, "tasks"); - mkdirSync(tDir, { recursive: true }); - - for (const { tid, content } of taskSummaries) { - writeFileSync(join(tDir, `${tid}-SUMMARY.md`), content, "utf-8"); - } - - return { gsdRoot, mDir, sDir, tDir }; -} - -function makeTaskSummary(tid: string, opts: { - oneLiner?: string; - provides?: string[]; - key_files?: string[]; - key_decisions?: string[]; - verification_result?: string; -}): string { - const lines: string[] = [ - "---", - `id: ${tid}`, - `parent: S01`, - `milestone: M001`, - ]; - if (opts.provides?.length) lines.push(`provides:\n${opts.provides.map(p => ` - ${p}`).join("\n")}`); - if (opts.key_files?.length) lines.push(`key_files:\n${opts.key_files.map(f => ` - ${f}`).join("\n")}`); - if (opts.key_decisions?.length) lines.push(`key_decisions:\n${opts.key_decisions.map(d => ` - ${d}`).join("\n")}`); - lines.push(`verification_result: ${opts.verification_result ?? "passed"}`); - lines.push("---"); - lines.push(""); - lines.push(`# ${tid}: Test Task`); - lines.push(""); - if (opts.oneLiner) lines.push(`**${opts.oneLiner}**`); - lines.push(""); - lines.push("## What Happened"); - lines.push(""); - lines.push(`Implemented the feature described in ${tid}. This was a significant change that modified multiple files across the codebase to support the new functionality.`); - lines.push(""); - return lines.join("\n"); -} - -// ─── Source-level structural tests ──────────────────────────────────────────── - -const mechanicalSrc = readFileSync( - join(import.meta.dirname!, "..", "mechanical-completion.ts"), - "utf-8", -); - -test("mechanical-completion: exports mechanicalSliceCompletion", () => { - assert.ok( - mechanicalSrc.includes("export async function mechanicalSliceCompletion"), - "should export mechanicalSliceCompletion", - ); -}); - -test("mechanical-completion: exports aggregateMilestoneVerification", () => { - assert.ok( - mechanicalSrc.includes("export async function aggregateMilestoneVerification"), - "should export aggregateMilestoneVerification", - ); -}); - -test("mechanical-completion: exports generateMilestoneSummary", () => { - assert.ok( - mechanicalSrc.includes("export async function generateMilestoneSummary"), - "should export generateMilestoneSummary", - ); -}); - -test("mechanical-completion: exports appendNewDecisions", () => { - assert.ok( - mechanicalSrc.includes("export async function appendNewDecisions"), - "should export appendNewDecisions", - ); -}); - -test("mechanical-completion: uses atomicWriteSync for file writes", () => { - assert.ok( - mechanicalSrc.includes("atomicWriteSync"), - "should use atomicWriteSync for safe file writes", - ); -}); - -test("mechanical-completion: quality gate checks summary length for multi-task slices", () => { - assert.ok( - mechanicalSrc.includes("totalContent.length < 200"), - "should have quality gate for summary content length", - ); -}); - -test("mechanical-completion: marks slice [x] in roadmap", () => { - assert.ok( - mechanicalSrc.includes("markSliceInRoadmap"), - "should mark slice done in roadmap", - ); -}); - -test("mechanical-completion: aggregates VERIFY.json files for milestone validation", () => { - assert.ok( - mechanicalSrc.includes("resolveTaskJsonFiles") && mechanicalSrc.includes("VERIFY"), - "should read VERIFY.json files for milestone validation", - ); -}); - -test("mechanical-completion: deduplicates decisions against existing DECISIONS.md", () => { - assert.ok( - mechanicalSrc.includes("existing.includes(d.trim())"), - "should deduplicate decisions against existing content", - ); -}); - -test("mechanical-completion: produces VALIDATION.md with verdict frontmatter", () => { - assert.ok( - mechanicalSrc.includes("verdict:") && mechanicalSrc.includes("remediation_round: 0"), - "VALIDATION.md should have verdict and remediation_round frontmatter", - ); -}); - -// ─── Integration tests with fixture data ────────────────────────────────────── - -test("mechanical: slice completion with 2 task summaries produces SUMMARY.md", async () => { - const base = createTmpBase(); - try { - const mid = "M001"; - const sid = "S01"; - - // Scaffold task summaries - scaffold(base, mid, sid, [ - { - tid: "T01", - content: makeTaskSummary("T01", { - oneLiner: "Set up project structure", - provides: ["project-scaffold"], - key_files: ["src/index.ts", "package.json"], - verification_result: "passed", - }), - }, - { - tid: "T02", - content: makeTaskSummary("T02", { - oneLiner: "Add core API endpoints", - provides: ["api-endpoints"], - key_files: ["src/api.ts"], - key_decisions: ["Used Express over Fastify"], - verification_result: "passed", - }), - }, - ]); - - // Write a roadmap with the slice unchecked - const roadmapPath = join(base, ".gsd", "milestones", mid, `${mid}-ROADMAP.md`); - writeFileSync(roadmapPath, `# Roadmap\n\n- [ ] **${sid}: First Slice**\n`, "utf-8"); - - // Write a slice plan with Verification section - const planPath = join(base, ".gsd", "milestones", mid, "slices", sid, `${sid}-PLAN.md`); - writeFileSync(planPath, `# Plan\n\n## Verification\n\n- Run \`npm test\`\n- Check output\n`, "utf-8"); - - // Dynamic import to get the actual module - const { mechanicalSliceCompletion } = await import("../mechanical-completion.js"); - const ok = await mechanicalSliceCompletion(base, mid, sid); - - assert.ok(ok, "should return true for valid slice completion"); - - // Check SUMMARY.md was written - const summaryPath = join(base, ".gsd", "milestones", mid, "slices", sid, `${sid}-SUMMARY.md`); - assert.ok(existsSync(summaryPath), "SUMMARY.md should exist"); - - const summaryContent = readFileSync(summaryPath, "utf-8"); - assert.ok(summaryContent.includes("T01"), "summary should reference T01"); - assert.ok(summaryContent.includes("T02"), "summary should reference T02"); - assert.ok(summaryContent.includes("verification_result: passed"), "should have passed verification"); - - // Check roadmap was updated - const updatedRoadmap = readFileSync(roadmapPath, "utf-8"); - assert.ok(updatedRoadmap.includes("[x]"), "roadmap should have [x] checkbox"); - - // Check UAT was written - const uatPath = join(base, ".gsd", "milestones", mid, "slices", sid, `${sid}-UAT.md`); - assert.ok(existsSync(uatPath), "UAT.md should exist"); - const uatContent = readFileSync(uatPath, "utf-8"); - assert.ok(uatContent.includes("npm test"), "UAT should contain verification content"); - } finally { - rmSync(base, { recursive: true, force: true }); - } -}); - -test("mechanical: returns false for empty task summaries", async () => { - const base = createTmpBase(); - try { - const mid = "M001"; - const sid = "S01"; - scaffold(base, mid, sid, []); - - const { mechanicalSliceCompletion } = await import("../mechanical-completion.js"); - const ok = await mechanicalSliceCompletion(base, mid, sid); - assert.ok(!ok, "should return false when no summaries exist"); - } finally { - rmSync(base, { recursive: true, force: true }); - } -}); - -test("mechanical: returns false for insufficient summary content in multi-task slice", async () => { - const base = createTmpBase(); - try { - const mid = "M001"; - const sid = "S01"; - - // Two tasks but with very short content (under 200 chars) - scaffold(base, mid, sid, [ - { tid: "T01", content: "---\nid: T01\nparent: S01\nmilestone: M001\n---\n\n# T01: A\n\n**Short**\n" }, - { tid: "T02", content: "---\nid: T02\nparent: S01\nmilestone: M001\n---\n\n# T02: B\n\n**Brief**\n" }, - ]); - - const { mechanicalSliceCompletion } = await import("../mechanical-completion.js"); - const ok = await mechanicalSliceCompletion(base, mid, sid); - assert.ok(!ok, "should return false when summaries are too short"); - } finally { - rmSync(base, { recursive: true, force: true }); - } -}); - -test("mechanical: milestone verification aggregates VERIFY.json files", async () => { - const base = createTmpBase(); - try { - const mid = "M001"; - const sid = "S01"; - const { tDir } = scaffold(base, mid, sid, []); - - // Write VERIFY.json files - const evidence = { - schemaVersion: 1, - taskId: "T01", - unitId: "M001/S01/T01", - timestamp: Date.now(), - passed: true, - discoverySource: "plan", - checks: [ - { command: "npm test", exitCode: 0, durationMs: 1500, verdict: "pass", blocking: true }, - ], - }; - writeFileSync(join(tDir, "T01-VERIFY.json"), JSON.stringify(evidence), "utf-8"); - - const evidence2 = { ...evidence, taskId: "T02", passed: false, checks: [ - { command: "npm test", exitCode: 1, durationMs: 500, verdict: "fail", blocking: true }, - ]}; - writeFileSync(join(tDir, "T02-VERIFY.json"), JSON.stringify(evidence2), "utf-8"); - - const { aggregateMilestoneVerification } = await import("../mechanical-completion.js"); - const result = await aggregateMilestoneVerification(base, mid); - - assert.equal(result.verdict, "mixed", "should be mixed when some pass and some fail"); - assert.equal(result.checks.length, 2, "should have 2 checks"); - - // Check VALIDATION.md was written - const validationPath = join(base, ".gsd", "milestones", mid, `${mid}-VALIDATION.md`); - assert.ok(existsSync(validationPath), "VALIDATION.md should exist"); - const validationContent = readFileSync(validationPath, "utf-8"); - assert.ok(validationContent.includes("verdict: mixed"), "should have mixed verdict in frontmatter"); - } finally { - rmSync(base, { recursive: true, force: true }); - } -}); - -test("mechanical: milestone summary aggregates slice summaries", async () => { - const base = createTmpBase(); - try { - const mid = "M001"; - - // Create two slices with summaries - for (const sid of ["S01", "S02"]) { - const sDir = join(base, ".gsd", "milestones", mid, "slices", sid); - mkdirSync(sDir, { recursive: true }); - writeFileSync( - join(sDir, `${sid}-SUMMARY.md`), - `---\nid: ${sid}\nprovides:\n - feature-${sid.toLowerCase()}\nkey_files:\n - src/${sid.toLowerCase()}.ts\n---\n\n# ${sid}: Slice\n\n**${sid} implemented**\n`, - "utf-8", - ); - } - - const { generateMilestoneSummary } = await import("../mechanical-completion.js"); - const content = await generateMilestoneSummary(base, mid); - - assert.ok(content.includes("S01"), "should reference S01"); - assert.ok(content.includes("S02"), "should reference S02"); - assert.ok(content.includes("feature-s01"), "should aggregate provides"); - assert.ok(content.includes("feature-s02"), "should aggregate provides"); - - const summaryPath = join(base, ".gsd", "milestones", mid, `${mid}-SUMMARY.md`); - assert.ok(existsSync(summaryPath), "M##-SUMMARY.md should exist"); - } finally { - rmSync(base, { recursive: true, force: true }); - } -}); - -test("mechanical: decision deduplication skips existing decisions", async () => { - const base = createTmpBase(); - try { - const gsdRoot = join(base, ".gsd"); - mkdirSync(gsdRoot, { recursive: true }); - - // Write existing decisions - const decisionsPath = join(gsdRoot, "DECISIONS.md"); - writeFileSync(decisionsPath, "# Decisions\n\n- Used TypeScript for type safety\n", "utf-8"); - - const { appendNewDecisions } = await import("../mechanical-completion.js"); - - // Call with one existing and one new decision - const mockSummaries = [ - { - frontmatter: { - id: "T01", parent: "S01", milestone: "M001", - provides: [], requires: [], affects: [], - key_files: [], key_decisions: ["Used TypeScript for type safety", "Chose Express over Koa"], - patterns_established: [], drill_down_paths: [], observability_surfaces: [], - duration: "", verification_result: "passed", completed_at: "", blocker_discovered: false, - }, - title: "T01", oneLiner: "", whatHappened: "", deviations: "", filesModified: [], - }, - ]; - - await appendNewDecisions(base, mockSummaries as any); - - const updated = readFileSync(decisionsPath, "utf-8"); - assert.ok(updated.includes("Chose Express over Koa"), "should append new decision"); - // The existing decision should not be duplicated - const matches = updated.match(/Used TypeScript for type safety/g); - assert.equal(matches?.length, 1, "should not duplicate existing decision"); - } finally { - rmSync(base, { recursive: true, force: true }); - } -}); 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 332e1b685..d66b9126f 100644 --- a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts @@ -38,9 +38,6 @@ function createTempRepo(): string { run("git config user.email test@test.com", dir); run("git config user.name Test", dir); writeFileSync(join(dir, "README.md"), "# test\n"); - // Mirror production: .gsd/worktrees/ is gitignored so autoCommitDirtyState - // doesn't pick up the worktrees directory as dirty state (#1127 fix). - writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n"); run("git add .", dir); run("git commit -m init", dir); run("git branch -M main", dir); @@ -125,23 +122,23 @@ test("worktree swap on milestone transition: merge old, create new", () => { // ─── Verify the transition code path exists in auto.ts ────────────────────── -test("auto.ts milestone transition block contains worktree lifecycle", () => { +test("auto-loop.ts milestone transition block contains worktree lifecycle", () => { const autoSrc = readFileSync( - join(__dirname, "..", "auto.ts"), + join(__dirname, "..", "auto-loop.ts"), "utf-8", ); - // The fix adds worktree merge + create inside the milestone transition block + // The resolver handles worktree merge + enter inside the milestone transition block assert.ok( autoSrc.includes("Worktree lifecycle on milestone transition"), - "auto.ts should contain the worktree lifecycle comment marker", + "auto-loop.ts should contain the worktree lifecycle comment marker", ); assert.ok( - autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== s.currentMilestoneId"), - "auto.ts should call mergeMilestoneToMain during milestone transition", + autoSrc.includes("resolver.mergeAndExit") && autoSrc.includes("mid !== s.currentMilestoneId"), + "auto-loop.ts should call resolver.mergeAndExit during milestone transition", ); assert.ok( - autoSrc.includes("createAutoWorktree") && autoSrc.includes("Created auto-worktree for"), - "auto.ts should create new worktree for incoming milestone", + autoSrc.includes("resolver.enterMilestone"), + "auto-loop.ts should call resolver.enterMilestone for incoming milestone", ); }); diff --git a/src/resources/extensions/gsd/tests/progress-score.test.ts b/src/resources/extensions/gsd/tests/progress-score.test.ts deleted file mode 100644 index 65096c68e..000000000 --- a/src/resources/extensions/gsd/tests/progress-score.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * progress-score.test.ts — Tests for progress score / traffic light (#1221). - * - * Tests: - * - Score computation from health signals - * - Signal evaluation (trend, error streak, recent errors) - * - Context-aware scoring (retry counts, unit progress) - * - Formatting (single-line, detailed report) - */ - -import { - recordHealthSnapshot, - resetProactiveHealing, -} from "../doctor-proactive.ts"; - -import { - computeProgressScore, - computeProgressScoreWithContext, - formatProgressLine, - formatProgressReport, -} from "../progress-score.ts"; - -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - -async function main(): Promise { - try { - // ── Base Score: No Data ───────────────────────────────────────────── - console.log("\n=== progress: green with no data ==="); - { - resetProactiveHealing(); - const score = computeProgressScore(); - assertEq(score.level, "green", "green when no data available"); - assertTrue(score.summary.includes("Progressing well"), "summary says progressing"); - assertTrue(score.signals.length > 0, "has signals"); - } - - // ── Green: Clean Health Data ──────────────────────────────────────── - console.log("\n=== progress: green with clean health ==="); - { - resetProactiveHealing(); - for (let i = 0; i < 5; i++) { - recordHealthSnapshot(0, 0, 0); - } - const score = computeProgressScore(); - assertEq(score.level, "green", "green with all clean snapshots"); - } - - // ── Yellow: Some Warnings ────────────────────────────────────────── - console.log("\n=== progress: yellow with error streak ==="); - { - resetProactiveHealing(); - recordHealthSnapshot(1, 2, 0); - recordHealthSnapshot(1, 1, 0); - const score = computeProgressScore(); - assertEq(score.level, "yellow", "yellow with consecutive errors"); - assertTrue(score.summary.includes("Struggling"), "summary says struggling"); - } - - // ── Red: Degrading Health ────────────────────────────────────────── - console.log("\n=== progress: red with degrading trend ==="); - { - resetProactiveHealing(); - // 5 older clean snapshots - for (let i = 0; i < 5; i++) { - recordHealthSnapshot(0, 0, 0); - } - // 5 recent error snapshots — triggers degrading trend - for (let i = 0; i < 5; i++) { - recordHealthSnapshot(3, 5, 0); - } - const score = computeProgressScore(); - assertEq(score.level, "red", "red with degrading trend and persistent errors"); - assertTrue(score.summary.includes("Stuck"), "summary says stuck"); - } - - // ── Red: High Error Streak ───────────────────────────────────────── - console.log("\n=== progress: red with high error streak ==="); - { - resetProactiveHealing(); - for (let i = 0; i < 4; i++) { - recordHealthSnapshot(2, 0, 0); - } - const score = computeProgressScore(); - assertEq(score.level, "red", "red with 4 consecutive error units"); - } - - // ── Context-Aware Scoring ────────────────────────────────────────── - console.log("\n=== progress: context with retries ==="); - { - resetProactiveHealing(); - for (let i = 0; i < 3; i++) { - recordHealthSnapshot(0, 0, 0); - } - const score = computeProgressScoreWithContext({ - currentUnitId: "M001/S01/T03", - completedUnits: 2, - totalUnits: 5, - retryCount: 0, - maxRetries: 5, - }); - assertEq(score.level, "green", "green with no retries"); - assertTrue(score.summary.includes("M001/S01/T03"), "summary includes unit ID"); - assertTrue(score.summary.includes("2 of 5"), "summary includes progress"); - } - - console.log("\n=== progress: context with high retry count ==="); - { - resetProactiveHealing(); - for (let i = 0; i < 3; i++) { - recordHealthSnapshot(0, 0, 0); - } - const score = computeProgressScoreWithContext({ - currentUnitId: "M001/S01/T03", - retryCount: 4, - maxRetries: 5, - }); - assertEq(score.level, "red", "red with high retry count"); - assertTrue(score.summary.includes("looping"), "summary mentions looping"); - } - - console.log("\n=== progress: context with moderate retries ==="); - { - resetProactiveHealing(); - for (let i = 0; i < 3; i++) { - recordHealthSnapshot(0, 0, 0); - } - const score = computeProgressScoreWithContext({ - currentUnitId: "M001/S01/T03", - retryCount: 1, - maxRetries: 5, - }); - assertEq(score.level, "yellow", "yellow with 1 retry"); - } - - // ── Formatting ───────────────────────────────────────────────────── - console.log("\n=== progress: formatProgressLine ==="); - { - resetProactiveHealing(); - const score = computeProgressScore(); - const line = formatProgressLine(score); - assertTrue(line.includes("Progressing well"), "line includes summary"); - // Should start with green circle emoji - assertTrue(line.startsWith("\uD83D\uDFE2"), "starts with green circle"); - } - - console.log("\n=== progress: formatProgressLine yellow ==="); - { - resetProactiveHealing(); - recordHealthSnapshot(1, 0, 0); - recordHealthSnapshot(1, 0, 0); - const score = computeProgressScore(); - const line = formatProgressLine(score); - assertTrue(line.startsWith("\uD83D\uDFE1"), "starts with yellow circle"); - } - - console.log("\n=== progress: formatProgressReport ==="); - { - resetProactiveHealing(); - recordHealthSnapshot(0, 1, 0); - const score = computeProgressScore(); - const detailed = formatProgressReport(score); - assertTrue(detailed.includes("Signals:"), "report has signals section"); - assertTrue(detailed.includes("health_trend"), "report includes trend signal"); - assertTrue(detailed.includes("error_streak"), "report includes streak signal"); - } - - // ── Signal Details ───────────────────────────────────────────────── - console.log("\n=== progress: signal names are consistent ==="); - { - resetProactiveHealing(); - recordHealthSnapshot(0, 0, 0); - const score = computeProgressScore(); - const names = score.signals.map(s => s.name); - assertTrue(names.includes("health_trend"), "has health_trend signal"); - assertTrue(names.includes("error_streak"), "has error_streak signal"); - assertTrue(names.includes("recent_errors"), "has recent_errors signal"); - assertTrue(names.includes("artifact_production"), "has artifact_production signal"); - assertTrue(names.includes("dispatch_velocity"), "has dispatch_velocity signal"); - } - - console.log("\n=== progress: all signals have valid levels ==="); - { - resetProactiveHealing(); - for (let i = 0; i < 5; i++) { - recordHealthSnapshot(1, 1, 1); - } - const score = computeProgressScore(); - for (const signal of score.signals) { - assertTrue( - signal.level === "green" || signal.level === "yellow" || signal.level === "red", - `signal ${signal.name} has valid level: ${signal.level}`, - ); - assertTrue(signal.detail.length > 0, `signal ${signal.name} has non-empty detail`); - } - } - - } finally { - resetProactiveHealing(); - } - - report(); -} - -main(); diff --git a/src/resources/extensions/gsd/tests/provider-errors.test.ts b/src/resources/extensions/gsd/tests/provider-errors.test.ts index 7ac8d0efe..35a7dd9ff 100644 --- a/src/resources/extensions/gsd/tests/provider-errors.test.ts +++ b/src/resources/extensions/gsd/tests/provider-errors.test.ts @@ -277,13 +277,11 @@ test("index.ts tracks consecutive transient errors for escalating backoff", () = test("index.ts resets consecutive transient error counter on success", () => { const indexSource = readFileSync(join(__dirname, "..", "index.ts"), "utf-8"); - // After successful unit completion, the counter must be reset - const marker = "successful unit completion"; - const successSection = indexSource.indexOf(marker); - assert.ok(successSection > -1, "must have success section that clears network retries"); - const nearbyCode = indexSource.slice(Math.max(0, successSection - 100), successSection + 200); + // After successful unit completion, the counter must be reset. + // Use a regex across the success block so CRLF checkouts on Windows do not + // push the reset line outside a fixed substring window. assert.ok( - nearbyCode.includes("consecutiveTransientErrors = 0"), + /consecutiveTransientErrors\s*=\s*0\s*;[\s\S]{0,250}successful unit completion/.test(indexSource), "consecutive transient error counter must be reset on successful unit completion (#1166)", ); }); diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts index be24b2dfb..c1063478d 100644 --- a/src/resources/extensions/gsd/tests/run-uat.test.ts +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -334,7 +334,7 @@ async function main(): Promise { ].join('\n'), ); - // human-experience UAT — should not dispatch + // human-experience UAT still dispatches, but auto-mode later pauses for manual review writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('human-experience')); const state = { @@ -351,8 +351,8 @@ async function main(): Promise { const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); assertEq( result, - null, - 'human-experience UAT is skipped — auto-mode only dispatches artifact-driven UATs', + { sliceId: 'S01', uatType: 'human-experience' }, + 'human-experience UAT dispatches so auto-mode can pause for manual review', ); } finally { cleanup(base); diff --git a/src/resources/extensions/gsd/tests/session-lock.test.ts b/src/resources/extensions/gsd/tests/session-lock.test.ts deleted file mode 100644 index 882726caf..000000000 --- a/src/resources/extensions/gsd/tests/session-lock.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -import { - acquireSessionLock, - releaseSessionLock, - updateSessionLock, - validateSessionLock, - readSessionLockData, - isSessionLockHeld, - isSessionLockProcessAlive, - cleanupStrayLockFiles, -} from "../session-lock.ts"; - -// ─── acquireSessionLock ────────────────────────────────────────────────── - -test("acquireSessionLock succeeds on empty directory", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true, "should acquire lock on empty dir"); - - // Verify lock file was created with correct data - const lockPath = join(dir, ".gsd", "auto.lock"); - assert.ok(existsSync(lockPath), "auto.lock should exist after acquire"); - - const data = JSON.parse(readFileSync(lockPath, "utf-8")); - assert.equal(data.pid, process.pid, "lock should contain current PID"); - assert.equal(data.unitType, "starting", "initial unit type should be 'starting'"); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -test("acquireSessionLock rejects when another live process holds lock", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - // Simulate another process holding the lock by writing a lock with parent PID - const fakeLockData = { - pid: process.ppid, - startedAt: new Date().toISOString(), - unitType: "execute-task", - unitId: "M001/S01/T01", - unitStartedAt: new Date().toISOString(), - completedUnits: 2, - }; - writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(fakeLockData, null, 2)); - - // First acquire to set up proper-lockfile state - const result1 = acquireSessionLock(dir); - - // If proper-lockfile is available, it should manage the OS lock. - // If not (fallback mode), the PID check should detect the live process. - // Either way, we can't fully simulate another process holding an OS lock - // from within the same process, so we test the fallback path. - if (result1.acquired) { - // We got the lock (proper-lockfile saw no OS lock from another process) - // This is expected since we're in the same process - releaseSessionLock(dir); - } - - rmSync(dir, { recursive: true, force: true }); -}); - -test("acquireSessionLock takes over stale lock from dead process", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - // Write a lock from a dead process - const staleLockData = { - pid: 9999999, - startedAt: "2026-03-01T00:00:00Z", - unitType: "execute-task", - unitId: "M001/S01/T01", - unitStartedAt: "2026-03-01T00:00:00Z", - completedUnits: 0, - }; - writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(staleLockData, null, 2)); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true, "should take over lock from dead process"); - - // Verify our PID is now in the lock - const data = readSessionLockData(dir); - assert.ok(data, "lock data should exist after acquire"); - assert.equal(data!.pid, process.pid, "lock should contain our PID now"); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── releaseSessionLock ───────────────────────────────────────────────── - -test("releaseSessionLock removes the lock file", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true); - - releaseSessionLock(dir); - - const lockPath = join(dir, ".gsd", "auto.lock"); - assert.ok(!existsSync(lockPath), "auto.lock should be removed after release"); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("releaseSessionLock is safe when no lock exists", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - // Should not throw - releaseSessionLock(dir); - - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── updateSessionLock ────────────────────────────────────────────────── - -test("updateSessionLock updates the lock data without re-acquiring", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true); - - updateSessionLock(dir, "execute-task", "M001/S01/T02", 3, "/tmp/session.jsonl"); - - const data = readSessionLockData(dir); - assert.ok(data, "lock data should exist after update"); - assert.equal(data!.pid, process.pid, "PID should still be ours"); - assert.equal(data!.unitType, "execute-task", "unit type should be updated"); - assert.equal(data!.unitId, "M001/S01/T02", "unit ID should be updated"); - assert.equal(data!.completedUnits, 3, "completed count should be updated"); - assert.equal(data!.sessionFile, "/tmp/session.jsonl", "session file should be recorded"); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── validateSessionLock ──────────────────────────────────────────────── - -test("validateSessionLock returns true when we hold the lock", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true); - - assert.equal(validateSessionLock(dir), true, "should validate when we hold the lock"); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -test("validateSessionLock returns false after release", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true); - assert.equal(validateSessionLock(dir), true, "should be valid while held"); - - // Release the lock — both OS lock and lock file are removed - releaseSessionLock(dir); - - // After release, _lockedPath is cleared and lock file is gone - assert.equal(isSessionLockHeld(dir), false, "should not be held after release"); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("validateSessionLock returns false when another PID owns the lock", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - // Write lock data with a different PID (parent process) - const foreignLockData = { - pid: process.ppid, - startedAt: new Date().toISOString(), - unitType: "execute-task", - unitId: "M001/S01/T01", - unitStartedAt: new Date().toISOString(), - completedUnits: 0, - }; - writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(foreignLockData, null, 2)); - - // Without holding the OS lock, validate should check PID - assert.equal(validateSessionLock(dir), false, "should fail when another PID owns lock"); - - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── isSessionLockHeld ────────────────────────────────────────────────── - -test("isSessionLockHeld returns true after acquire", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - acquireSessionLock(dir); - assert.equal(isSessionLockHeld(dir), true); - - releaseSessionLock(dir); - assert.equal(isSessionLockHeld(dir), false, "should return false after release"); - - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── isSessionLockProcessAlive ────────────────────────────────────────── - -test("isSessionLockProcessAlive returns false for dead PID", () => { - const data = { - pid: 9999999, - startedAt: new Date().toISOString(), - unitType: "starting", - unitId: "bootstrap", - unitStartedAt: new Date().toISOString(), - completedUnits: 0, - }; - assert.equal(isSessionLockProcessAlive(data), false); -}); - -test("isSessionLockProcessAlive returns false for own PID (recycled)", () => { - const data = { - pid: process.pid, - startedAt: new Date().toISOString(), - unitType: "starting", - unitId: "bootstrap", - unitStartedAt: new Date().toISOString(), - completedUnits: 0, - }; - // Own PID returns false because it means the lock is from a recycled PID - assert.equal(isSessionLockProcessAlive(data), false); -}); - -// ─── readSessionLockData ──────────────────────────────────────────────── - -test("readSessionLockData returns null when no lock exists", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const data = readSessionLockData(dir); - assert.equal(data, null); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("readSessionLockData reads existing lock data", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - const lockData = { - pid: 12345, - startedAt: "2026-03-18T00:00:00Z", - unitType: "execute-task", - unitId: "M001/S01/T01", - unitStartedAt: "2026-03-18T00:01:00Z", - completedUnits: 2, - sessionFile: "/tmp/session.jsonl", - }; - writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2)); - - const data = readSessionLockData(dir); - assert.ok(data, "should read lock data"); - assert.equal(data!.pid, 12345); - assert.equal(data!.unitType, "execute-task"); - assert.equal(data!.unitId, "M001/S01/T01"); - assert.equal(data!.completedUnits, 2); - assert.equal(data!.sessionFile, "/tmp/session.jsonl"); - - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── Acquire → Release → Re-Acquire lifecycle ────────────────────────── - -test("session lock supports acquire → release → re-acquire cycle", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - mkdirSync(join(dir, ".gsd"), { recursive: true }); - - // First acquire - const r1 = acquireSessionLock(dir); - assert.equal(r1.acquired, true, "first acquire should succeed"); - assert.equal(isSessionLockHeld(dir), true); - - // Release - releaseSessionLock(dir); - assert.equal(isSessionLockHeld(dir), false); - - // Re-acquire - const r2 = acquireSessionLock(dir); - assert.equal(r2.acquired, true, "re-acquire after release should succeed"); - assert.equal(isSessionLockHeld(dir), true); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── Lock creates .gsd/ directory if needed ───────────────────────────── - -test("acquireSessionLock creates .gsd/ if it does not exist", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - // Do NOT create .gsd/ — let the lock function do it - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true, "should succeed even without .gsd/"); - assert.ok(existsSync(join(dir, ".gsd")), ".gsd/ should be created"); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -// ─── cleanupStrayLockFiles (#1315) ────────────────────────────────────── - -test("cleanupStrayLockFiles removes numbered lock variants but preserves auto.lock", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - const gsdDir = join(dir, ".gsd"); - mkdirSync(gsdDir, { recursive: true }); - - // Create canonical lock file + numbered variants - writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}'); - writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":2}'); - writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":3}'); - writeFileSync(join(gsdDir, "auto 4.lock"), '{"pid":4}'); - - cleanupStrayLockFiles(dir); - - assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved"); - assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed"); - assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed"); - assert.ok(!existsSync(join(gsdDir, "auto 4.lock")), "auto 4.lock should be removed"); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("cleanupStrayLockFiles handles parenthesized variants", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - const gsdDir = join(dir, ".gsd"); - mkdirSync(gsdDir, { recursive: true }); - - // macOS sometimes uses parenthesized format: "auto (2).lock" - writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}'); - writeFileSync(join(gsdDir, "auto (2).lock"), '{"pid":2}'); - - cleanupStrayLockFiles(dir); - - assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved"); - assert.ok(!existsSync(join(gsdDir, "auto (2).lock")), "auto (2).lock should be removed"); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("cleanupStrayLockFiles does not remove unrelated files", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - const gsdDir = join(dir, ".gsd"); - mkdirSync(gsdDir, { recursive: true }); - - // Create unrelated files that should NOT be removed - writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}'); - writeFileSync(join(gsdDir, "config.json"), '{}'); - writeFileSync(join(gsdDir, "other.lock"), '{}'); - - cleanupStrayLockFiles(dir); - - assert.ok(existsSync(join(gsdDir, "auto.lock")), "auto.lock should be preserved"); - assert.ok(existsSync(join(gsdDir, "config.json")), "config.json should be preserved"); - assert.ok(existsSync(join(gsdDir, "other.lock")), "other.lock should be preserved"); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("cleanupStrayLockFiles is safe on empty directory", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - const gsdDir = join(dir, ".gsd"); - mkdirSync(gsdDir, { recursive: true }); - - // Should not throw - cleanupStrayLockFiles(dir); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("cleanupStrayLockFiles is safe when .gsd/ does not exist", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - - // Should not throw even without .gsd/ - cleanupStrayLockFiles(dir); - - rmSync(dir, { recursive: true, force: true }); -}); - -test("acquireSessionLock cleans stray lock files before acquiring", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - const gsdDir = join(dir, ".gsd"); - mkdirSync(gsdDir, { recursive: true }); - - // Plant stray lock files before acquire - writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}'); - writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":9999998}'); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true, "should acquire lock"); - - // Stray files should be cleaned up - assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during acquire"); - assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed during acquire"); - - releaseSessionLock(dir); - rmSync(dir, { recursive: true, force: true }); -}); - -test("releaseSessionLock cleans stray lock files after releasing", () => { - const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-")); - const gsdDir = join(dir, ".gsd"); - mkdirSync(gsdDir, { recursive: true }); - - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true); - - // Plant stray lock files (simulating cloud sync creating them during session) - writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}'); - - releaseSessionLock(dir); - - assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during release"); - assert.ok(!existsSync(join(gsdDir, "auto.lock")), "auto.lock should also be removed"); - - rmSync(dir, { recursive: true, force: true }); -}); diff --git a/src/resources/extensions/gsd/tests/sidecar-queue.test.ts b/src/resources/extensions/gsd/tests/sidecar-queue.test.ts new file mode 100644 index 000000000..7446c6722 --- /dev/null +++ b/src/resources/extensions/gsd/tests/sidecar-queue.test.ts @@ -0,0 +1,181 @@ +/** + * sidecar-queue.test.ts — Source-level contract tests for the sidecar queue pattern (S03). + * + * Verifies the structural invariants of the sidecar queue: the SidecarItem type, + * AutoSession sidecarQueue field, enqueue patterns in postUnitPostVerification, + * and dequeue logic in autoLoop. These are source-reading tests — no runtime required. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts"); +const POST_UNIT_TS_PATH = join(__dirname, "..", "auto-post-unit.ts"); +const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts"); + +function getSessionTsSource(): string { + return readFileSync(SESSION_TS_PATH, "utf-8"); +} + +function getPostUnitTsSource(): string { + return readFileSync(POST_UNIT_TS_PATH, "utf-8"); +} + +function getAutoLoopTsSource(): string { + return readFileSync(AUTO_LOOP_TS_PATH, "utf-8"); +} + +/** + * Extract the body of postUnitPostVerification from auto-post-unit.ts source. + */ +function getPostUnitPostVerificationBody(): string { + const source = getPostUnitTsSource(); + const fnIdx = source.indexOf("export async function postUnitPostVerification"); + assert.ok(fnIdx > -1, "postUnitPostVerification must exist in auto-post-unit.ts"); + return source.slice(fnIdx); +} + +// ─── SidecarItem type contract ─────────────────────────────────────────────── + +test("SidecarItem type is exported from session.ts", () => { + const source = getSessionTsSource(); + assert.ok( + source.includes("export interface SidecarItem"), + "session.ts must export the SidecarItem interface", + ); +}); + +test("SidecarItem has required kind field with hook/triage/quick-task union", () => { + const source = getSessionTsSource(); + const ifaceIdx = source.indexOf("export interface SidecarItem"); + const ifaceBlock = source.slice(ifaceIdx, ifaceIdx + 500); + assert.ok( + ifaceBlock.includes('"hook"') && ifaceBlock.includes('"triage"') && ifaceBlock.includes('"quick-task"'), + "SidecarItem.kind must be a union of 'hook' | 'triage' | 'quick-task'", + ); +}); + +// ─── AutoSession sidecarQueue field ────────────────────────────────────────── + +test("AutoSession declares sidecarQueue field", () => { + const source = getSessionTsSource(); + assert.ok( + source.includes("sidecarQueue"), + "AutoSession must declare sidecarQueue property", + ); + assert.ok( + source.includes("SidecarItem[]"), + "sidecarQueue must be typed as SidecarItem[]", + ); +}); + +test("AutoSession resets sidecarQueue in reset()", () => { + const source = getSessionTsSource(); + const resetIdx = source.indexOf("reset(): void"); + assert.ok(resetIdx > -1, "AutoSession must have a reset() method"); + const resetBlock = source.slice(resetIdx, resetIdx + 3000); + assert.ok( + resetBlock.includes("sidecarQueue"), + "reset() must clear sidecarQueue", + ); +}); + +// ─── postUnitPostVerification: no inline dispatch ──────────────────────────── + +test("postUnitPostVerification does not call pi.sendMessage", () => { + const body = getPostUnitPostVerificationBody(); + assert.ok( + !body.includes("pi.sendMessage"), + "postUnitPostVerification must not call pi.sendMessage — all dispatch goes through sidecar queue", + ); +}); + +test("postUnitPostVerification does not call newSession", () => { + const body = getPostUnitPostVerificationBody(); + assert.ok( + !body.includes("s.cmdCtx.newSession") && !body.includes("cmdCtx.newSession"), + "postUnitPostVerification must not call newSession — all dispatch goes through sidecar queue", + ); +}); + +// ─── postUnitPostVerification: sidecar enqueue for hooks ───────────────────── + +test("postUnitPostVerification pushes to sidecarQueue for hooks", () => { + const source = getPostUnitTsSource(); + // Find the hook section (marked by the post-unit hooks comment) + const hookSectionStart = source.indexOf("// ── Post-unit hooks"); + assert.ok(hookSectionStart > -1, "auto-post-unit.ts must have a post-unit hooks section"); + const triageSectionStart = source.indexOf("// ── Triage check"); + assert.ok(triageSectionStart > -1, "auto-post-unit.ts must have a triage check section"); + const hookSection = source.slice(hookSectionStart, triageSectionStart); + assert.ok( + hookSection.includes("s.sidecarQueue.push("), + "hook section must push to s.sidecarQueue", + ); + assert.ok( + hookSection.includes('kind: "hook"'), + "hook sidecar item must have kind: 'hook'", + ); +}); + +// ─── postUnitPostVerification: sidecar enqueue for triage ──────────────────── + +test("postUnitPostVerification pushes to sidecarQueue for triage", () => { + const source = getPostUnitTsSource(); + const triageSectionStart = source.indexOf("// ── Triage check"); + const quickTaskSectionStart = source.indexOf("// ── Quick-task dispatch"); + assert.ok(triageSectionStart > -1, "auto-post-unit.ts must have a triage check section"); + assert.ok(quickTaskSectionStart > -1, "auto-post-unit.ts must have a quick-task dispatch section"); + const triageSection = source.slice(triageSectionStart, quickTaskSectionStart); + assert.ok( + triageSection.includes("s.sidecarQueue.push("), + "triage section must push to s.sidecarQueue", + ); + assert.ok( + triageSection.includes('kind: "triage"'), + "triage sidecar item must have kind: 'triage'", + ); +}); + +// ─── postUnitPostVerification: sidecar enqueue for quick-tasks ─────────────── + +test("postUnitPostVerification pushes to sidecarQueue for quick-tasks", () => { + const source = getPostUnitTsSource(); + const quickTaskSectionStart = source.indexOf("// ── Quick-task dispatch"); + assert.ok(quickTaskSectionStart > -1, "auto-post-unit.ts must have a quick-task dispatch section"); + const quickTaskSection = source.slice(quickTaskSectionStart); + assert.ok( + quickTaskSection.includes("s.sidecarQueue.push("), + "quick-task section must push to s.sidecarQueue", + ); + assert.ok( + quickTaskSection.includes('kind: "quick-task"'), + "quick-task sidecar item must have kind: 'quick-task'", + ); +}); + +// ─── autoLoop: sidecar dequeue ─────────────────────────────────────────────── + +test("autoLoop has sidecar-dequeue phase", () => { + const source = getAutoLoopTsSource(); + assert.ok( + source.includes('"sidecar-dequeue"'), + "autoLoop must log phase: 'sidecar-dequeue' when draining the sidecar queue", + ); +}); + +test("autoLoop does not have inline dispatch loop", () => { + const source = getAutoLoopTsSource(); + assert.ok( + !source.includes('"await-inline-dispatch"'), + "autoLoop must not contain 'await-inline-dispatch' — replaced by sidecar queue", + ); + assert.ok( + !source.includes("while (inlineResult"), + "autoLoop must not contain a while(inlineResult...) loop — replaced by sidecar queue drain", + ); +}); diff --git a/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts b/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts index aa696853c..163b0a804 100644 --- a/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +++ b/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts @@ -28,9 +28,6 @@ function createTempRepo(): string { run("git config user.email test@test.com", dir); run("git config user.name Test", dir); writeFileSync(join(dir, "README.md"), "# test\n"); - // Mirror production: .gsd/worktrees/ is gitignored so autoCommitDirtyState - // doesn't pick up the worktrees directory as dirty state (#1127 fix). - writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n"); run("git add .", dir); run("git commit -m init", dir); run("git branch -M main", dir); diff --git a/src/resources/extensions/gsd/tests/token-profile.test.ts b/src/resources/extensions/gsd/tests/token-profile.test.ts index 26337ff72..0003e189d 100644 --- a/src/resources/extensions/gsd/tests/token-profile.test.ts +++ b/src/resources/extensions/gsd/tests/token-profile.test.ts @@ -36,16 +36,16 @@ const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8"); test("types: TokenProfile type exported with budget/balanced/quality", () => { assert.ok(typesSrc.includes("export type TokenProfile"), "TokenProfile should be exported"); - assert.ok(typesSrc.includes("'budget'"), "should include budget"); - assert.ok(typesSrc.includes("'balanced'"), "should include balanced"); - assert.ok(typesSrc.includes("'quality'"), "should include quality"); + assert.match(typesSrc, /["']budget["']/, "should include budget"); + assert.match(typesSrc, /["']balanced["']/, "should include balanced"); + assert.match(typesSrc, /["']quality["']/, "should include quality"); }); test("types: InlineLevel type exported with full/standard/minimal", () => { assert.ok(typesSrc.includes("export type InlineLevel"), "InlineLevel should be exported"); - assert.ok(typesSrc.includes("'full'"), "should include full"); - assert.ok(typesSrc.includes("'standard'"), "should include standard"); - assert.ok(typesSrc.includes("'minimal'"), "should include minimal"); + assert.match(typesSrc, /["']full["']/, "should include full"); + assert.match(typesSrc, /["']standard["']/, "should include standard"); + assert.match(typesSrc, /["']minimal["']/, "should include minimal"); }); test("types: PhaseSkipPreferences interface exported", () => { diff --git a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts index 1cf5c4183..7ea81c020 100644 --- a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts @@ -108,14 +108,14 @@ test("dispatch: triage check guards against quick-task triggering triage", () => ); }); -test("dispatch: triage dispatch uses return-value pattern", () => { +test("dispatch: triage dispatch keeps the loop in continue mode", () => { const triageBlock = postUnitSrc.slice( postUnitSrc.indexOf("// ── Triage check"), postUnitSrc.indexOf("// ── Quick-task dispatch"), ); assert.ok( - triageBlock.includes('return "dispatched"'), - "triage dispatch should return 'dispatched' after sending message", + triageBlock.includes('return "continue"'), + "triage dispatch should return 'continue' after enqueuing sidecar work", ); }); @@ -309,14 +309,14 @@ test("dispatch: quick-task dispatch marks capture as executed", () => { ); }); -test("dispatch: quick-task dispatch uses return-value pattern", () => { +test("dispatch: quick-task dispatch keeps the loop in continue mode", () => { const quickTaskSection = postUnitSrc.slice( postUnitSrc.indexOf("// ── Quick-task dispatch"), postUnitSrc.indexOf("if (s.stepMode)"), ); assert.ok( - quickTaskSection.includes('return "dispatched"'), - "quick-task dispatch should return 'dispatched' after sending message", + quickTaskSection.includes('return "continue"'), + "quick-task dispatch should return 'continue' after enqueuing sidecar work", ); }); diff --git a/src/resources/extensions/gsd/tests/undo.test.ts b/src/resources/extensions/gsd/tests/undo.test.ts index 6aee92930..fee95171b 100644 --- a/src/resources/extensions/gsd/tests/undo.test.ts +++ b/src/resources/extensions/gsd/tests/undo.test.ts @@ -19,11 +19,17 @@ test("handleUndo without --force only warns and leaves completed units intact", const base = makeTempDir("gsd-undo-confirm"); try { mkdirSync(join(base, ".gsd"), { recursive: true }); + mkdirSync(join(base, ".gsd", "activity"), { recursive: true }); writeFileSync( join(base, ".gsd", "completed-units.json"), JSON.stringify(["execute-task/M001/S01/T01"]), "utf-8", ); + writeFileSync( + join(base, ".gsd", "activity", "001-execute-task-M001-S01-T01.jsonl"), + "", + "utf-8", + ); const notifications: Array<{ message: string; level: string }> = []; const ctx = { diff --git a/src/resources/extensions/gsd/tests/verification-evidence.test.ts b/src/resources/extensions/gsd/tests/verification-evidence.test.ts index aa992807f..a02590a85 100644 --- a/src/resources/extensions/gsd/tests/verification-evidence.test.ts +++ b/src/resources/extensions/gsd/tests/verification-evidence.test.ts @@ -58,7 +58,6 @@ test("verification-evidence: writeVerificationJSON writes correct JSON shape", ( stdout: "all good", stderr: "", durationMs: 2340, - blocking: true, }, ], }); @@ -106,9 +105,9 @@ test("verification-evidence: writeVerificationJSON maps exitCode to verdict corr const result = makeResult({ passed: false, checks: [ - { command: "lint", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true }, - { command: "test", exitCode: 1, stdout: "", stderr: "fail", durationMs: 200, blocking: true }, - { command: "audit", exitCode: 2, stdout: "", stderr: "err", durationMs: 300, blocking: true }, + { command: "lint", exitCode: 0, stdout: "", stderr: "", durationMs: 100 }, + { command: "test", exitCode: 1, stdout: "", stderr: "fail", durationMs: 200 }, + { command: "audit", exitCode: 2, stdout: "", stderr: "err", durationMs: 300 }, ], }); @@ -134,7 +133,6 @@ test("verification-evidence: writeVerificationJSON excludes stdout/stderr from o stdout: "hello\n", stderr: "some warning", durationMs: 50, - blocking: true, }, ], }); @@ -183,8 +181,8 @@ test("verification-evidence: writeVerificationJSON uses optional unitId when pro test("verification-evidence: formatEvidenceTable returns markdown table with correct columns", () => { const result = makeResult({ checks: [ - { command: "npm run typecheck", exitCode: 0, stdout: "", stderr: "", durationMs: 2340, blocking: true }, - { command: "npm run lint", exitCode: 1, stdout: "", stderr: "err", durationMs: 1100, blocking: true }, + { command: "npm run typecheck", exitCode: 0, stdout: "", stderr: "", durationMs: 2340 }, + { command: "npm run lint", exitCode: 1, stdout: "", stderr: "err", durationMs: 1100 }, ], }); @@ -216,9 +214,9 @@ test("verification-evidence: formatEvidenceTable returns no-checks message for e test("verification-evidence: formatEvidenceTable formats duration as seconds with 1 decimal", () => { const result = makeResult({ checks: [ - { command: "fast", exitCode: 0, stdout: "", stderr: "", durationMs: 150, blocking: true }, - { command: "slow", exitCode: 0, stdout: "", stderr: "", durationMs: 2340, blocking: true }, - { command: "zero", exitCode: 0, stdout: "", stderr: "", durationMs: 0, blocking: true }, + { command: "fast", exitCode: 0, stdout: "", stderr: "", durationMs: 150 }, + { command: "slow", exitCode: 0, stdout: "", stderr: "", durationMs: 2340 }, + { command: "zero", exitCode: 0, stdout: "", stderr: "", durationMs: 0 }, ], }); @@ -232,8 +230,8 @@ test("verification-evidence: formatEvidenceTable uses ✅/❌ emoji for pass/fai const result = makeResult({ passed: false, checks: [ - { command: "pass-cmd", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true }, - { command: "fail-cmd", exitCode: 1, stdout: "", stderr: "", durationMs: 200, blocking: true }, + { command: "pass-cmd", exitCode: 0, stdout: "", stderr: "", durationMs: 100 }, + { command: "fail-cmd", exitCode: 1, stdout: "", stderr: "", durationMs: 200 }, ], }); @@ -337,8 +335,8 @@ test("verification-evidence: integration — VerificationResult → JSON → tab const result = makeResult({ passed: false, checks: [ - { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500, blocking: true }, - { command: "npm run test:unit", exitCode: 1, stdout: "", stderr: "1 failed", durationMs: 3200, blocking: true }, + { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500 }, + { command: "npm run test:unit", exitCode: 1, stdout: "", stderr: "1 failed", durationMs: 3200 }, ], discoverySource: "package-json", }); @@ -392,7 +390,7 @@ test("verification-evidence: writeVerificationJSON with retryAttempt and maxRetr const result = makeResult({ passed: false, checks: [ - { command: "npm run lint", exitCode: 1, stdout: "", stderr: "error", durationMs: 300, blocking: true }, + { command: "npm run lint", exitCode: 1, stdout: "", stderr: "error", durationMs: 300 }, ], }); @@ -417,7 +415,7 @@ test("verification-evidence: writeVerificationJSON without retry params omits re const result = makeResult({ passed: true, checks: [ - { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true }, + { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 }, ], }); @@ -443,7 +441,7 @@ test("verification-evidence: writeVerificationJSON includes runtimeErrors when p const result = makeResult({ passed: false, checks: [ - { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true }, + { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 }, ], runtimeErrors: [ { source: "bg-shell", severity: "crash", message: "Server crashed", blocking: true }, @@ -475,7 +473,7 @@ test("verification-evidence: writeVerificationJSON omits runtimeErrors when abse const result = makeResult({ passed: true, checks: [ - { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50, blocking: true }, + { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50 }, ], }); @@ -514,7 +512,7 @@ test("verification-evidence: formatEvidenceTable appends runtime errors section" const result = makeResult({ passed: false, checks: [ - { command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true }, + { command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100 }, ], runtimeErrors: [ { source: "bg-shell", severity: "crash", message: "Server crashed with SIGKILL", blocking: true }, @@ -539,7 +537,7 @@ test("verification-evidence: formatEvidenceTable omits runtime errors section wh const result = makeResult({ passed: true, checks: [ - { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200, blocking: true }, + { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200 }, ], }); @@ -554,7 +552,7 @@ test("verification-evidence: formatEvidenceTable truncates runtime error message const result = makeResult({ passed: false, checks: [ - { command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true }, + { command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100 }, ], runtimeErrors: [ { source: "bg-shell", severity: "error", message: longMessage, blocking: false }, @@ -600,7 +598,7 @@ test("verification-evidence: writeVerificationJSON includes auditWarnings when p const result = makeResult({ passed: true, checks: [ - { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true }, + { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 }, ], auditWarnings: SAMPLE_AUDIT_WARNINGS, }); @@ -629,7 +627,7 @@ test("verification-evidence: writeVerificationJSON omits auditWarnings when abse const result = makeResult({ passed: true, checks: [ - { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50, blocking: true }, + { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 50 }, ], }); @@ -668,7 +666,7 @@ test("verification-evidence: formatEvidenceTable appends audit warnings section" const result = makeResult({ passed: true, checks: [ - { command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100, blocking: true }, + { command: "npm run test", exitCode: 0, stdout: "", stderr: "", durationMs: 100 }, ], auditWarnings: SAMPLE_AUDIT_WARNINGS, }); @@ -691,7 +689,7 @@ test("verification-evidence: formatEvidenceTable omits audit warnings section wh const result = makeResult({ passed: true, checks: [ - { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200, blocking: true }, + { command: "npm run lint", exitCode: 0, stdout: "", stderr: "", durationMs: 200 }, ], }); @@ -707,7 +705,7 @@ test("verification-evidence: integration — VerificationResult with auditWarnin const result = makeResult({ passed: true, checks: [ - { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500, blocking: true }, + { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 1500 }, ], auditWarnings: [ { diff --git a/src/resources/extensions/gsd/tests/verification-gate.test.ts b/src/resources/extensions/gsd/tests/verification-gate.test.ts index c1c89aa4e..2b6b90929 100644 --- a/src/resources/extensions/gsd/tests/verification-gate.test.ts +++ b/src/resources/extensions/gsd/tests/verification-gate.test.ts @@ -261,71 +261,6 @@ test("verification-gate: each check has durationMs", () => { } }); -// ─── Infra Error Tagging Tests ─────────────────────────────────────────────── - -test("verification-gate: spawnSync ETIMEDOUT → infraError: true on the check", () => { - const tmp = makeTempDir("vg-etimedout"); - try { - // Use a short timeout against a long sleep to guarantee ETIMEDOUT - const result = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - preferenceCommands: ["sleep 60"], - commandTimeoutMs: 200, - }); - assert.equal(result.passed, false); - assert.equal(result.checks.length, 1); - assert.ok(result.checks[0].exitCode !== 0, "should have non-zero exit code"); - assert.equal(result.checks[0].infraError, true, "ETIMEDOUT should be tagged as infraError"); - } finally { - rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); - } -}); - -test("verification-gate: real command failure does NOT have infraError", () => { - const tmp = makeTempDir("vg-real-fail"); - try { - const result = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - // Cross-platform: node with --eval flag and no shell-sensitive characters - preferenceCommands: ["node --eval \"process.exitCode=1\""], - }); - assert.equal(result.passed, false); - assert.equal(result.checks.length, 1); - assert.equal(result.checks[0].exitCode, 1); - assert.equal(result.checks[0].infraError, undefined, "real failure should not be tagged as infraError"); - } finally { - rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); - } -}); - -test("verification-gate: mixed infra + real failure — only infra check is tagged", () => { - const tmp = makeTempDir("vg-mixed-infra"); - try { - // Use a timeout that kills "sleep 60" but lets "node --eval" complete (~80ms). - // The gate applies the same timeout to each command sequentially. - const result = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - preferenceCommands: ["sleep 60", "node --eval \"process.exitCode=2\""], - commandTimeoutMs: 500, - }); - assert.equal(result.passed, false); - assert.equal(result.checks.length, 2); - // First check: ETIMEDOUT → infraError - assert.equal(result.checks[0].infraError, true, "timed-out command should be infraError"); - // Second check: real exit 2 → no infraError - assert.equal(result.checks[1].exitCode, 2); - assert.equal(result.checks[1].infraError, undefined, "real failure should not be infraError"); - } finally { - rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); - } -}); - // ─── Preference Validation Tests ───────────────────────────────────────────── test("verification-gate: validatePreferences accepts valid verification keys", () => { @@ -646,7 +581,7 @@ test("formatFailureContext: formats a single failure with command, exit code, st const result: import("../types.ts").VerificationResult = { passed: false, checks: [ - { command: "npm run lint", exitCode: 1, stdout: "", stderr: "error: unused var", durationMs: 500, blocking: true }, + { command: "npm run lint", exitCode: 1, stdout: "", stderr: "error: unused var", durationMs: 500 }, ], discoverySource: "preference", timestamp: Date.now(), @@ -663,9 +598,9 @@ test("formatFailureContext: formats multiple failures", () => { const result: import("../types.ts").VerificationResult = { passed: false, checks: [ - { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint error", durationMs: 100, blocking: true }, - { command: "npm run test", exitCode: 2, stdout: "", stderr: "test failure", durationMs: 200, blocking: true }, - { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 50, blocking: true }, + { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint error", durationMs: 100 }, + { command: "npm run test", exitCode: 2, stdout: "", stderr: "test failure", durationMs: 200 }, + { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 50 }, ], discoverySource: "preference", timestamp: Date.now(), @@ -684,7 +619,7 @@ test("formatFailureContext: truncates stderr longer than 2000 chars", () => { const result: import("../types.ts").VerificationResult = { passed: false, checks: [ - { command: "big-err", exitCode: 1, stdout: "", stderr: longStderr, durationMs: 100, blocking: true }, + { command: "big-err", exitCode: 1, stdout: "", stderr: longStderr, durationMs: 100 }, ], discoverySource: "preference", timestamp: Date.now(), @@ -699,8 +634,8 @@ test("formatFailureContext: returns empty string when all checks pass", () => { const result: import("../types.ts").VerificationResult = { passed: true, checks: [ - { command: "npm run lint", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true }, - { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 200, blocking: true }, + { command: "npm run lint", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 }, + { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 200 }, ], discoverySource: "preference", timestamp: Date.now(), @@ -728,7 +663,6 @@ test("formatFailureContext: caps total output at 10,000 chars", () => { stdout: "", stderr: "e".repeat(1000), // 1000 chars each, 20 * ~1050 (with formatting) > 10,000 durationMs: 100, - blocking: true, }); } const result: import("../types.ts").VerificationResult = { @@ -1143,131 +1077,3 @@ test("dependency-audit: subdirectory package.json does not trigger audit", () => assert.equal(npmAuditCalled, false, "subdirectory dependency files should not trigger audit"); assert.deepStrictEqual(result, []); }); - -// ─── Non-Blocking Discovery Tests ──────────────────────────────────────────── - -test("non-blocking: package-json discovered commands failing → result.passed is still true", () => { - const tmp = makeTempDir("vg-nb-pkg-fail"); - try { - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ scripts: { lint: "eslint .", test: "vitest" } }), - ); - // These commands will fail because eslint/vitest don't exist in the temp dir - const result = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - // No preference commands — discovery falls through to package.json - }); - assert.equal(result.discoverySource, "package-json"); - assert.ok(result.checks.length > 0, "should have discovered package.json checks"); - assert.equal(result.passed, true, "package-json failures should not block the gate"); - for (const check of result.checks) { - assert.equal(check.blocking, false, "package-json checks should be non-blocking"); - } - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("non-blocking: preference commands failing → result.passed is false", () => { - const tmp = makeTempDir("vg-nb-pref-fail"); - try { - const result = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - preferenceCommands: ["sh -c 'exit 1'"], - }); - assert.equal(result.discoverySource, "preference"); - assert.equal(result.passed, false, "preference failures should block the gate"); - assert.equal(result.checks[0].blocking, true, "preference checks should be blocking"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("non-blocking: task-plan commands failing → result.passed is false", () => { - const tmp = makeTempDir("vg-nb-tp-fail"); - try { - const result = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - taskPlanVerify: "sh -c 'exit 1'", - }); - assert.equal(result.discoverySource, "task-plan"); - assert.equal(result.passed, false, "task-plan failures should block the gate"); - assert.equal(result.checks[0].blocking, true, "task-plan checks should be blocking"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("non-blocking: blocking field is set correctly based on discovery source", () => { - const tmp = makeTempDir("vg-nb-field"); - try { - // preference → blocking - const prefResult = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - preferenceCommands: ["echo ok"], - }); - assert.equal(prefResult.checks[0].blocking, true); - - // task-plan → blocking - const tpResult = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - taskPlanVerify: "echo ok", - }); - assert.equal(tpResult.checks[0].blocking, true); - - // package-json → non-blocking - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ scripts: { test: "echo ok" } }), - ); - const pkgResult = runVerificationGate({ - basePath: tmp, - unitId: "T01", - cwd: tmp, - }); - assert.equal(pkgResult.checks[0].blocking, false); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("non-blocking: formatFailureContext only includes blocking failures", () => { - const result: import("../types.ts").VerificationResult = { - passed: true, - checks: [ - { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint warning", durationMs: 100, blocking: false }, - { command: "npm run test", exitCode: 1, stdout: "", stderr: "test error", durationMs: 200, blocking: true }, - { command: "npm run typecheck", exitCode: 1, stdout: "", stderr: "type error", durationMs: 50, blocking: false }, - ], - discoverySource: "preference", - timestamp: Date.now(), - }; - const output = formatFailureContext(result); - assert.ok(output.includes("`npm run test`"), "should include blocking failure"); - assert.ok(!output.includes("npm run lint"), "should not include non-blocking failure"); - assert.ok(!output.includes("npm run typecheck"), "should not include non-blocking failure"); -}); - -test("non-blocking: formatFailureContext returns empty when only non-blocking failures exist", () => { - const result: import("../types.ts").VerificationResult = { - passed: true, - checks: [ - { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint warning", durationMs: 100, blocking: false }, - { command: "npm run test", exitCode: 1, stdout: "", stderr: "test warning", durationMs: 200, blocking: false }, - ], - discoverySource: "package-json", - timestamp: Date.now(), - }; - assert.equal(formatFailureContext(result), "", "should return empty when only non-blocking failures"); -}); diff --git a/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts new file mode 100644 index 000000000..791a5f494 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts @@ -0,0 +1,205 @@ +/** + * worktree-db-integration.test.ts + * + * Integration tests for the worktree DB copy and reconcile hooks. + * Uses real temp git repos and real SQLite databases. + * + * Test cases: + * 1. Copy: createAutoWorktree seeds .gsd/gsd.db into the worktree when main has one + * 2. Copy-skip: createAutoWorktree silently skips when main has no gsd.db + * 3. Reconcile: reconcileWorktreeDb merges worktree rows into main DB + * 4. Reconcile-skip: reconcileWorktreeDb is non-fatal when both paths are nonexistent + * 5. Failure path: reconcileWorktreeDb emits to stderr on open failure (observable) + */ + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { createAutoWorktree } from "../auto-worktree.ts"; +import { worktreePath } from "../worktree-manager.ts"; +import { + copyWorktreeDb, + reconcileWorktreeDb, + openDatabase, + closeDatabase, + upsertDecision, + getActiveDecisions, + isDbAvailable, +} from "../gsd-db.ts"; + +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +function createTempRepo(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-test-"))); + run("git init", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + writeFileSync(join(dir, "README.md"), "# test\n"); + run("git add .", dir); + run("git commit -m init", dir); + run("git branch -M main", dir); + return dir; +} + +async function main(): Promise { + const savedCwd = process.cwd(); + const tempDirs: string[] = []; + + function makeTempDir(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-"))); + tempDirs.push(dir); + return dir; + } + + try { + + // ─── Test 1: copy on worktree creation ─────────────────────────── + console.log("\n=== Test 1: copy on worktree creation ==="); + { + const tempDir = createTempRepo(); + tempDirs.push(tempDir); + + // Seed a gsd.db in the main repo + const gsdDir = join(tempDir, ".gsd"); + mkdirSync(gsdDir, { recursive: true }); + const mainDbPath = join(gsdDir, "gsd.db"); + openDatabase(mainDbPath); + closeDatabase(); + + // Commit so createAutoWorktree can copy planning artifacts + run("git add .", tempDir); + run('git commit -m "add gsd dir"', tempDir); + + // createAutoWorktree should copy the DB into the worktree + const wtPath = createAutoWorktree(tempDir, "M004"); + + const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db"); + assertTrue( + existsSync(worktreeDbPath), + "gsd.db exists in worktree .gsd after createAutoWorktree", + ); + + // Restore cwd for next test + process.chdir(savedCwd); + } + + // ─── Test 2: copy skip when no source DB ───────────────────────── + console.log("\n=== Test 2: copy skip when no source DB ==="); + { + const tempDir = createTempRepo(); + tempDirs.push(tempDir); + + // No gsd.db — just a bare repo + let threw = false; + let wtPath: string | null = null; + try { + wtPath = createAutoWorktree(tempDir, "M004"); + } catch (err) { + threw = true; + console.error(" Unexpected throw:", err); + } + + assertTrue(!threw, "createAutoWorktree does not throw when no source DB"); + + const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db"); + assertTrue( + !existsSync(worktreeDbPath), + "gsd.db is absent in worktree when source had none", + ); + + process.chdir(savedCwd); + } + + // ─── Test 3: reconcile inserts worktree rows into main ─────────── + console.log("\n=== Test 3: reconcile merges worktree rows into main ==="); + { + const mainDbPath = join(makeTempDir(), "main.db"); + const worktreeDbPath = join(makeTempDir(), "wt.db"); + + // Seed main DB (empty schema) + openDatabase(mainDbPath); + closeDatabase(); + + // Seed worktree DB with one decision + openDatabase(worktreeDbPath); + upsertDecision({ + id: "D-WT-001", + when_context: "integration test", + scope: "test", + decision: "use reconcile", + choice: "reconcile on merge", + rationale: "test coverage", + revisable: "no", + superseded_by: null, + }); + closeDatabase(); + + // Reconcile worktree → main + const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath); + assertTrue(result.decisions >= 1, "reconcile reports at least 1 decision merged"); + + // Open main DB and verify the row is present + openDatabase(mainDbPath); + const decisions = getActiveDecisions(); + closeDatabase(); + + const found = decisions.some((d) => d.id === "D-WT-001"); + assertTrue(found, "worktree decision D-WT-001 present in main DB after reconcile"); + } + + // ─── Test 4: reconcile non-fatal when both paths nonexistent ───── + console.log("\n=== Test 4: reconcile non-fatal on nonexistent paths ==="); + { + let threw = false; + try { + reconcileWorktreeDb("/nonexistent/path/gsd.db", "/also/nonexistent/gsd.db"); + } catch { + threw = true; + } + assertTrue(!threw, "reconcileWorktreeDb does not throw when worktree DB is absent"); + } + + // ─── Test 5: failure path observable via stderr (diagnostic) ───── + // reconcileWorktreeDb emits to stderr on reconciliation failures. + // We can't easily intercept stderr in this test harness, but we verify + // that the function returns the zero-result shape (not undefined/throws) + // when the worktree DB is missing — confirming the failure path is non-fatal + // and returns a structured result. + console.log("\n=== Test 5: reconcile returns zero-shape when worktree DB absent ==="); + { + const mainDbPath = join(makeTempDir(), "main2.db"); + openDatabase(mainDbPath); + closeDatabase(); + + const result = reconcileWorktreeDb(mainDbPath, "/definitely/does/not/exist.db"); + assertEq(result.decisions, 0, "decisions is 0 when worktree DB absent"); + assertEq(result.requirements, 0, "requirements is 0 when worktree DB absent"); + assertEq(result.artifacts, 0, "artifacts is 0 when worktree DB absent"); + assertEq(result.conflicts.length, 0, "conflicts is empty when worktree DB absent"); + } + + } finally { + // Always restore cwd + process.chdir(savedCwd); + // Ensure DB is closed + if (isDbAvailable()) closeDatabase(); + // Remove all temp dirs + for (const dir of tempDirs) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + } + } + + report(); +} + +main(); diff --git a/src/resources/extensions/gsd/tests/worktree-db.test.ts b/src/resources/extensions/gsd/tests/worktree-db.test.ts new file mode 100644 index 000000000..131f47a84 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-db.test.ts @@ -0,0 +1,442 @@ +import { createTestContext } from './test-helpers.ts'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + openDatabase, + closeDatabase, + isDbAvailable, + insertDecision, + insertRequirement, + insertArtifact, + getDecisionById, + getRequirementById, + _getAdapter, + copyWorktreeDb, + reconcileWorktreeDb, +} from '../gsd-db.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-test-')); +} + +function cleanup(...dirs: string[]): void { + closeDatabase(); + for (const dir of dirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + } +} + +function seedMainDb(dbPath: string): void { + openDatabase(dbPath); + insertDecision({ + id: 'D001', + when_context: '2025-01-01', + scope: 'M001/S01', + decision: 'Use SQLite', + choice: 'node:sqlite', + rationale: 'Built-in', + revisable: 'yes', + superseded_by: null, + }); + insertRequirement({ + id: 'R001', + class: 'functional', + status: 'active', + description: 'Must store decisions', + why: 'Core feature', + source: 'design', + primary_owner: 'S01', + supporting_slices: '', + validation: 'test', + notes: '', + full_content: 'Full requirement text', + superseded_by: null, + }); + insertArtifact({ + path: 'docs/arch.md', + artifact_type: 'plan', + milestone_id: 'M001', + slice_id: null, + task_id: null, + full_content: 'Architecture document', + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// copyWorktreeDb tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== worktree-db: copyWorktreeDb ==='); + +// Test: copies DB file and data is queryable +{ + const srcDir = tempDir(); + const destDir = tempDir(); + const srcDb = path.join(srcDir, 'gsd.db'); + const destDb = path.join(destDir, 'nested', 'gsd.db'); + + seedMainDb(srcDb); + closeDatabase(); + + const result = copyWorktreeDb(srcDb, destDb); + assertTrue(result === true, 'copyWorktreeDb returns true on success'); + assertTrue(fs.existsSync(destDb), 'dest DB file exists after copy'); + + // Open the copy and verify data is queryable + openDatabase(destDb); + const d = getDecisionById('D001'); + assertTrue(d !== null, 'decision queryable in copied DB'); + assertEq(d?.choice, 'node:sqlite', 'decision data preserved in copy'); + + const r = getRequirementById('R001'); + assertTrue(r !== null, 'requirement queryable in copied DB'); + assertEq(r?.description, 'Must store decisions', 'requirement data preserved in copy'); + + cleanup(srcDir, destDir); +} + +// Test: skips -wal and -shm files +{ + const srcDir = tempDir(); + const destDir = tempDir(); + const srcDb = path.join(srcDir, 'gsd.db'); + const destDb = path.join(destDir, 'gsd.db'); + + seedMainDb(srcDb); + closeDatabase(); + + // Create fake WAL/SHM files + fs.writeFileSync(srcDb + '-wal', 'fake wal data'); + fs.writeFileSync(srcDb + '-shm', 'fake shm data'); + + copyWorktreeDb(srcDb, destDb); + + assertTrue(fs.existsSync(destDb), 'DB file copied'); + assertTrue(!fs.existsSync(destDb + '-wal'), 'WAL file NOT copied'); + assertTrue(!fs.existsSync(destDb + '-shm'), 'SHM file NOT copied'); + + cleanup(srcDir, destDir); +} + +// Test: returns false when source doesn't exist (no throw) +{ + const destDir = tempDir(); + const result = copyWorktreeDb('/nonexistent/path/gsd.db', path.join(destDir, 'gsd.db')); + assertEq(result, false, 'returns false for missing source'); + cleanup(destDir); +} + +// Test: creates dest directory if needed +{ + const srcDir = tempDir(); + const destDir = tempDir(); + const srcDb = path.join(srcDir, 'gsd.db'); + const deepDest = path.join(destDir, 'a', 'b', 'c', 'gsd.db'); + + seedMainDb(srcDb); + closeDatabase(); + + const result = copyWorktreeDb(srcDb, deepDest); + assertTrue(result === true, 'copyWorktreeDb succeeds with nested dest'); + assertTrue(fs.existsSync(deepDest), 'DB file created at deeply nested path'); + + cleanup(srcDir, destDir); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// reconcileWorktreeDb tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== worktree-db: reconcileWorktreeDb ==='); + +// Test: merges new decisions from worktree into main +{ + const mainDir = tempDir(); + const wtDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + // Seed main with D001 + seedMainDb(mainDb); + closeDatabase(); + + // Copy to worktree, add D002 in worktree + copyWorktreeDb(mainDb, wtDb); + openDatabase(wtDb); + insertDecision({ + id: 'D002', + when_context: '2025-02-01', + scope: 'M001/S02', + decision: 'Use WAL mode', + choice: 'WAL', + rationale: 'Performance', + revisable: 'yes', + superseded_by: null, + }); + closeDatabase(); + + // Re-open main and reconcile + openDatabase(mainDb); + const result = reconcileWorktreeDb(mainDb, wtDb); + + assertTrue(result.decisions > 0, 'decisions merged count > 0'); + const d2 = getDecisionById('D002'); + assertTrue(d2 !== null, 'D002 from worktree now in main'); + assertEq(d2?.choice, 'WAL', 'D002 data correct after merge'); + + cleanup(mainDir, wtDir); +} + +// Test: merges new requirements from worktree into main +{ + const mainDir = tempDir(); + const wtDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + seedMainDb(mainDb); + closeDatabase(); + copyWorktreeDb(mainDb, wtDb); + + openDatabase(wtDb); + insertRequirement({ + id: 'R002', + class: 'non-functional', + status: 'active', + description: 'Must be fast', + why: 'UX', + source: 'design', + primary_owner: 'S02', + supporting_slices: '', + validation: 'benchmark', + notes: '', + full_content: 'Performance requirement', + superseded_by: null, + }); + closeDatabase(); + + openDatabase(mainDb); + const result = reconcileWorktreeDb(mainDb, wtDb); + + assertTrue(result.requirements > 0, 'requirements merged count > 0'); + const r2 = getRequirementById('R002'); + assertTrue(r2 !== null, 'R002 from worktree now in main'); + assertEq(r2?.description, 'Must be fast', 'R002 data correct after merge'); + + cleanup(mainDir, wtDir); +} + +// Test: merges new artifacts from worktree into main +{ + const mainDir = tempDir(); + const wtDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + seedMainDb(mainDb); + closeDatabase(); + copyWorktreeDb(mainDb, wtDb); + + openDatabase(wtDb); + insertArtifact({ + path: 'docs/api.md', + artifact_type: 'reference', + milestone_id: 'M001', + slice_id: 'S01', + task_id: 'T01', + full_content: 'API documentation', + }); + closeDatabase(); + + openDatabase(mainDb); + const result = reconcileWorktreeDb(mainDb, wtDb); + + assertTrue(result.artifacts > 0, 'artifacts merged count > 0'); + const adapter = _getAdapter()!; + const row = adapter.prepare('SELECT * FROM artifacts WHERE path = ?').get('docs/api.md'); + assertTrue(row !== null, 'artifact from worktree now in main'); + assertEq(row?.['artifact_type'], 'reference', 'artifact data correct after merge'); + + cleanup(mainDir, wtDir); +} + +// Test: detects conflicts (same PK, different content in both DBs) +{ + const mainDir = tempDir(); + const wtDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + // Seed main with D001 + seedMainDb(mainDb); + closeDatabase(); + copyWorktreeDb(mainDb, wtDb); + + // Modify D001 in main + openDatabase(mainDb); + const mainAdapter = _getAdapter()!; + mainAdapter.prepare( + `UPDATE decisions SET choice = 'better-sqlite3' WHERE id = 'D001'`, + ).run(); + closeDatabase(); + + // Modify D001 in worktree differently + openDatabase(wtDb); + const wtAdapter = _getAdapter()!; + wtAdapter.prepare( + `UPDATE decisions SET choice = 'sql.js' WHERE id = 'D001'`, + ).run(); + closeDatabase(); + + // Reconcile + openDatabase(mainDb); + const result = reconcileWorktreeDb(mainDb, wtDb); + + assertTrue(result.conflicts.length > 0, 'conflicts detected'); + assertTrue( + result.conflicts.some(c => c.includes('D001')), + 'conflict mentions D001', + ); + + // Worktree-wins: D001 should now have worktree's value + const d1 = getDecisionById('D001'); + assertEq(d1?.choice, 'sql.js', 'worktree wins on conflict (INSERT OR REPLACE)'); + + cleanup(mainDir, wtDir); +} + +// Test: handles missing worktree DB gracefully +{ + const mainDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + + seedMainDb(mainDb); + + const result = reconcileWorktreeDb(mainDb, '/nonexistent/worktree.db'); + assertEq(result.decisions, 0, 'no decisions merged for missing worktree DB'); + assertEq(result.requirements, 0, 'no requirements merged for missing worktree DB'); + assertEq(result.artifacts, 0, 'no artifacts merged for missing worktree DB'); + assertEq(result.conflicts.length, 0, 'no conflicts for missing worktree DB'); + + cleanup(mainDir); +} + +// Test: path with spaces works +{ + const baseDir = tempDir(); + const mainDir = path.join(baseDir, 'main dir'); + const wtDir = path.join(baseDir, 'worktree dir'); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(wtDir, { recursive: true }); + + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + seedMainDb(mainDb); + closeDatabase(); + copyWorktreeDb(mainDb, wtDb); + + // Add a decision in worktree + openDatabase(wtDb); + insertDecision({ + id: 'D003', + when_context: '2025-03-01', + scope: 'M001/S03', + decision: 'Path spaces test', + choice: 'yes', + rationale: 'Robustness', + revisable: 'no', + superseded_by: null, + }); + closeDatabase(); + + openDatabase(mainDb); + const result = reconcileWorktreeDb(mainDb, wtDb); + assertTrue(result.decisions > 0, 'reconciliation works with spaces in path'); + const d3 = getDecisionById('D003'); + assertTrue(d3 !== null, 'D003 merged from worktree with spaces in path'); + + cleanup(baseDir); +} + +// Test: main DB is usable after reconciliation (DETACH cleanup verified) +{ + const mainDir = tempDir(); + const wtDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + seedMainDb(mainDb); + closeDatabase(); + copyWorktreeDb(mainDb, wtDb); + + openDatabase(mainDb); + reconcileWorktreeDb(mainDb, wtDb); + + // Verify main DB is still fully usable after DETACH + assertTrue(isDbAvailable(), 'DB still available after reconciliation'); + + insertDecision({ + id: 'D099', + when_context: '2025-12-01', + scope: 'test', + decision: 'Post-reconcile insert', + choice: 'works', + rationale: 'Verify DETACH cleanup', + revisable: 'no', + superseded_by: null, + }); + + const d99 = getDecisionById('D099'); + assertTrue(d99 !== null, 'can insert and query after reconciliation'); + assertEq(d99?.choice, 'works', 'post-reconcile data correct'); + + // Verify no "wt" database still attached + const adapter = _getAdapter()!; + let wtAccessible = false; + try { + adapter.prepare('SELECT count(*) FROM wt.decisions').get(); + wtAccessible = true; + } catch { + // Expected — wt should be detached + } + assertTrue(!wtAccessible, 'wt database is detached after reconciliation'); + + cleanup(mainDir, wtDir); +} + +// Test: reconcile with empty worktree DB (no new rows, no conflicts) +{ + const mainDir = tempDir(); + const wtDir = tempDir(); + const mainDb = path.join(mainDir, 'gsd.db'); + const wtDb = path.join(wtDir, 'gsd.db'); + + seedMainDb(mainDb); + closeDatabase(); + copyWorktreeDb(mainDb, wtDb); + + // Don't modify the worktree DB at all — reconcile the identical copy + openDatabase(mainDb); + const result = reconcileWorktreeDb(mainDb, wtDb); + + // Should still report counts for the existing rows (INSERT OR REPLACE touches them) + assertTrue(result.conflicts.length === 0, 'no conflicts when DBs are identical'); + assertTrue(isDbAvailable(), 'DB usable after no-change reconciliation'); + + cleanup(mainDir, wtDir); +} + +// ─── Final Report ────────────────────────────────────────────────────────── +report(); diff --git a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts index 6014682aa..865813e07 100644 --- a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts @@ -38,9 +38,6 @@ function createTempRepo(): string { run("git config user.email test@test.com", dir); run("git config user.name Test", dir); writeFileSync(join(dir, "README.md"), "# test\n"); - // Mirror production: .gsd/worktrees/ is gitignored so autoCommitDirtyState - // doesn't pick up the worktrees directory as dirty state (#1127 fix). - writeFileSync(join(dir, ".gitignore"), ".gsd/worktrees/\n"); mkdirSync(join(dir, ".gsd"), { recursive: true }); writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n"); run("git add .", dir); diff --git a/src/resources/extensions/gsd/tests/worktree-resolver.test.ts b/src/resources/extensions/gsd/tests/worktree-resolver.test.ts new file mode 100644 index 000000000..23abed9a3 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-resolver.test.ts @@ -0,0 +1,705 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + WorktreeResolver, + type WorktreeResolverDeps, + type NotifyCtx, +} from "../worktree-resolver.js"; +import { AutoSession } from "../auto/session.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Track calls to mock deps for assertion. */ +interface CallLog { + fn: string; + args: unknown[]; +} + +function makeSession( + overrides?: Partial<{ basePath: string; originalBasePath: string }>, +): AutoSession { + const s = new AutoSession(); + s.basePath = overrides?.basePath ?? "/project"; + s.originalBasePath = overrides?.originalBasePath ?? "/project"; + return s; +} + +function makeDeps( + overrides?: Partial, +): WorktreeResolverDeps & { calls: CallLog[] } { + const calls: CallLog[] = []; + + const deps: WorktreeResolverDeps & { calls: CallLog[] } = { + calls, + isInAutoWorktree: (basePath: string) => { + calls.push({ fn: "isInAutoWorktree", args: [basePath] }); + return false; + }, + shouldUseWorktreeIsolation: () => { + calls.push({ fn: "shouldUseWorktreeIsolation", args: [] }); + return true; + }, + getIsolationMode: () => { + calls.push({ fn: "getIsolationMode", args: [] }); + return "worktree"; + }, + mergeMilestoneToMain: ( + basePath: string, + milestoneId: string, + roadmapContent: string, + ) => { + calls.push({ + fn: "mergeMilestoneToMain", + args: [basePath, milestoneId, roadmapContent], + }); + return { pushed: false }; + }, + syncWorktreeStateBack: ( + mainBasePath: string, + worktreePath: string, + milestoneId: string, + ) => { + calls.push({ + fn: "syncWorktreeStateBack", + args: [mainBasePath, worktreePath, milestoneId], + }); + return { synced: [] }; + }, + teardownAutoWorktree: ( + basePath: string, + milestoneId: string, + opts?: { preserveBranch?: boolean }, + ) => { + calls.push({ + fn: "teardownAutoWorktree", + args: [basePath, milestoneId, opts], + }); + }, + createAutoWorktree: (basePath: string, milestoneId: string) => { + calls.push({ fn: "createAutoWorktree", args: [basePath, milestoneId] }); + return `/project/.gsd/worktrees/${milestoneId}`; + }, + enterAutoWorktree: (basePath: string, milestoneId: string) => { + calls.push({ fn: "enterAutoWorktree", args: [basePath, milestoneId] }); + return `/project/.gsd/worktrees/${milestoneId}`; + }, + getAutoWorktreePath: (basePath: string, milestoneId: string) => { + calls.push({ fn: "getAutoWorktreePath", args: [basePath, milestoneId] }); + return null; + }, + autoCommitCurrentBranch: ( + basePath: string, + reason: string, + milestoneId: string, + ) => { + calls.push({ + fn: "autoCommitCurrentBranch", + args: [basePath, reason, milestoneId], + }); + }, + getCurrentBranch: (basePath: string) => { + calls.push({ fn: "getCurrentBranch", args: [basePath] }); + return "main"; + }, + autoWorktreeBranch: (milestoneId: string) => { + calls.push({ fn: "autoWorktreeBranch", args: [milestoneId] }); + return `milestone/${milestoneId}`; + }, + resolveMilestoneFile: ( + basePath: string, + milestoneId: string, + fileType: string, + ) => { + calls.push({ + fn: "resolveMilestoneFile", + args: [basePath, milestoneId, fileType], + }); + return `/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`; + }, + readFileSync: (path: string, _encoding: string) => { + calls.push({ fn: "readFileSync", args: [path] }); + return "# Roadmap\n- [x] S01: Slice one\n"; + }, + GitServiceImpl: class MockGitServiceImpl { + basePath: string; + gitConfig: unknown; + constructor(basePath: string, gitConfig: unknown) { + calls.push({ fn: "GitServiceImpl", args: [basePath, gitConfig] }); + this.basePath = basePath; + this.gitConfig = gitConfig; + } + } as unknown as WorktreeResolverDeps["GitServiceImpl"], + loadEffectiveGSDPreferences: () => { + calls.push({ fn: "loadEffectiveGSDPreferences", args: [] }); + return { preferences: { git: {} } }; + }, + invalidateAllCaches: () => { + calls.push({ fn: "invalidateAllCaches", args: [] }); + }, + captureIntegrationBranch: ( + basePath: string, + mid: string | undefined, + opts?: { commitDocs?: boolean }, + ) => { + calls.push({ + fn: "captureIntegrationBranch", + args: [basePath, mid, opts], + }); + }, + ...overrides, + }; + + // Re-apply overrides that add the call tracking + if (overrides) { + for (const [key, val] of Object.entries(overrides)) { + if (key !== "calls") { + (deps as unknown as Record)[key] = val; + } + } + } + + return deps; +} + +function makeNotifyCtx(): NotifyCtx & { + messages: Array<{ msg: string; level?: string }>; +} { + const messages: Array<{ msg: string; level?: string }> = []; + return { + messages, + notify: (msg: string, level?: "info" | "warning" | "error" | "success") => { + messages.push({ msg, level }); + }, + }; +} + +function findCalls(calls: CallLog[], fn: string): CallLog[] { + return calls.filter((c) => c.fn === fn); +} + +// ─── Getter Tests ──────────────────────────────────────────────────────────── + +test("workPath returns s.basePath", () => { + const s = makeSession({ basePath: "/project/.gsd/worktrees/M001" }); + const resolver = new WorktreeResolver(s, makeDeps()); + assert.equal(resolver.workPath, "/project/.gsd/worktrees/M001"); +}); + +test("projectRoot returns originalBasePath when set", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const resolver = new WorktreeResolver(s, makeDeps()); + assert.equal(resolver.projectRoot, "/project"); +}); + +test("projectRoot falls back to basePath when originalBasePath is empty", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "" }); + const resolver = new WorktreeResolver(s, makeDeps()); + assert.equal(resolver.projectRoot, "/project"); +}); + +test("lockPath returns originalBasePath when set (same as lockBase)", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const resolver = new WorktreeResolver(s, makeDeps()); + assert.equal(resolver.lockPath, "/project"); +}); + +test("lockPath falls back to basePath when originalBasePath is empty", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "" }); + const resolver = new WorktreeResolver(s, makeDeps()); + assert.equal(resolver.lockPath, "/project"); +}); + +// ─── enterMilestone Tests ──────────────────────────────────────────────────── + +test("enterMilestone creates new worktree when none exists", () => { + const s = makeSession(); + const deps = makeDeps({ + getAutoWorktreePath: () => null, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", ctx); + + assert.equal(s.basePath, "/project/.gsd/worktrees/M001"); + assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 1); + assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0); + assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); + assert.ok( + ctx.messages.some( + (m) => m.level === "info" && m.msg.includes("Entered worktree"), + ), + ); +}); + +test("enterMilestone enters existing worktree instead of creating", () => { + const s = makeSession(); + const deps = makeDeps({ + getAutoWorktreePath: () => "/project/.gsd/worktrees/M001", + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", ctx); + + assert.equal(s.basePath, "/project/.gsd/worktrees/M001"); + assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 1); + assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0); +}); + +test("enterMilestone is no-op when shouldUseWorktreeIsolation is false", () => { + const s = makeSession(); + const deps = makeDeps({ + shouldUseWorktreeIsolation: () => false, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", ctx); + + assert.equal(s.basePath, "/project"); // unchanged + assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0); + assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0); +}); + +test("enterMilestone does NOT update basePath on creation failure", () => { + const s = makeSession(); + const deps = makeDeps({ + getAutoWorktreePath: () => null, + createAutoWorktree: () => { + throw new Error("disk full"); + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", ctx); + + assert.equal(s.basePath, "/project"); // unchanged — error recovery + assert.ok( + ctx.messages.some( + (m) => m.level === "warning" && m.msg.includes("disk full"), + ), + ); +}); + +test("enterMilestone uses originalBasePath as base for worktree ops", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + let createdFrom = ""; + const deps = makeDeps({ + getAutoWorktreePath: () => null, + createAutoWorktree: (basePath: string, _mid: string) => { + createdFrom = basePath; + return "/project/.gsd/worktrees/M002"; + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M002", ctx); + + assert.equal(createdFrom, "/project"); // uses originalBasePath, not current basePath +}); + +// ─── exitMilestone Tests ───────────────────────────────────────────────────── + +test("exitMilestone commits, tears down, and resets basePath", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.exitMilestone("M001", ctx); + + assert.equal(s.basePath, "/project"); // reset to originalBasePath + assert.equal(findCalls(deps.calls, "autoCommitCurrentBranch").length, 1); + assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1); + assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt + assert.equal(findCalls(deps.calls, "invalidateAllCaches").length, 1); +}); + +test("exitMilestone is no-op when not in worktree", () => { + const s = makeSession(); + const deps = makeDeps({ + isInAutoWorktree: () => false, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.exitMilestone("M001", ctx); + + assert.equal(s.basePath, "/project"); // unchanged + assert.equal(findCalls(deps.calls, "autoCommitCurrentBranch").length, 0); + assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0); +}); + +test("exitMilestone passes preserveBranch option", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + let preserveOpts: unknown = null; + const deps = makeDeps({ + isInAutoWorktree: () => true, + teardownAutoWorktree: ( + _basePath: string, + _mid: string, + opts?: { preserveBranch?: boolean }, + ) => { + preserveOpts = opts; + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.exitMilestone("M001", ctx, { preserveBranch: true }); + + assert.deepEqual(preserveOpts, { preserveBranch: true }); +}); + +test("exitMilestone still resets basePath even if auto-commit fails", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + autoCommitCurrentBranch: () => { + throw new Error("commit error"); + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.exitMilestone("M001", ctx); + + // Should still complete: reset basePath, rebuild git service + assert.equal(s.basePath, "/project"); + assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); +}); + +// ─── mergeAndExit Tests (worktree mode) ────────────────────────────────────── + +test("mergeAndExit in worktree mode reads roadmap and merges", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "syncWorktreeStateBack").length, 1); + assert.equal(findCalls(deps.calls, "resolveMilestoneFile").length, 1); + assert.equal(findCalls(deps.calls, "readFileSync").length, 1); + assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1); + assert.equal(s.basePath, "/project"); // restored + assert.ok(ctx.messages.some((m) => m.msg.includes("merged to main"))); +}); + +test("mergeAndExit in worktree mode shows pushed status", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + mergeMilestoneToMain: () => ({ pushed: true }), + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.ok(ctx.messages.some((m) => m.msg.includes("Pushed to remote"))); +}); + +test("mergeAndExit falls back to teardown when roadmap is missing", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + resolveMilestoneFile: () => null, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1); + assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0); + assert.equal(s.basePath, "/project"); // restored + assert.ok(ctx.messages.some((m) => m.msg.includes("no roadmap for merge"))); +}); + +test("mergeAndExit in worktree mode restores to project root on merge failure", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + mergeMilestoneToMain: () => { + throw new Error("conflict in main"); + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(s.basePath, "/project"); // error recovery — restored + assert.ok( + ctx.messages.some( + (m) => m.level === "warning" && m.msg.includes("conflict in main"), + ), + ); + assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt after recovery +}); + +// ─── mergeAndExit Tests (branch mode) ──────────────────────────────────────── + +test("mergeAndExit in branch mode merges when on milestone branch", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "/project" }); + const deps = makeDeps({ + isInAutoWorktree: () => false, + getIsolationMode: () => "branch", + getCurrentBranch: () => "milestone/M001", + autoWorktreeBranch: () => "milestone/M001", + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1); + assert.ok(ctx.messages.some((m) => m.msg.includes("branch mode"))); +}); + +test("mergeAndExit in branch mode skips when not on milestone branch", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "/project" }); + const deps = makeDeps({ + isInAutoWorktree: () => false, + getIsolationMode: () => "branch", + getCurrentBranch: () => "main", + autoWorktreeBranch: () => "milestone/M001", + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0); + assert.equal(ctx.messages.length, 0); +}); + +test("mergeAndExit in branch mode handles merge failure gracefully", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "/project" }); + const deps = makeDeps({ + isInAutoWorktree: () => false, + getIsolationMode: () => "branch", + getCurrentBranch: () => "milestone/M001", + autoWorktreeBranch: () => "milestone/M001", + mergeMilestoneToMain: () => { + throw new Error("branch merge conflict"); + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.ok( + ctx.messages.some( + (m) => m.level === "warning" && m.msg.includes("branch merge conflict"), + ), + ); +}); + +test("mergeAndExit in branch mode skips when no roadmap", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "/project" }); + const deps = makeDeps({ + isInAutoWorktree: () => false, + getIsolationMode: () => "branch", + getCurrentBranch: () => "milestone/M001", + autoWorktreeBranch: () => "milestone/M001", + resolveMilestoneFile: () => null, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0); +}); + +test("mergeAndExit in branch mode rebuilds GitService after merge", () => { + const s = makeSession({ basePath: "/project", originalBasePath: "/project" }); + const deps = makeDeps({ + isInAutoWorktree: () => false, + getIsolationMode: () => "branch", + getCurrentBranch: () => "milestone/M001", + autoWorktreeBranch: () => "milestone/M001", + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); +}); + +// ─── mergeAndExit Tests (none mode) ────────────────────────────────────────── + +test("mergeAndExit in none mode is a no-op", () => { + const s = makeSession(); + const deps = makeDeps({ + getIsolationMode: () => "none", + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", ctx); + + assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0); + assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0); + assert.equal(ctx.messages.length, 0); +}); + +// ─── mergeAndEnterNext Tests ───────────────────────────────────────────────── + +test("mergeAndEnterNext calls mergeAndExit then enterMilestone", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const callOrder: string[] = []; + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + shouldUseWorktreeIsolation: () => true, + mergeMilestoneToMain: ( + basePath: string, + milestoneId: string, + _roadmap: string, + ) => { + callOrder.push(`merge:${milestoneId}`); + return { pushed: false }; + }, + getAutoWorktreePath: () => null, + createAutoWorktree: (basePath: string, milestoneId: string) => { + callOrder.push(`create:${milestoneId}`); + return `/project/.gsd/worktrees/${milestoneId}`; + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndEnterNext("M001", "M002", ctx); + + assert.deepEqual(callOrder, ["merge:M001", "create:M002"]); + assert.equal(s.basePath, "/project/.gsd/worktrees/M002"); +}); + +test("mergeAndEnterNext enters next milestone even if merge fails", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + const deps = makeDeps({ + isInAutoWorktree: (basePath: string) => basePath.includes("worktrees"), + getIsolationMode: () => "worktree", + shouldUseWorktreeIsolation: () => true, + mergeMilestoneToMain: () => { + throw new Error("merge failed"); + }, + getAutoWorktreePath: () => null, + createAutoWorktree: (_basePath: string, milestoneId: string) => { + return `/project/.gsd/worktrees/${milestoneId}`; + }, + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndEnterNext("M001", "M002", ctx); + + // Merge failed but enter should still happen + assert.equal(s.basePath, "/project/.gsd/worktrees/M002"); + assert.ok( + ctx.messages.some( + (m) => m.level === "warning" && m.msg.includes("merge failed"), + ), + ); + assert.ok( + ctx.messages.some( + (m) => m.level === "info" && m.msg.includes("Entered worktree"), + ), + ); +}); + +// ─── GitService Rebuild Atomicity ──────────────────────────────────────────── + +test("GitService is rebuilt with the NEW basePath after enterMilestone", () => { + const s = makeSession(); + let gitServiceBasePath = ""; + const deps = makeDeps({ + getAutoWorktreePath: () => null, + GitServiceImpl: class { + constructor(basePath: string, _config: unknown) { + gitServiceBasePath = basePath; + } + } as unknown as WorktreeResolverDeps["GitServiceImpl"], + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", ctx); + + assert.equal(gitServiceBasePath, "/project/.gsd/worktrees/M001"); // new path, not old +}); + +test("GitService is rebuilt with originalBasePath after exitMilestone", () => { + const s = makeSession({ + basePath: "/project/.gsd/worktrees/M001", + originalBasePath: "/project", + }); + let gitServiceBasePath = ""; + const deps = makeDeps({ + isInAutoWorktree: () => true, + GitServiceImpl: class { + constructor(basePath: string, _config: unknown) { + gitServiceBasePath = basePath; + } + } as unknown as WorktreeResolverDeps["GitServiceImpl"], + }); + const ctx = makeNotifyCtx(); + const resolver = new WorktreeResolver(s, deps); + + resolver.exitMilestone("M001", ctx); + + assert.equal(gitServiceBasePath, "/project"); // project root, not worktree +}); diff --git a/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts b/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts index 48c9f8fde..bb2f4288f 100644 --- a/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts @@ -1,26 +1,27 @@ /** * worktree-sync-milestones.test.ts — Regression test for #1311. * - * Verifies that syncGsdStateToWorktree copies missing milestones, - * milestone files, and slice directories from the main repo's .gsd/ - * into the worktree's .gsd/. + * Verifies that syncProjectRootToWorktree copies milestone artifacts + * from the main repo's .gsd/ into the worktree's .gsd/ for the + * specified milestone, and deletes gsd.db so it rebuilds from fresh state. * * Covers: - * - Entirely missing milestone directory - * - Milestone exists but missing CONTEXT/ROADMAP files - * - Missing slices within an existing milestone - * - No-op when directories are identical (symlinked) - * - Root-level files (DECISIONS, REQUIREMENTS, etc.) + * - Milestone directory synced from main to worktree + * - Missing slices within a milestone are synced + * - gsd.db deleted in worktree after sync + * - No-op when paths are equal + * - No-op when milestoneId is null + * - Non-existent directories handled gracefully */ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, symlinkSync, realpathSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { syncGsdStateToWorktree } from '../auto-worktree.ts'; +import { syncProjectRootToWorktree } from '../auto-worktree-sync.ts'; import { createTestContext } from './test-helpers.ts'; -const { assertEq, assertTrue, report } = createTestContext(); +const { assertTrue, report } = createTestContext(); function createBase(name: string): string { const base = mkdtempSync(join(tmpdir(), `gsd-wt-sync-${name}-`)); @@ -34,156 +35,106 @@ function cleanup(base: string): void { async function main(): Promise { - // ─── 1. Missing milestone directory is synced ───────────────────────── - console.log('\n=== 1. missing milestone directory is copied from main ==='); + // ─── 1. Milestone directory synced from main to worktree ────────────── + console.log('\n=== 1. milestone directory synced from main to worktree ==='); { const mainBase = createBase('main'); const wtBase = createBase('wt'); try { - // Main repo has M001 and M002 const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001'); mkdirSync(m001Dir, { recursive: true }); - writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001\nDone.'); + writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001\nContext.'); writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap'); - const m002Dir = join(mainBase, '.gsd', 'milestones', 'M002'); - mkdirSync(m002Dir, { recursive: true }); - writeFileSync(join(m002Dir, 'M002-CONTEXT.md'), '# M002\nNew milestone.'); - writeFileSync(join(m002Dir, 'M002-ROADMAP.md'), '# Roadmap'); + // Worktree has no M001 + assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), 'M001 missing before sync'); - // Worktree only has M001 - const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001'); - mkdirSync(wtM001Dir, { recursive: true }); - writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001\nDone.'); + syncProjectRootToWorktree(mainBase, wtBase, 'M001'); - // M002 is missing from worktree - assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), 'M002 missing before sync'); - - const result = syncGsdStateToWorktree(mainBase, wtBase); - - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), '#1311: M002 synced to worktree'); - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-CONTEXT.md')), 'M002 CONTEXT synced'); - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-ROADMAP.md')), 'M002 ROADMAP synced'); - assertTrue(result.synced.length > 0, 'sync reported files'); + assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), '#1311: M001 synced to worktree'); + assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md')), 'M001 CONTEXT synced'); + assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'M001 ROADMAP synced'); } finally { cleanup(mainBase); cleanup(wtBase); } } - // ─── 2. Missing files within existing milestone ─────────────────────── - console.log('\n=== 2. missing files within existing milestone are synced ==='); + // ─── 2. Missing slices synced ────────────────────────────────────────── + console.log('\n=== 2. missing slices within milestone are synced ==='); { const mainBase = createBase('main'); const wtBase = createBase('wt'); try { - // Main repo M001 has CONTEXT, ROADMAP, RESEARCH - const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001'); - mkdirSync(m001Dir, { recursive: true }); - writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001 Context'); - writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# M001 Roadmap'); - writeFileSync(join(m001Dir, 'M001-RESEARCH.md'), '# M001 Research'); - - // Worktree M001 only has CONTEXT (stale snapshot) - const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001'); - mkdirSync(wtM001Dir, { recursive: true }); - writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001 Context'); - - const result = syncGsdStateToWorktree(mainBase, wtBase); - - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'ROADMAP synced'); - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-RESEARCH.md')), 'RESEARCH synced'); - // Existing file should NOT be overwritten - assertEq( - readFileSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'), 'utf-8'), - '# M001 Context', - 'existing CONTEXT not overwritten', - ); - } finally { - cleanup(mainBase); - cleanup(wtBase); - } - } - - // ─── 3. Missing slices directory synced ─────────────────────────────── - console.log('\n=== 3. missing slices directory synced ==='); - { - const mainBase = createBase('main'); - const wtBase = createBase('wt'); - - try { - // Main repo has M001 with slices S01–S03 const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001'); mkdirSync(join(m001Dir, 'slices', 'S01'), { recursive: true }); mkdirSync(join(m001Dir, 'slices', 'S02'), { recursive: true }); - mkdirSync(join(m001Dir, 'slices', 'S03'), { recursive: true }); writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap'); writeFileSync(join(m001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan'); writeFileSync(join(m001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan'); - writeFileSync(join(m001Dir, 'slices', 'S03', 'S03-PLAN.md'), '# S03 Plan'); - // Worktree M001 has slices S01–S02 only (S03 missing) + // Worktree only has S01 const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001'); mkdirSync(join(wtM001Dir, 'slices', 'S01'), { recursive: true }); - mkdirSync(join(wtM001Dir, 'slices', 'S02'), { recursive: true }); - writeFileSync(join(wtM001Dir, 'M001-ROADMAP.md'), '# Roadmap'); writeFileSync(join(wtM001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan'); - writeFileSync(join(wtM001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan'); - assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), 'S03 missing before sync'); + syncProjectRootToWorktree(mainBase, wtBase, 'M001'); - syncGsdStateToWorktree(mainBase, wtBase); - - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), '#1311: S03 synced'); - assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03', 'S03-PLAN.md')), 'S03 PLAN synced'); + assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02')), '#1311: S02 synced'); + assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md')), 'S02 PLAN synced'); } finally { cleanup(mainBase); cleanup(wtBase); } } - // ─── 4. No-op when both resolve to same directory (symlink) ─────────── - console.log('\n=== 4. no-op when .gsd/ resolves to same path (symlinked) ==='); - { - const sharedDir = createBase('shared'); - const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-main-')); - const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-wt-')); - - try { - // Both main and worktree symlink to the same shared directory - writeFileSync(join(sharedDir, '.gsd', 'milestones', 'keep'), ''); - symlinkSync(join(sharedDir, '.gsd'), join(mainBase, '.gsd')); - symlinkSync(join(sharedDir, '.gsd'), join(wtBase, '.gsd')); - - const result = syncGsdStateToWorktree(mainBase, wtBase); - assertEq(result.synced.length, 0, 'no files synced when both point to same dir'); - } finally { - cleanup(sharedDir); - rmSync(mainBase, { recursive: true, force: true }); - rmSync(wtBase, { recursive: true, force: true }); - } - } - - // ─── 5. Root-level .gsd/ files synced ───────────────────────────────── - console.log('\n=== 5. root-level .gsd/ files synced ==='); + // ─── 3. gsd.db deleted in worktree after sync ───────────────────────── + console.log('\n=== 3. gsd.db deleted in worktree after sync ==='); { const mainBase = createBase('main'); const wtBase = createBase('wt'); try { - writeFileSync(join(mainBase, '.gsd', 'DECISIONS.md'), '# Decisions'); - writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements'); - writeFileSync(join(mainBase, '.gsd', 'PROJECT.md'), '# Project'); + const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001'); + mkdirSync(m001Dir, { recursive: true }); + writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap'); - // Worktree has none of these - const result = syncGsdStateToWorktree(mainBase, wtBase); + // Worktree has a stale gsd.db + writeFileSync(join(wtBase, '.gsd', 'gsd.db'), 'stale data'); + assertTrue(existsSync(join(wtBase, '.gsd', 'gsd.db')), 'gsd.db exists before sync'); - assertTrue(existsSync(join(wtBase, '.gsd', 'DECISIONS.md')), 'DECISIONS.md synced'); - assertTrue(existsSync(join(wtBase, '.gsd', 'REQUIREMENTS.md')), 'REQUIREMENTS.md synced'); - assertTrue(existsSync(join(wtBase, '.gsd', 'PROJECT.md')), 'PROJECT.md synced'); - assertTrue(result.synced.length >= 3, 'at least 3 files synced'); + syncProjectRootToWorktree(mainBase, wtBase, 'M001'); + + assertTrue(!existsSync(join(wtBase, '.gsd', 'gsd.db')), '#853: gsd.db deleted after sync'); + } finally { + cleanup(mainBase); + cleanup(wtBase); + } + } + + // ─── 4. No-op when paths are equal ──────────────────────────────────── + console.log('\n=== 4. no-op when paths are equal ==='); + { + const base = createBase('same'); + try { + // Should not throw + syncProjectRootToWorktree(base, base, 'M001'); + assertTrue(true, 'no crash when paths are equal'); + } finally { + cleanup(base); + } + } + + // ─── 5. No-op when milestoneId is null ──────────────────────────────── + console.log('\n=== 5. no-op when milestoneId is null ==='); + { + const mainBase = createBase('main'); + const wtBase = createBase('wt'); + try { + syncProjectRootToWorktree(mainBase, wtBase, null); + assertTrue(true, 'no crash when milestoneId is null'); } finally { cleanup(mainBase); cleanup(wtBase); @@ -193,8 +144,8 @@ async function main(): Promise { // ─── 6. Non-existent directories handled gracefully ─────────────────── console.log('\n=== 6. non-existent directories → no-op ==='); { - const result = syncGsdStateToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt'); - assertEq(result.synced.length, 0, 'no crash on missing directories'); + syncProjectRootToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt', 'M001'); + assertTrue(true, 'no crash on missing directories'); } report(); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 9547b8d5c..995c45be6 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -104,11 +104,15 @@ async function main(): Promise { run("git checkout -b f-123-thing", repo); assertEq(getCurrentBranch(repo), "f-123-thing", "on feature branch"); + const commitsBefore = run("git rev-list --count HEAD", repo); captureIntegrationBranch(repo, "M001"); assertEq(readIntegrationBranch(repo, "M001"), "f-123-thing", "captureIntegrationBranch records the current branch"); - // .gsd/ metadata is written to disk only (not committed) since commit_docs removal + // Metadata is stored in external state, not committed to git. + const commitsAfter = run("git rev-list --count HEAD", repo); + assertEq(commitsAfter, commitsBefore, "captureIntegrationBranch does not create a git commit"); + rmSync(repo, { recursive: true, force: true }); } diff --git a/src/resources/extensions/gsd/tests/write-gate.test.ts b/src/resources/extensions/gsd/tests/write-gate.test.ts index 2387de245..0b7074adc 100644 --- a/src/resources/extensions/gsd/tests/write-gate.test.ts +++ b/src/resources/extensions/gsd/tests/write-gate.test.ts @@ -1,31 +1,19 @@ /** - * Unit tests for the CONTEXT.md write-gate. + * Unit tests for the CONTEXT.md write-gate (D031 guard chain). * * Exercises shouldBlockContextWrite() — a pure function that implements: * (a) toolName !== "write" → pass - * (b) milestoneId null AND no queue phase → pass (not in any flow) + * (b) milestoneId null → pass (not in discussion) * (c) path doesn't match /M\d+-CONTEXT\.md$/ → pass - * (d) depthVerified → pass (backward compat for discussion flows) - * (e) queuePhaseActive + per-milestone verified → pass - * (f) queuePhaseActive + not verified → block - * (g) else → block with actionable reason - * - * Also exercises per-milestone verification helpers: - * markDepthVerified(), isDepthVerifiedFor() + * (d) depthVerified → pass + * (e) else → block with actionable reason */ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - shouldBlockContextWrite, - markDepthVerified, - isDepthVerifiedFor, - isDepthVerified, -} from '../index.ts'; +import { shouldBlockContextWrite } from '../index.ts'; -// ═══════════════════════════════════════════════════════════════════════════ -// Discussion flow tests (backward compatibility) -// ═══════════════════════════════════════════════════════════════════════════ +// ─── Scenario 1: Blocks CONTEXT.md write during discussion without depth verification (absolute path) ── test('write-gate: blocks CONTEXT.md write during discussion without depth verification (absolute path)', () => { const result = shouldBlockContextWrite( @@ -38,6 +26,8 @@ test('write-gate: blocks CONTEXT.md write during discussion without depth verifi assert.ok(result.reason, 'should provide a reason'); }); +// ─── Scenario 2: Blocks CONTEXT.md write during discussion without depth verification (relative path) ── + test('write-gate: blocks CONTEXT.md write during discussion without depth verification (relative path)', () => { const result = shouldBlockContextWrite( 'write', @@ -49,7 +39,9 @@ test('write-gate: blocks CONTEXT.md write during discussion without depth verifi assert.ok(result.reason, 'should provide a reason'); }); -test('write-gate: allows CONTEXT.md write after depth verification (discussion flow)', () => { +// ─── Scenario 3: Allows CONTEXT.md write after depth verification ── + +test('write-gate: allows CONTEXT.md write after depth verification', () => { const result = shouldBlockContextWrite( 'write', '/Users/dev/project/.gsd/milestones/M001/M001-CONTEXT.md', @@ -60,28 +52,51 @@ test('write-gate: allows CONTEXT.md write after depth verification (discussion f assert.strictEqual(result.reason, undefined, 'should have no reason'); }); -test('write-gate: allows CONTEXT.md write outside any flow (milestoneId null, no queue)', () => { +// ─── Scenario 4: Allows CONTEXT.md write outside discussion phase (milestoneId null) ── + +test('write-gate: allows CONTEXT.md write outside discussion phase', () => { const result = shouldBlockContextWrite( 'write', '.gsd/milestones/M001/M001-CONTEXT.md', null, false, - false, ); - assert.strictEqual(result.block, false, 'should not block outside any flow'); + assert.strictEqual(result.block, false, 'should not block outside discussion phase'); }); +// ─── Scenario 5: Allows non-CONTEXT.md writes during discussion ── + test('write-gate: allows non-CONTEXT.md writes during discussion', () => { - const r1 = shouldBlockContextWrite('write', '.gsd/milestones/M001/M001-DISCUSSION.md', 'M001', false); + // DISCUSSION.md + const r1 = shouldBlockContextWrite( + 'write', + '.gsd/milestones/M001/M001-DISCUSSION.md', + 'M001', + false, + ); assert.strictEqual(r1.block, false, 'DISCUSSION.md should pass'); - const r2 = shouldBlockContextWrite('write', '.gsd/milestones/M001/slices/S01/S01-PLAN.md', 'M001', false); + // Slice file + const r2 = shouldBlockContextWrite( + 'write', + '.gsd/milestones/M001/slices/S01/S01-PLAN.md', + 'M001', + false, + ); assert.strictEqual(r2.block, false, 'slice plan should pass'); - const r3 = shouldBlockContextWrite('write', 'src/index.ts', 'M001', false); + // Regular code file + const r3 = shouldBlockContextWrite( + 'write', + 'src/index.ts', + 'M001', + false, + ); assert.strictEqual(r3.block, false, 'regular code file should pass'); }); +// ─── Scenario 6: Regex specificity — doesn't match S01-CONTEXT.md ── + test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', () => { const result = shouldBlockContextWrite( 'write', @@ -92,7 +107,9 @@ test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', () assert.strictEqual(result.block, false, 'S01-CONTEXT.md should not be blocked'); }); -test('write-gate: blocked reason contains actionable instructions', () => { +// ─── Scenario 7: Error message contains actionable instruction ── + +test('write-gate: blocked reason contains depth_verification keyword', () => { const result = shouldBlockContextWrite( 'write', '.gsd/milestones/M999/M999-CONTEXT.md', @@ -100,112 +117,6 @@ test('write-gate: blocked reason contains actionable instructions', () => { false, ); assert.strictEqual(result.block, true); - assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification'); - assert.ok(result.reason!.includes('ask_user_questions'), 'reason should mention ask_user_questions'); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Queue flow tests (NEW — enforces write-gate during /gsd queue) -// ═══════════════════════════════════════════════════════════════════════════ - -test('write-gate: blocks CONTEXT.md write during queue flow without verification', () => { - const result = shouldBlockContextWrite( - 'write', - '.gsd/milestones/M010-3ym37m/M010-3ym37m-CONTEXT.md', - null, // queue flows have no pendingAutoStart → milestoneId is null - false, - true, // but queuePhaseActive is true - ); - assert.strictEqual(result.block, true, 'should block during queue flow without verification'); - assert.ok(result.reason!.includes('multi-milestone'), 'reason should mention multi-milestone'); -}); - -test('write-gate: allows CONTEXT.md write during queue flow AFTER per-milestone verification', () => { - // Simulate: depth_verification_M010-3ym37m was answered - markDepthVerified('M010-3ym37m'); - - const result = shouldBlockContextWrite( - 'write', - '.gsd/milestones/M010-3ym37m/M010-3ym37m-CONTEXT.md', - null, - false, - true, - ); - assert.strictEqual(result.block, false, 'should allow after per-milestone verification'); -}); - -test('write-gate: blocks DIFFERENT milestone in queue flow when only one is verified', () => { - // M010-3ym37m was verified above, but M011-rfmd3q was NOT - const result = shouldBlockContextWrite( - 'write', - '.gsd/milestones/M011-rfmd3q/M011-rfmd3q-CONTEXT.md', - null, - false, - true, - ); - assert.strictEqual(result.block, true, 'should block unverified milestone even when another is verified'); -}); - -test('write-gate: wildcard verification unlocks all milestones in queue flow', () => { - markDepthVerified('*'); - - const r1 = shouldBlockContextWrite( - 'write', - '.gsd/milestones/M099/M099-CONTEXT.md', - null, - false, - true, - ); - assert.strictEqual(r1.block, false, 'wildcard should pass any milestone'); -}); - -test('write-gate: allows non-CONTEXT.md writes during queue flow regardless', () => { - const result = shouldBlockContextWrite( - 'write', - '.gsd/QUEUE.md', - null, - false, - true, - ); - assert.strictEqual(result.block, false, 'QUEUE.md should pass during queue flow'); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Unique milestone ID format tests -// ═══════════════════════════════════════════════════════════════════════════ - -test('write-gate: matches unique milestone ID format (M010-3ym37m)', () => { - const result = shouldBlockContextWrite( - 'write', - '.gsd/milestones/M010-3ym37m/M010-3ym37m-CONTEXT.md', - 'M010-3ym37m', - false, - ); - assert.strictEqual(result.block, true, 'should match unique milestone ID format'); -}); - -test('write-gate: matches classic milestone ID format (M001)', () => { - const result = shouldBlockContextWrite( - 'write', - '.gsd/milestones/M001/M001-CONTEXT.md', - 'M001', - false, - ); - assert.strictEqual(result.block, true, 'should match classic milestone ID format'); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Per-milestone depth verification helpers -// ═══════════════════════════════════════════════════════════════════════════ - -test('isDepthVerifiedFor: returns false for unknown milestone', () => { - assert.strictEqual(isDepthVerifiedFor('M999-xxxxxx'), true, - 'returns true because wildcard * was set in earlier test'); - // Note: test isolation would require clearing state, but these tests - // exercise the module as a singleton (matching production behavior) -}); - -test('isDepthVerified: returns true when any milestone verified', () => { - // At this point M010-3ym37m and * are verified from earlier tests - assert.strictEqual(isDepthVerified(), true); + assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification question id'); + assert.ok(result.reason!.includes('ask_user_questions'), 'reason should mention ask_user_questions tool'); }); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index ce7fe9cf4..c91d500a2 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -4,30 +4,45 @@ // ─── Enums & Literal Unions ──────────────────────────────────────────────── -export type RiskLevel = 'low' | 'medium' | 'high'; -export type Phase = 'pre-planning' | 'needs-discussion' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'validating-milestone' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked'; -export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted'; +export type RiskLevel = "low" | "medium" | "high"; +export type Phase = + | "pre-planning" + | "needs-discussion" + | "discussing" + | "researching" + | "planning" + | "executing" + | "verifying" + | "summarizing" + | "advancing" + | "validating-milestone" + | "completing-milestone" + | "replanning-slice" + | "complete" + | "paused" + | "blocked"; +export type ContinueStatus = "in_progress" | "interrupted" | "compacted"; // ─── Roadmap (Milestone-level) ───────────────────────────────────────────── export interface RoadmapSliceEntry { - id: string; // e.g. "S01" - title: string; // e.g. "Types + File I/O + Git Operations" + id: string; // e.g. "S01" + title: string; // e.g. "Types + File I/O + Git Operations" risk: RiskLevel; - depends: string[]; // e.g. ["S01", "S02"] + depends: string[]; // e.g. ["S01", "S02"] done: boolean; - demo: string; // the "After this:" sentence + demo: string; // the "After this:" sentence } export interface BoundaryMapEntry { - fromSlice: string; // e.g. "S01" - toSlice: string; // e.g. "S02" or "terminal" - produces: string; // raw text block of what this slice produces - consumes: string; // raw text block of what it consumes (or "nothing") + fromSlice: string; // e.g. "S01" + toSlice: string; // e.g. "S02" or "terminal" + produces: string; // raw text block of what this slice produces + consumes: string; // raw text block of what it consumes (or "nothing") } export interface Roadmap { - title: string; // e.g. "M001: GSD Extension — Hierarchical Planning with Auto Mode" + title: string; // e.g. "M001: GSD Extension — Hierarchical Planning with Auto Mode" vision: string; successCriteria: string[]; slices: RoadmapSliceEntry[]; @@ -37,29 +52,24 @@ export interface Roadmap { // ─── Slice Plan ──────────────────────────────────────────────────────────── export interface TaskPlanEntry { - id: string; // e.g. "T01" - title: string; // e.g. "Core Type Definitions" + id: string; // e.g. "T01" + title: string; // e.g. "Core Type Definitions" description: string; done: boolean; - estimate: string; // e.g. "30m", "2h" — informational only - files?: string[]; // e.g. ["types.ts", "files.ts"] — extracted from "- Files:" subline - verify?: string; // e.g. "run tests" — extracted from "- Verify:" subline + estimate: string; // e.g. "30m", "2h" — informational only + files?: string[]; // e.g. ["types.ts", "files.ts"] — extracted from "- Files:" subline + verify?: string; // e.g. "run tests" — extracted from "- Verify:" subline } // ─── Verification Gate ───────────────────────────────────────────────────── /** Result of a single verification command execution */ export interface VerificationCheck { - command: string; // e.g. "npm run lint" - exitCode: number; // 0 = pass + command: string; // e.g. "npm run lint" + exitCode: number; // 0 = pass stdout: string; stderr: string; durationMs: number; - blocking: boolean; // true for preference/task-plan sources, false for package-json (advisory only) - /** True when the failure was a spawn/infra error (ETIMEDOUT, ENOENT, ENOMEM) - * rather than the command itself failing. Infra errors are transient and - * should not trigger auto-fix retries — the agent cannot fix the OS. */ - infraError?: boolean; } /** A runtime error captured from bg-shell processes or browser console */ @@ -81,17 +91,17 @@ export interface AuditWarning { /** Aggregate result from the verification gate */ export interface VerificationResult { - passed: boolean; // true if all checks passed (or no checks discovered) - checks: VerificationCheck[]; // per-command results + passed: boolean; // true if all checks passed (or no checks discovered) + checks: VerificationCheck[]; // per-command results discoverySource: "preference" | "task-plan" | "package-json" | "none"; - timestamp: number; // Date.now() at gate start - runtimeErrors?: RuntimeError[]; // optional — populated by captureRuntimeErrors() - auditWarnings?: AuditWarning[]; // optional — populated by runDependencyAudit() + timestamp: number; // Date.now() at gate start + runtimeErrors?: RuntimeError[]; // optional — populated by captureRuntimeErrors() + auditWarnings?: AuditWarning[]; // optional — populated by runDependencyAudit() } export interface SlicePlan { - id: string; // e.g. "S01" - title: string; // from the H1 + id: string; // e.g. "S01" + title: string; // from the H1 goal: string; demo: string; mustHaves: string[]; // top-level must-have bullet points @@ -161,29 +171,29 @@ export interface Continue { // ─── Secrets Manifest ────────────────────────────────────────────────────── -export type SecretsManifestEntryStatus = 'pending' | 'collected' | 'skipped'; +export type SecretsManifestEntryStatus = "pending" | "collected" | "skipped"; export interface SecretsManifestEntry { - key: string; // e.g. "OPENAI_API_KEY" - service: string; // e.g. "OpenAI" - dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown - guidance: string[]; // numbered setup steps - formatHint: string; // e.g. "starts with sk-" — empty if unknown + key: string; // e.g. "OPENAI_API_KEY" + service: string; // e.g. "OpenAI" + dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown + guidance: string[]; // numbered setup steps + formatHint: string; // e.g. "starts with sk-" — empty if unknown status: SecretsManifestEntryStatus; - destination: string; // e.g. "dotenv", "vercel", "convex" + destination: string; // e.g. "dotenv", "vercel", "convex" } export interface SecretsManifest { - milestone: string; // e.g. "M001" - generatedAt: string; // ISO 8601 timestamp + milestone: string; // e.g. "M001" + generatedAt: string; // ISO 8601 timestamp entries: SecretsManifestEntry[]; } export interface ManifestStatus { - pending: string[]; // manifest status = pending AND not in env - collected: string[]; // manifest status = collected AND not in env - skipped: string[]; // manifest status = skipped - existing: string[]; // key present in .env or process.env (regardless of manifest status) + pending: string[]; // manifest status = pending AND not in env + collected: string[]; // manifest status = collected AND not in env + skipped: string[]; // manifest status = skipped + existing: string[]; // key present in .env or process.env (regardless of manifest status) } // ─── GSD State (Derived Dashboard) ──────────────────────────────────────── @@ -196,7 +206,7 @@ export interface ActiveRef { export interface MilestoneRegistryEntry { id: string; title: string; - status: 'complete' | 'active' | 'pending' | 'parked'; + status: "complete" | "active" | "pending" | "parked"; /** Milestone IDs that must be complete before this milestone becomes active. Populated from CONTEXT.md YAML frontmatter. */ dependsOn?: string[]; } @@ -279,13 +289,13 @@ export interface HookDispatchResult { // ─── Budget & Notification Types ────────────────────────────────────────── -export type BudgetEnforcementMode = 'warn' | 'pause' | 'halt'; +export type BudgetEnforcementMode = "warn" | "pause" | "halt"; -export type TokenProfile = 'budget' | 'balanced' | 'quality'; +export type TokenProfile = "budget" | "balanced" | "quality"; -export type InlineLevel = 'full' | 'standard' | 'minimal'; +export type InlineLevel = "full" | "standard" | "minimal"; -export type ComplexityTier = 'light' | 'standard' | 'heavy'; +export type ComplexityTier = "light" | "standard" | "heavy"; export interface ClassificationResult { tier: ComplexityTier; @@ -308,19 +318,18 @@ export interface PhaseSkipPreferences { skip_reassess?: boolean; skip_slice_research?: boolean; skip_milestone_validation?: boolean; - /** When true, reassess-roadmap fires after each slice completion. Opt-in. */ reassess_after_slice?: boolean; /** When true, auto-mode pauses before each slice for discussion (#789). */ require_slice_discussion?: boolean; } export interface NotificationPreferences { - enabled?: boolean; // default true - on_complete?: boolean; // notify on each unit completion - on_error?: boolean; // notify on errors - on_budget?: boolean; // notify on budget thresholds - on_milestone?: boolean; // notify when milestone finishes - on_attention?: boolean; // notify when manual attention needed + enabled?: boolean; // default true + on_complete?: boolean; // notify on each unit completion + on_error?: boolean; // notify on errors + on_budget?: boolean; // notify on budget thresholds + on_milestone?: boolean; // notify when milestone finishes + on_attention?: boolean; // notify when manual attention needed } // ─── Pre-Dispatch Hook Types ────────────────────────────────────────────── @@ -331,7 +340,7 @@ export interface PreDispatchHookConfig { /** Unit types this hook intercepts before dispatch (e.g., ["execute-task"]). */ before: string[]; /** Action to take: "modify" mutates the prompt, "skip" skips the unit, "replace" swaps it. */ - action: 'modify' | 'skip' | 'replace'; + action: "modify" | "skip" | "replace"; /** For "modify": text prepended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */ prepend?: string; /** For "modify": text appended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */ @@ -350,7 +359,7 @@ export interface PreDispatchHookConfig { export interface PreDispatchResult { /** What happened: the unit proceeds with modifications, was skipped, or was replaced. */ - action: 'proceed' | 'skip' | 'replace'; + action: "proceed" | "skip" | "replace"; /** Modified/replacement prompt (for "proceed" and "replace"). */ prompt?: string; /** Override unit type (for "replace"). */ @@ -374,7 +383,7 @@ export interface HookStatusEntry { /** Hook name. */ name: string; /** Hook type: "post" or "pre". */ - type: 'post' | 'pre'; + type: "post" | "pre"; /** Whether hook is enabled. */ enabled: boolean; /** What unit types it targets. */ @@ -386,36 +395,36 @@ export interface HookStatusEntry { // ─── Database Types (Decisions & Requirements) ──────────────────────────── export interface Decision { - seq: number; // auto-increment primary key - id: string; // e.g. "D001" - when_context: string; // when/context of the decision - scope: string; // scope (milestone, slice, global, etc.) - decision: string; // what was decided - choice: string; // the specific choice made - rationale: string; // why this choice - revisable: string; // whether/when revisable - superseded_by: string | null; // ID of superseding decision, or null + seq: number; // auto-increment primary key + id: string; // e.g. "D001" + when_context: string; // when/context of the decision + scope: string; // scope (milestone, slice, global, etc.) + decision: string; // what was decided + choice: string; // the specific choice made + rationale: string; // why this choice + revisable: string; // whether/when revisable + superseded_by: string | null; // ID of superseding decision, or null } export interface Requirement { - id: string; // e.g. "R001" - class: string; // requirement class (functional, non-functional, etc.) - status: string; // active, validated, deferred, etc. - description: string; // short description - why: string; // rationale - source: string; // origin (milestone, user, etc.) - primary_owner: string; // owning slice/milestone + id: string; // e.g. "R001" + class: string; // requirement class (functional, non-functional, etc.) + status: string; // active, validated, deferred, etc. + description: string; // short description + why: string; // rationale + source: string; // origin (milestone, user, etc.) + primary_owner: string; // owning slice/milestone supporting_slices: string; // other slices that touch this - validation: string; // how to validate - notes: string; // additional notes - full_content: string; // full requirement text - superseded_by: string | null; // ID of superseding requirement, or null + validation: string; // how to validate + notes: string; // additional notes + full_content: string; // full requirement text + superseded_by: string | null; // ID of superseding requirement, or null } // ─── Parallel Orchestration Types ──────────────────────────────────────── -export type CompressionStrategy = 'truncate' | 'compress'; -export type ContextSelectionMode = 'full' | 'smart'; +export type CompressionStrategy = "truncate" | "compress"; +export type ContextSelectionMode = "full" | "smart"; export type MergeStrategy = "per-slice" | "per-milestone"; export type AutoMergeMode = "auto" | "confirm" | "manual"; diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts index fa3815d57..a9b66c270 100644 --- a/src/resources/extensions/gsd/undo.ts +++ b/src/resources/extensions/gsd/undo.ts @@ -9,46 +9,48 @@ import { deriveState } from "./state.js"; import { invalidateAllCaches } from "./cache.js"; import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js"; import { sendDesktopNotification } from "./notifications.js"; -import { parseUnitId } from "./unit-id.js"; /** - * Undo the last completed unit: revert git commits, remove from completed-units, + * Undo the last completed unit: revert git commits, * delete summary artifacts, and uncheck the task in PLAN. + * deriveState() handles re-derivation after revert. */ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi: ExtensionAPI, basePath: string): Promise { const force = args.includes("--force"); - // 1. Load completed-units.json - const completedKeysFile = join(gsdRoot(basePath), "completed-units.json"); - if (!existsSync(completedKeysFile)) { - ctx.ui.notify("Nothing to undo — no completed units found.", "info"); + // Find the last GSD-related commit from git activity logs + const activityDir = join(gsdRoot(basePath), "activity"); + if (!existsSync(activityDir)) { + ctx.ui.notify("Nothing to undo — no activity logs found.", "info"); return; } - let keys: string[]; - try { - keys = JSON.parse(readFileSync(completedKeysFile, "utf-8")); - } catch { - ctx.ui.notify("Nothing to undo — completed-units.json is corrupt.", "warning"); + // Parse activity logs to find the most recent unit + const files = readdirSync(activityDir) + .filter(f => f.endsWith(".jsonl")) + .sort() + .reverse(); + + if (files.length === 0) { + ctx.ui.notify("Nothing to undo — no activity logs found.", "info"); return; } - if (keys.length === 0) { - ctx.ui.notify("Nothing to undo — no completed units.", "info"); + // Extract unit type and ID from the most recent activity log filename + // Format: --.jsonl + const match = files[0].match(/^\d+-(.+?)-(.+)\.jsonl$/); + if (!match) { + ctx.ui.notify("Nothing to undo — could not parse latest activity log.", "warning"); return; } - // Get the last completed unit - const lastKey = keys[keys.length - 1]; - const sepIdx = lastKey.indexOf("/"); - const unitType = sepIdx >= 0 ? lastKey.slice(0, sepIdx) : lastKey; - const unitId = sepIdx >= 0 ? lastKey.slice(sepIdx + 1) : lastKey; + const unitType = match[1]; + const unitId = match[2].replace(/-/g, "/"); if (!force) { ctx.ui.notify( `Will undo: ${unitType} (${unitId})\n` + `This will:\n` + - ` - Remove from completed-units.json\n` + ` - Delete summary artifacts\n` + ` - Uncheck task in PLAN (if execute-task)\n` + ` - Attempt to revert associated git commits\n\n` + @@ -58,15 +60,12 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi return; } - // 2. Remove from completed-units.json - keys = keys.filter(k => k !== lastKey); - writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8"); - - // 3. Delete summary artifact - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + // 1. Delete summary artifact + const parts = unitId.split("/"); let summaryRemoved = false; - if (mid && sid && tid) { + if (parts.length === 3) { // Task-level: M001/S01/T01 + const [mid, sid, tid] = parts; const tasksDir = resolveTasksDir(basePath, mid, sid); if (tasksDir) { const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY")); @@ -75,11 +74,11 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi summaryRemoved = true; } } - } else if (mid && sid) { + } else if (parts.length === 2) { // Slice-level: M001/S01 + const [mid, sid] = parts; const slicePath = resolveSlicePath(basePath, mid, sid); if (slicePath) { - // Try common summary filenames for (const suffix of ["SUMMARY", "COMPLETE"]) { const candidates = findFileWithPrefix(slicePath, sid, suffix); for (const f of candidates) { @@ -90,40 +89,37 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi } } - // 4. Uncheck task in PLAN if execute-task + // 2. Uncheck task in PLAN if execute-task let planUpdated = false; - if (unitType === "execute-task" && mid && sid && tid) { + if (unitType === "execute-task" && parts.length === 3) { + const [mid, sid, tid] = parts; planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid); } - // 5. Try to revert git commits from activity log + // 3. Try to revert git commits from activity log let commitsReverted = 0; - const activityDir = join(gsdRoot(basePath), "activity"); try { - if (existsSync(activityDir)) { - const commits = findCommitsForUnit(activityDir, unitType, unitId); - if (commits.length > 0) { - for (const sha of commits.reverse()) { - try { - nativeRevertCommit(basePath, sha); - commitsReverted++; - } catch { - // Revert conflict or already reverted — skip - try { nativeRevertAbort(basePath); } catch { /* no-op */ } - break; - } + const commits = findCommitsForUnit(activityDir, unitType, unitId); + if (commits.length > 0) { + for (const sha of commits.reverse()) { + try { + nativeRevertCommit(basePath, sha); + commitsReverted++; + } catch { + // Revert conflict or already reverted — skip + try { nativeRevertAbort(basePath); } catch { /* no-op */ } + break; } } } } finally { - // 6. Re-derive state — always invalidate caches even if git operations fail + // 4. Re-derive state — always invalidate caches even if git operations fail invalidateAllCaches(); await deriveState(basePath); } // Build result message const results: string[] = [`Undone: ${unitType} (${unitId})`]; - results.push(` - Removed from completed-units.json`); if (summaryRemoved) results.push(` - Deleted summary artifact`); if (planUpdated) results.push(` - Unchecked task in PLAN`); if (commitsReverted > 0) { diff --git a/src/resources/extensions/gsd/unit-runtime.ts b/src/resources/extensions/gsd/unit-runtime.ts index ba1dbef55..8384ea401 100644 --- a/src/resources/extensions/gsd/unit-runtime.ts +++ b/src/resources/extensions/gsd/unit-runtime.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot, @@ -8,8 +8,6 @@ import { resolveTaskFile, } from "./paths.js"; import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js"; -import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; -import { parseUnitId } from "./unit-id.js"; export type UnitRuntimePhase = | "dispatched" @@ -48,23 +46,13 @@ export interface AutoUnitRuntimeRecord { lastRecoveryReason?: "idle" | "hard"; } -function isAutoUnitRuntimeRecord(data: unknown): data is AutoUnitRuntimeRecord { - return ( - typeof data === "object" && - data !== null && - (data as AutoUnitRuntimeRecord).version === 1 && - typeof (data as AutoUnitRuntimeRecord).unitType === "string" && - typeof (data as AutoUnitRuntimeRecord).unitId === "string" - ); -} - function runtimeDir(basePath: string): string { return join(gsdRoot(basePath), "runtime", "units"); } function runtimePath(basePath: string, unitType: string, unitId: string): string { - const sanitizedUnitType = unitType.replace(/[^a-zA-Z0-9._-]+/g, "-"); - const sanitizedUnitId = unitId.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const sanitizedUnitType = unitType.replace(/[\/]/g, "-"); + const sanitizedUnitId = unitId.replace(/[\/]/g, "-"); return join(runtimeDir(basePath), `${sanitizedUnitType}-${sanitizedUnitId}.json`); } @@ -75,6 +63,8 @@ export function writeUnitRuntimeRecord( startedAt: number, updates: Partial = {}, ): AutoUnitRuntimeRecord { + const dir = runtimeDir(basePath); + mkdirSync(dir, { recursive: true }); const path = runtimePath(basePath, unitType, unitId); const prev = readUnitRuntimeRecord(basePath, unitType, unitId); const next: AutoUnitRuntimeRecord = { @@ -94,12 +84,18 @@ export function writeUnitRuntimeRecord( recoveryAttempts: updates.recoveryAttempts ?? prev?.recoveryAttempts ?? 0, lastRecoveryReason: updates.lastRecoveryReason ?? prev?.lastRecoveryReason, }; - saveJsonFile(path, next); + writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8"); return next; } export function readUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): AutoUnitRuntimeRecord | null { - return loadJsonFileOrNull(runtimePath(basePath, unitType, unitId), isAutoUnitRuntimeRecord); + const path = runtimePath(basePath, unitType, unitId); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as AutoUnitRuntimeRecord; + } catch { + return null; + } } export function clearUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): void { @@ -132,7 +128,7 @@ export async function inspectExecuteTaskDurability( basePath: string, unitId: string, ): Promise { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const [mid, sid, tid] = unitId.split("/"); if (!mid || !sid || !tid) return null; const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN"); diff --git a/src/resources/extensions/gsd/verification-evidence.ts b/src/resources/extensions/gsd/verification-evidence.ts index 761385f05..0918b40f1 100644 --- a/src/resources/extensions/gsd/verification-evidence.ts +++ b/src/resources/extensions/gsd/verification-evidence.ts @@ -11,7 +11,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { VerificationResult } from "./types.js"; +import type { VerificationResult } from "./types.ts"; // ─── JSON Evidence Artifact ────────────────────────────────────────────────── @@ -20,7 +20,6 @@ export interface EvidenceCheckJSON { exitCode: number; durationMs: number; verdict: "pass" | "fail"; - blocking: boolean; } export interface RuntimeErrorJSON { @@ -81,7 +80,6 @@ export function writeVerificationJSON( exitCode: check.exitCode, durationMs: check.durationMs, verdict: check.exitCode === 0 ? "pass" : "fail", - blocking: check.blocking, })), ...(retryAttempt !== undefined ? { retryAttempt } : {}), ...(maxRetries !== undefined ? { maxRetries } : {}), diff --git a/src/resources/extensions/gsd/verification-gate.ts b/src/resources/extensions/gsd/verification-gate.ts index 04665d97b..22af55f92 100644 --- a/src/resources/extensions/gsd/verification-gate.ts +++ b/src/resources/extensions/gsd/verification-gate.ts @@ -45,12 +45,11 @@ const PACKAGE_SCRIPT_KEYS = ["typecheck", "lint", "test"] as const; * 4. None found */ export function discoverCommands(options: DiscoverCommandsOptions): DiscoveredCommands { - // 1. Preference commands (still sanitize — may contain prose from misconfiguration) + // 1. Preference commands if (options.preferenceCommands && options.preferenceCommands.length > 0) { const filtered = options.preferenceCommands .map(c => c.trim()) - .filter(Boolean) - .filter(c => isLikelyCommand(c)); + .filter(Boolean); if (filtered.length > 0) { return { commands: filtered, source: "preference" }; } @@ -112,9 +111,7 @@ const MAX_FAILURE_CONTEXT_CHARS = 10_000; * Returns an empty string when all checks pass or the checks array is empty. */ export function formatFailureContext(result: VerificationResult): string { - // Only include blocking failures in retry context — non-blocking (advisory) failures - // should not be injected into retry prompts to avoid noise pollution. - const failures = result.checks.filter((c) => c.exitCode !== 0 && c.blocking); + const failures = result.checks.filter((c) => c.exitCode !== 0); if (failures.length === 0) return ""; const blocks: string[] = []; @@ -232,20 +229,13 @@ export interface RunVerificationGateOptions { commandTimeoutMs?: number; } -/** Error codes from spawnSync that indicate infrastructure/OS-level failures - * rather than the command itself failing. These are transient — the agent - * cannot fix them, so they should not trigger auto-fix retries. */ -const INFRA_ERROR_CODES = new Set(["ETIMEDOUT", "ENOENT", "ENOMEM", "EMFILE", "ENFILE", "EAGAIN"]); - /** * Run the verification gate: discover commands, execute each via spawnSync, * and return a structured result. * * - All commands run sequentially regardless of individual pass/fail. - * - `passed` is true when every blocking command exits 0 (or no commands are discovered). + * - `passed` is true when every command exits 0 (or no commands are discovered). * - stdout/stderr per command are truncated to 10 KB. - * - Spawn/infra errors (ETIMEDOUT, ENOENT, etc.) are tagged with `infraError: true` - * so the retry logic can distinguish "the OS couldn't run this" from "the tests failed". */ export function runVerificationGate(options: RunVerificationGateOptions): VerificationResult { const timestamp = Date.now(); @@ -265,10 +255,6 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi }; } - // Commands from preference and task-plan sources are blocking; - // package-json discovered commands are advisory (non-blocking). - const blocking = source === "preference" || source === "task-plan"; - const checks: VerificationCheck[] = []; for (const command of commands) { @@ -286,26 +272,12 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi let stderr: string; if (result.error) { - // Spawn infrastructure failure — OS-level, not a test failure. - // Tag with infraError so the retry logic can skip auto-fix attempts. - const errCode = (result.error as NodeJS.ErrnoException).code; - const isInfra = !!errCode && INFRA_ERROR_CODES.has(errCode); + // Command not found or spawn failure exitCode = 127; stderr = truncate( (result.stderr || "") + "\n" + (result.error as Error).message, MAX_OUTPUT_BYTES, ); - - checks.push({ - command, - exitCode, - stdout: truncate(result.stdout, MAX_OUTPUT_BYTES), - stderr, - durationMs, - blocking, - ...(isInfra ? { infraError: true } : {}), - }); - continue; } else { // status is null when killed by signal — treat as failure exitCode = result.status ?? 1; @@ -318,16 +290,11 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi stdout: truncate(result.stdout, MAX_OUTPUT_BYTES), stderr, durationMs, - blocking, }); } - // Gate passes if all blocking checks pass (non-blocking failures are advisory) - const blockingChecks = checks.filter(c => c.blocking); - const passed = blockingChecks.length === 0 || blockingChecks.every(c => c.exitCode === 0); - return { - passed, + passed: checks.every(c => c.exitCode === 0), checks, discoverySource: source, timestamp, diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 8cb6ee086..1be5a016b 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -34,7 +34,6 @@ import type { FileLineStat } from "./worktree-manager.js"; import { existsSync, realpathSync, readdirSync, rmSync, unlinkSync } from "node:fs"; import { nativeMergeAbort } from "./native-git-bridge.js"; import { join, sep } from "node:path"; -import { getErrorMessage } from "./error-utils.js"; /** * Tracks the original project root so we can switch back. @@ -42,28 +41,16 @@ import { getErrorMessage } from "./error-utils.js"; */ let originalCwd: string | null = null; -function ensureWorktreeStateInitialized(): void { - if (originalCwd) return; - const cwd = process.cwd(); - const marker = `${sep}.gsd${sep}worktrees${sep}`; - const markerIdx = cwd.indexOf(marker); - if (markerIdx !== -1) { - originalCwd = cwd.slice(0, markerIdx); - } -} - /** Get the original project root if currently in a worktree, or null. */ export function getWorktreeOriginalCwd(): string | null { - ensureWorktreeStateInitialized(); return originalCwd; } /** Get the name of the active worktree, or null if not in one. */ export function getActiveWorktreeName(): string | null { - ensureWorktreeStateInitialized(); if (!originalCwd) return null; const cwd = process.cwd(); - const wtDir = join(gsdRoot(originalCwd), "worktrees"); + const wtDir = join(originalCwd, ".gsd", "worktrees"); if (!cwd.startsWith(wtDir)) return null; const rel = cwd.slice(wtDir.length + 1); const name = rel.split("/")[0] ?? rel.split("\\")[0]; @@ -116,13 +103,12 @@ function worktreeCompletions(prefix: string) { return []; } -export async function handleWorktreeCommand( +async function worktreeHandler( args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI, alias: string, ): Promise { - ensureWorktreeStateInitialized(); const trimmed = (typeof args === "string" ? args : "").trim(); const basePath = process.cwd(); @@ -242,11 +228,27 @@ export async function handleWorktreeCommand( } } +export async function handleWorktreeCommand( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + alias: string, +): Promise { + await worktreeHandler(args, ctx, pi, alias); +} + export function registerWorktreeCommand(pi: ExtensionAPI): void { // Restore worktree state after /reload. // The module-level originalCwd resets to null when extensions are re-loaded, // but process.cwd() is still inside the worktree. Detect this and recover. - ensureWorktreeStateInitialized(); + if (!originalCwd) { + const cwd = process.cwd(); + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const markerIdx = cwd.indexOf(marker); + if (markerIdx !== -1) { + originalCwd = cwd.slice(0, markerIdx); + } + } pi.registerCommand("worktree", { description: "Git worktrees (also /wt): /worktree | list | merge | remove", @@ -377,7 +379,7 @@ async function handleCreate( "info", ); } catch (error) { - const msg = getErrorMessage(error); + const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to create worktree: ${msg}`, "error"); } } @@ -425,7 +427,7 @@ async function handleSwitch( "info", ); } catch (error) { - const msg = getErrorMessage(error); + const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to switch to worktree: ${msg}`, "error"); } } @@ -535,7 +537,7 @@ async function handleList( ctx.ui.notify(lines.join("\n"), "info"); } catch (error) { - const msg = getErrorMessage(error); + const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to list worktrees: ${msg}`, "error"); } } @@ -640,6 +642,16 @@ async function handleMerge( const commitType = inferCommitType(name); const commitMessage = `${commitType}(${name}): merge worktree ${name}`; + // Reconcile worktree DB into main DB before squash merge + const wtDbPath = join(worktreePath(basePath, name), ".gsd", "gsd.db"); + const mainDbPath = join(basePath, ".gsd", "gsd.db"); + if (existsSync(wtDbPath) && existsSync(mainDbPath)) { + try { + const { reconcileWorktreeDb } = await import("./gsd-db.js"); + reconcileWorktreeDb(mainDbPath, wtDbPath); + } catch { /* non-fatal */ } + } + try { mergeWorktreeToMain(basePath, name, commitMessage); ctx.ui.notify( @@ -653,7 +665,7 @@ async function handleMerge( ); return; } catch (mergeErr) { - const mergeMsg = getErrorMessage(mergeErr); + const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr); const isConflict = /conflict/i.test(mergeMsg); if (isConflict) { @@ -710,7 +722,7 @@ async function handleMerge( "info", ); } catch (error) { - const msg = getErrorMessage(error); + const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to start merge: ${msg}`, "error"); } } @@ -753,7 +765,7 @@ async function handleRemove( ctx.ui.notify(`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, "info"); } catch (error) { - const msg = getErrorMessage(error); + const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error"); } } @@ -807,7 +819,7 @@ async function handleRemoveAll( if (failed.length > 0) lines.push(`${CLR.warn("✗")} Failed: ${failed.map(n => CLR.name(n)).join(", ")}`); ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info"); } catch (error) { - const msg = getErrorMessage(error); + const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to remove worktrees: ${msg}`, "error"); } } diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index e10f2707f..191676ccf 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -17,7 +17,6 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { join, resolve, sep } from "node:path"; -import { gsdRoot } from "./paths.js"; import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js"; import { nativeBranchDelete, @@ -101,7 +100,7 @@ export function resolveGitDir(basePath: string): string { } export function worktreesDir(basePath: string): string { - return join(gsdRoot(basePath), "worktrees"); + return join(basePath, ".gsd", "worktrees"); } export function worktreePath(basePath: string, name: string): string { @@ -194,7 +193,7 @@ export function listWorktrees(basePath: string): WorktreeInfo[] { const seenRoots = new Set(); const worktreeRoots = baseVariants .map(baseVariant => { - const path = join(gsdRoot(baseVariant), "worktrees"); + const path = join(baseVariant, ".gsd", "worktrees"); return { normalized: normalizePathForComparison(path), }; diff --git a/src/resources/extensions/gsd/worktree-resolver.ts b/src/resources/extensions/gsd/worktree-resolver.ts new file mode 100644 index 000000000..bdc081d52 --- /dev/null +++ b/src/resources/extensions/gsd/worktree-resolver.ts @@ -0,0 +1,485 @@ +/** + * WorktreeResolver — encapsulates worktree path state and merge/exit lifecycle. + * + * Replaces scattered `s.basePath`/`s.originalBasePath` mutation and 3 duplicated + * merge-or-teardown blocks in auto-loop.ts with single method calls. All + * `s.basePath` mutations (except session.reset() and initial setup) happen + * through this class. + * + * Design: Option A — mutates AutoSession fields directly so existing `s.basePath` + * reads continue to work everywhere without wiring changes. + * + * Key invariant: `createAutoWorktree()` and `enterAutoWorktree()` call + * `process.chdir()` internally — this class MUST NOT double-chdir. + */ + +import type { AutoSession } from "./auto/session.js"; +import { debugLog } from "./debug-logger.js"; + +// ─── Dependency Interface ────────────────────────────────────────────────── + +export interface WorktreeResolverDeps { + isInAutoWorktree: (basePath: string) => boolean; + shouldUseWorktreeIsolation: () => boolean; + getIsolationMode: () => "worktree" | "branch" | "none"; + mergeMilestoneToMain: ( + basePath: string, + milestoneId: string, + roadmapContent: string, + ) => { pushed: boolean }; + syncWorktreeStateBack: ( + mainBasePath: string, + worktreePath: string, + milestoneId: string, + ) => { synced: string[] }; + teardownAutoWorktree: ( + basePath: string, + milestoneId: string, + opts?: { preserveBranch?: boolean }, + ) => void; + createAutoWorktree: (basePath: string, milestoneId: string) => string; + enterAutoWorktree: (basePath: string, milestoneId: string) => string; + getAutoWorktreePath: (basePath: string, milestoneId: string) => string | null; + autoCommitCurrentBranch: ( + basePath: string, + reason: string, + milestoneId: string, + ) => void; + getCurrentBranch: (basePath: string) => string; + autoWorktreeBranch: (milestoneId: string) => string; + resolveMilestoneFile: ( + basePath: string, + milestoneId: string, + fileType: string, + ) => string | null; + readFileSync: (path: string, encoding: string) => string; + GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown; + loadEffectiveGSDPreferences: () => + | { preferences?: { git?: Record } } + | undefined; + invalidateAllCaches: () => void; + captureIntegrationBranch: ( + basePath: string, + mid: string, + opts?: { commitDocs?: boolean }, + ) => void; +} + +// ─── Notify Context ──────────────────────────────────────────────────────── + +export interface NotifyCtx { + notify: ( + msg: string, + level?: "info" | "warning" | "error" | "success", + ) => void; +} + +// ─── WorktreeResolver ────────────────────────────────────────────────────── + +export class WorktreeResolver { + private readonly s: AutoSession; + private readonly deps: WorktreeResolverDeps; + + constructor(session: AutoSession, deps: WorktreeResolverDeps) { + this.s = session; + this.deps = deps; + } + + // ── Getters ──────────────────────────────────────────────────────────── + + /** Current working path — may be worktree or project root. */ + get workPath(): string { + return this.s.basePath; + } + + /** Original project root — always the non-worktree path. */ + get projectRoot(): string { + return this.s.originalBasePath || this.s.basePath; + } + + /** Path for auto.lock file — same as the old lockBase(). */ + get lockPath(): string { + return this.s.originalBasePath || this.s.basePath; + } + + // ── Private Helpers ──────────────────────────────────────────────────── + + private rebuildGitService(): void { + const gitConfig = + this.deps.loadEffectiveGSDPreferences()?.preferences?.git ?? {}; + this.s.gitService = new this.deps.GitServiceImpl( + this.s.basePath, + gitConfig, + ) as AutoSession["gitService"]; + } + + /** Restore basePath to originalBasePath and rebuild GitService. */ + private restoreToProjectRoot(): void { + if (!this.s.originalBasePath) return; + this.s.basePath = this.s.originalBasePath; + this.rebuildGitService(); + this.deps.invalidateAllCaches(); + } + + // ── Validation ────────────────────────────────────────────────────────── + + /** Validate milestoneId to prevent path traversal. */ + private validateMilestoneId(milestoneId: string): void { + if (/[\/\\]|\.\./.test(milestoneId)) { + throw new Error( + `Invalid milestoneId: ${milestoneId} — contains path separators or traversal`, + ); + } + } + + // ── Enter Milestone ──────────────────────────────────────────────────── + + /** + * Enter or create a worktree for the given milestone. + * + * Only acts if `shouldUseWorktreeIsolation()` returns true. + * Delegates to `enterAutoWorktree` (existing) or `createAutoWorktree` (new). + * Those functions call `process.chdir()` internally — we do NOT double-chdir. + * + * Updates `s.basePath` and rebuilds GitService on success. + * On failure: notifies a warning and does NOT update `s.basePath`. + */ + enterMilestone(milestoneId: string, ctx: NotifyCtx): void { + this.validateMilestoneId(milestoneId); + if (!this.deps.shouldUseWorktreeIsolation()) { + debugLog("WorktreeResolver", { + action: "enterMilestone", + milestoneId, + skipped: true, + reason: "isolation-disabled", + }); + return; + } + + const basePath = this.s.originalBasePath || this.s.basePath; + debugLog("WorktreeResolver", { + action: "enterMilestone", + milestoneId, + basePath, + }); + + try { + const existingPath = this.deps.getAutoWorktreePath(basePath, milestoneId); + let wtPath: string; + + if (existingPath) { + wtPath = this.deps.enterAutoWorktree(basePath, milestoneId); + } else { + wtPath = this.deps.createAutoWorktree(basePath, milestoneId); + } + + this.s.basePath = wtPath; + this.rebuildGitService(); + + debugLog("WorktreeResolver", { + action: "enterMilestone", + milestoneId, + result: "success", + wtPath, + }); + ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugLog("WorktreeResolver", { + action: "enterMilestone", + milestoneId, + result: "error", + error: msg, + }); + ctx.notify( + `Auto-worktree creation for ${milestoneId} failed: ${msg}. Continuing in project root.`, + "warning", + ); + // Do NOT update s.basePath — stay in project root + } + } + + // ── Exit Milestone ───────────────────────────────────────────────────── + + /** + * Exit the current worktree: auto-commit, teardown, reset basePath. + * + * Only acts if currently in an auto-worktree (checked via `isInAutoWorktree`). + * Resets `s.basePath` to `s.originalBasePath` and rebuilds GitService. + */ + exitMilestone( + milestoneId: string, + ctx: NotifyCtx, + opts?: { preserveBranch?: boolean }, + ): void { + this.validateMilestoneId(milestoneId); + if (!this.deps.isInAutoWorktree(this.s.basePath)) { + debugLog("WorktreeResolver", { + action: "exitMilestone", + milestoneId, + skipped: true, + reason: "not-in-worktree", + }); + return; + } + + debugLog("WorktreeResolver", { + action: "exitMilestone", + milestoneId, + basePath: this.s.basePath, + }); + + try { + this.deps.autoCommitCurrentBranch(this.s.basePath, "stop", milestoneId); + } catch (err) { + debugLog("WorktreeResolver", { + action: "exitMilestone", + milestoneId, + phase: "auto-commit-failed", + error: err instanceof Error ? err.message : String(err), + }); + } + + try { + this.deps.teardownAutoWorktree(this.s.originalBasePath, milestoneId, { + preserveBranch: opts?.preserveBranch ?? false, + }); + } catch (err) { + debugLog("WorktreeResolver", { + action: "exitMilestone", + milestoneId, + phase: "teardown-failed", + error: err instanceof Error ? err.message : String(err), + }); + } + + this.restoreToProjectRoot(); + debugLog("WorktreeResolver", { + action: "exitMilestone", + milestoneId, + result: "done", + basePath: this.s.basePath, + }); + ctx.notify(`Exited worktree for ${milestoneId}`, "info"); + } + + // ── Merge and Exit ───────────────────────────────────────────────────── + + /** + * Merge the completed milestone branch back to main and exit the worktree. + * + * Handles all three isolation modes: + * - **worktree**: Read roadmap, merge, teardown worktree, reset paths. + * Falls back to bare teardown if no roadmap exists. + * - **branch**: Check if on milestone branch, merge if so (no chdir/teardown). + * - **none**: No-op. + * + * Error recovery: on merge failure, always restore `s.basePath` to + * `s.originalBasePath` and `process.chdir(s.originalBasePath)`. + */ + mergeAndExit(milestoneId: string, ctx: NotifyCtx): void { + this.validateMilestoneId(milestoneId); + const mode = this.deps.getIsolationMode(); + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + mode, + basePath: this.s.basePath, + }); + + if (mode === "none") { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + skipped: true, + reason: "mode-none", + }); + return; + } + + if ( + mode === "worktree" || + (this.deps.isInAutoWorktree(this.s.basePath) && this.s.originalBasePath) + ) { + this._mergeWorktreeMode(milestoneId, ctx); + } else if (mode === "branch") { + this._mergeBranchMode(milestoneId, ctx); + } + } + + /** Worktree-mode merge: read roadmap, merge, teardown, reset paths. */ + private _mergeWorktreeMode(milestoneId: string, ctx: NotifyCtx): void { + const originalBase = this.s.originalBasePath; + if (!originalBase) { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + mode: "worktree", + skipped: true, + reason: "missing-original-base", + }); + return; + } + + try { + const { synced } = this.deps.syncWorktreeStateBack( + originalBase, + this.s.basePath, + milestoneId, + ); + if (synced.length > 0) { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + phase: "reverse-sync", + synced: synced.length, + }); + } + + const roadmapPath = this.deps.resolveMilestoneFile( + originalBase, + milestoneId, + "ROADMAP", + ); + + if (roadmapPath) { + const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8"); + const mergeResult = this.deps.mergeMilestoneToMain( + originalBase, + milestoneId, + roadmapContent, + ); + ctx.notify( + `Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, + "info", + ); + } else { + // No roadmap — fall back to bare teardown + this.deps.teardownAutoWorktree(originalBase, milestoneId); + ctx.notify( + `Exited worktree for ${milestoneId} (no roadmap for merge).`, + "info", + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + result: "error", + error: msg, + fallback: "chdir-to-project-root", + }); + ctx.notify(`Milestone merge failed: ${msg}`, "warning"); + + // Error recovery: always restore to project root + if (originalBase) { + try { + process.chdir(originalBase); + } catch { + /* best-effort */ + } + } + } + + // Always restore basePath and rebuild — whether merge succeeded or failed + this.restoreToProjectRoot(); + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + result: "done", + basePath: this.s.basePath, + }); + } + + /** Branch-mode merge: check current branch, merge if on milestone branch. */ + private _mergeBranchMode(milestoneId: string, ctx: NotifyCtx): void { + try { + const currentBranch = this.deps.getCurrentBranch(this.s.basePath); + const milestoneBranch = this.deps.autoWorktreeBranch(milestoneId); + + if (currentBranch !== milestoneBranch) { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + mode: "branch", + skipped: true, + reason: "not-on-milestone-branch", + currentBranch, + milestoneBranch, + }); + return; + } + + const roadmapPath = this.deps.resolveMilestoneFile( + this.s.basePath, + milestoneId, + "ROADMAP", + ); + if (!roadmapPath) { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + mode: "branch", + skipped: true, + reason: "no-roadmap", + }); + return; + } + + const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8"); + const mergeResult = this.deps.mergeMilestoneToMain( + this.s.basePath, + milestoneId, + roadmapContent, + ); + + // Rebuild GitService after merge (branch HEAD changed) + this.rebuildGitService(); + + ctx.notify( + `Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`, + "info", + ); + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + mode: "branch", + result: "success", + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + mode: "branch", + result: "error", + error: msg, + }); + ctx.notify(`Milestone merge failed (branch mode): ${msg}`, "warning"); + } + } + + // ── Merge and Enter Next ─────────────────────────────────────────────── + + /** + * Milestone transition: merge the current milestone, then enter the next one. + * + * This is the pattern used when the loop detects that the active milestone + * has changed (e.g., current completed, next one is now active). The caller + * is responsible for re-deriving state between the merge and the enter. + */ + mergeAndEnterNext( + currentMilestoneId: string, + nextMilestoneId: string, + ctx: NotifyCtx, + ): void { + debugLog("WorktreeResolver", { + action: "mergeAndEnterNext", + currentMilestoneId, + nextMilestoneId, + }); + this.mergeAndExit(currentMilestoneId, ctx); + this.enterMilestone(nextMilestoneId, ctx); + } +} diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 3a2c28826..7669aa9db 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -12,7 +12,7 @@ * SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches. */ -import { existsSync, lstatSync, readFileSync, utimesSync } from "node:fs"; +import { existsSync, readFileSync, utimesSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js"; @@ -56,13 +56,13 @@ export function setActiveMilestoneId(basePath: string, milestoneId: string | nul * record when the user starts from a different branch (#300). Always a no-op * if on a GSD slice branch. */ -export function captureIntegrationBranch(basePath: string, milestoneId: string): void { +export function captureIntegrationBranch(basePath: string, milestoneId: string, options?: { commitDocs?: boolean }): void { // In a worktree, the base branch is implicit (worktree/). // Writing it to META.json would leave stale metadata after merge back to main. if (detectWorktreeName(basePath)) return; const svc = getService(basePath); const current = svc.getCurrentBranch(); - writeIntegrationBranch(basePath, milestoneId, current); + writeIntegrationBranch(basePath, milestoneId, current, options); } // ─── Pure Utility Functions (unchanged) ──────────────────────────────────── @@ -72,25 +72,6 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string): * Returns null if not inside a GSD worktree (.gsd/worktrees//). */ export function detectWorktreeName(basePath: string): string | null { - // Primary: use git metadata — .git file with gitdir: pointer - const gitPath = join(basePath, ".git"); - try { - const stat = lstatSync(gitPath); - if (stat.isFile()) { - const content = readFileSync(gitPath, "utf-8").trim(); - if (content.startsWith("gitdir:")) { - const gitdir = content.slice(7).trim(); - // Git worktree gitdir format: /.git/worktrees/ - const parts = gitdir.replace(/\\/g, "/").split("/"); - const wtIdx = parts.lastIndexOf("worktrees"); - if (wtIdx !== -1 && wtIdx < parts.length - 1) { - return parts[wtIdx + 1] || null; - } - } - } - } catch { /* fall through */ } - - // Fallback: path-based detection for legacy setups const normalizedPath = basePath.replaceAll("\\", "/"); const marker = "/.gsd/worktrees/"; const idx = normalizedPath.indexOf(marker); @@ -109,32 +90,14 @@ export function detectWorktreeName(basePath: string): string | null { * operate against the real project root, not a worktree subdirectory. */ export function resolveProjectRoot(basePath: string): string { - // Primary: use git metadata to resolve the main worktree root - const gitPath = join(basePath, ".git"); - try { - const stat = lstatSync(gitPath); - if (stat.isFile()) { - const content = readFileSync(gitPath, "utf-8").trim(); - if (content.startsWith("gitdir:")) { - const gitdir = resolve(basePath, content.slice(7).trim()); - // Git worktree gitdir: /.git/worktrees/ - // Walk up to - const parts = gitdir.replace(/\\/g, "/").split("/"); - const wtIdx = parts.lastIndexOf("worktrees"); - if (wtIdx >= 2 && parts[wtIdx - 1] === ".git") { - return parts.slice(0, wtIdx - 1).join("/"); - } - } - } - } catch { /* fall through */ } - - // Fallback: legacy path-based detection const normalizedPath = basePath.replaceAll("\\", "/"); const marker = "/.gsd/worktrees/"; const idx = normalizedPath.indexOf(marker); if (idx === -1) return basePath; - const osSep = basePath.includes("\\") ? "\\" : "/"; - const markerOs = `${osSep}.gsd${osSep}worktrees${osSep}`; + // Return the original path up to the .gsd/ marker (un-normalized) + // Account for potential OS-specific separators + const sep = basePath.includes("\\") ? "\\" : "/"; + const markerOs = `${sep}.gsd${sep}worktrees${sep}`; const idxOs = basePath.indexOf(markerOs); if (idxOs !== -1) return basePath.slice(0, idxOs); return basePath.slice(0, idx); diff --git a/src/tests/file-watcher.test.ts b/src/tests/file-watcher.test.ts index 38040cdc6..e8dc7fd00 100644 --- a/src/tests/file-watcher.test.ts +++ b/src/tests/file-watcher.test.ts @@ -54,11 +54,10 @@ test("settings.json change emits settings-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); - await delay(200); writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true })); // Wait for debounce (300ms) + filesystem propagation - await delay(800); + await delay(600); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.ok(matched.length > 0, "should emit settings-changed event"); @@ -69,10 +68,9 @@ test("auth.json change emits auth-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); - await delay(200); writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" })); - await delay(800); + await delay(600); const matched = bus.events.filter((e) => e.channel === "auth-changed"); assert.ok(matched.length > 0, "should emit auth-changed event"); @@ -83,10 +81,9 @@ test("models.json change emits models-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); - await delay(200); writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" })); - await delay(800); + await delay(600); const matched = bus.events.filter((e) => e.channel === "models-changed"); assert.ok(matched.length > 0, "should emit models-changed event"); @@ -136,7 +133,7 @@ test("debouncing coalesces rapid changes into one event", async () => { for (let i = 0; i < 5; i++) { writeFileSync(join(dir, "settings.json"), JSON.stringify({ i })); } - await delay(800); + await delay(600); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.strictEqual( diff --git a/src/tests/integration/e2e-smoke.test.ts b/src/tests/integration/e2e-smoke.test.ts index 8e7cf6c79..598d2f6f7 100644 --- a/src/tests/integration/e2e-smoke.test.ts +++ b/src/tests/integration/e2e-smoke.test.ts @@ -17,9 +17,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; const projectRoot = process.cwd(); const loaderPath = join(projectRoot, "dist", "loader.js"); @@ -88,6 +89,14 @@ function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); } +function createTempGitRepo(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir, stdio: "pipe" }); + return dir; +} + // --------------------------------------------------------------------------- // 1. gsd --version outputs a semver string and exits 0 // --------------------------------------------------------------------------- @@ -503,6 +512,47 @@ test("gsd headless --timeout with negative value exits 1", async () => { } }); +test("gsd headless query returns JSON from the built CLI", async () => { + const tmpDir = createTempGitRepo("gsd-e2e-query-"); + + try { + mkdirSync(join(tmpDir, ".gsd", "milestones"), { recursive: true }); + + const result = await runGsd(["headless", "query"], 10_000, {}, tmpDir); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assertNoCrashMarkers(combined); + + const snapshot = JSON.parse(result.stdout); + assert.equal(typeof snapshot.state?.phase, "string", "query output should include state.phase"); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("gsd worktree list loads the built worktree CLI without module errors", async () => { + const tmpDir = createTempGitRepo("gsd-e2e-worktree-"); + + try { + const result = await runGsd(["worktree", "list"], 10_000, {}, tmpDir); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assertNoCrashMarkers(combined); + assert.ok( + combined.includes("No worktrees") || combined.includes("Worktrees"), + `expected worktree CLI output, got:\n${combined.slice(0, 500)}`, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + // =========================================================================== // SUBCOMMAND HELP COMPLETENESS // =========================================================================== diff --git a/src/tool-bootstrap.ts b/src/tool-bootstrap.ts index 84c80cce5..4640ae07b 100644 --- a/src/tool-bootstrap.ts +++ b/src/tool-bootstrap.ts @@ -1,4 +1,4 @@ -import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, statSync, symlinkSync, unlinkSync } from "node:fs"; import { delimiter, join } from "node:path"; type ManagedTool = "fd" | "rg"; @@ -40,6 +40,43 @@ function isRegularFile(path: string): boolean { } } +function pathExistsIncludingBrokenSymlink(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + +function isBrokenSymlink(path: string): boolean { + try { + const stat = lstatSync(path); + if (!stat.isSymbolicLink()) return false; + try { + statSync(path); + return false; + } catch { + return true; + } + } catch { + return false; + } +} + +function removeTargetPath(path: string): void { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + unlinkSync(path); + return; + } + rmSync(path, { force: true }); + } catch { + // Path already absent. + } +} + export function resolveToolFromPath(tool: ManagedTool, pathValue: string | undefined = process.env.PATH): string | null { const spec = TOOL_SPECS[tool]; for (const dir of splitPath(pathValue)) { @@ -57,18 +94,27 @@ export function resolveToolFromPath(tool: ManagedTool, pathValue: string | undef function provisionTool(targetDir: string, tool: ManagedTool, sourcePath: string): string { const targetPath = join(targetDir, TOOL_SPECS[tool].targetName); - if (existsSync(targetPath)) return targetPath; + const brokenTarget = isBrokenSymlink(targetPath); + if (pathExistsIncludingBrokenSymlink(targetPath)) { + if (!brokenTarget) return targetPath; + removeTargetPath(targetPath); + } mkdirSync(targetDir, { recursive: true }); - try { - symlinkSync(sourcePath, targetPath); - } catch { - rmSync(targetPath, { force: true }); - copyFileSync(sourcePath, targetPath); - chmodSync(targetPath, 0o755); + if (!brokenTarget) { + try { + symlinkSync(sourcePath, targetPath); + return targetPath; + } catch { + // Fall back to copying below. + } } + removeTargetPath(targetPath); + copyFileSync(sourcePath, targetPath); + chmodSync(targetPath, 0o755); + return targetPath; } @@ -76,7 +122,8 @@ export function ensureManagedTools(targetDir: string, pathValue: string | undefi const provisioned: string[] = []; for (const tool of Object.keys(TOOL_SPECS) as ManagedTool[]) { - if (existsSync(join(targetDir, TOOL_SPECS[tool].targetName))) continue; + const targetPath = join(targetDir, TOOL_SPECS[tool].targetName); + if (pathExistsIncludingBrokenSymlink(targetPath) && !isBrokenSymlink(targetPath)) continue; const sourcePath = resolveToolFromPath(tool, pathValue); if (!sourcePath) continue; provisioned.push(provisionTool(targetDir, tool, sourcePath)); diff --git a/src/worktree-cli.ts b/src/worktree-cli.ts index e31f577c5..0ad371eef 100644 --- a/src/worktree-cli.ts +++ b/src/worktree-cli.ts @@ -21,12 +21,13 @@ import chalk from 'chalk' import { createJiti } from '@mariozechner/jiti' import { fileURLToPath } from 'node:url' -import { dirname, join } from 'node:path' import { generateWorktreeName } from './worktree-name-gen.js' import { existsSync } from 'node:fs' +import { resolveBundledSourceResource } from './bundled-resource-path.js' -const __dirname = dirname(fileURLToPath(import.meta.url)) const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false }) +const gsdExtensionPath = (...segments: string[]) => + resolveBundledSourceResource(import.meta.url, 'extensions', 'gsd', ...segments) // Lazily-loaded extension modules (loaded once on first use via jiti) let _ext: ExtensionModules | null = null @@ -51,11 +52,11 @@ interface ExtensionModules { async function loadExtensionModules(): Promise { if (_ext) return _ext const [wtMgr, autoWt, gitBridge, gitSvc, wt] = await Promise.all([ - jiti.import(join(__dirname, 'resources/extensions/gsd/worktree-manager.ts'), {}) as Promise, - jiti.import(join(__dirname, 'resources/extensions/gsd/auto-worktree.ts'), {}) as Promise, - jiti.import(join(__dirname, 'resources/extensions/gsd/native-git-bridge.ts'), {}) as Promise, - jiti.import(join(__dirname, 'resources/extensions/gsd/git-service.ts'), {}) as Promise, - jiti.import(join(__dirname, 'resources/extensions/gsd/worktree.ts'), {}) as Promise, + jiti.import(gsdExtensionPath('worktree-manager.ts'), {}) as Promise, + jiti.import(gsdExtensionPath('auto-worktree.ts'), {}) as Promise, + jiti.import(gsdExtensionPath('native-git-bridge.ts'), {}) as Promise, + jiti.import(gsdExtensionPath('git-service.ts'), {}) as Promise, + jiti.import(gsdExtensionPath('worktree.ts'), {}) as Promise, ]) _ext = { createWorktree: wtMgr.createWorktree,