From 3e4be6babfe963ebe5ce2a1cf119503c161302b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sat, 21 Mar 2026 11:40:22 -0600 Subject: [PATCH] fix: detect REPLAN-TRIGGER.md in deriveState for triage-initiated replans (#1798) --- src/resources/extensions/gsd/state.ts | 33 +++++++++ .../extensions/gsd/tests/replan-slice.test.ts | 74 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 2c65cfbbf..40df2d643 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -740,6 +740,39 @@ async function _deriveStateImpl(basePath: string): Promise { // REPLAN.md exists — loop protection: fall through to normal executing } + // ── REPLAN-TRIGGER detection: triage-initiated replan ────────────────── + // Manual `/gsd triage` writes REPLAN-TRIGGER.md when a capture is classified + // as "replan". Detect it here and transition to replanning-slice so the + // dispatch loop picks it up (instead of silently advancing past it). + if (!blockerTaskId) { + const replanTriggerFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER"); + if (replanTriggerFile) { + // Same loop protection: if REPLAN.md already exists, a replan was + // already performed — skip further replanning and continue executing. + const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN"); + if (!replanFile) { + return { + activeMilestone, + activeSlice, + activeTask, + phase: 'replanning-slice', + recentDecisions: [], + blockers: ['Triage replan trigger detected — slice replan required'], + nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`, + + activeWorkspace: undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + } + // Check for interrupted work const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null; diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts index d9e0a9e11..73eddeb92 100644 --- a/src/resources/extensions/gsd/tests/replan-slice.test.ts +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -56,6 +56,12 @@ function writeReplanFile(base: string, mid: string, sid: string, content: string writeFileSync(join(dir, `${sid}-REPLAN.md`), content); } +function writeReplanTrigger(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}-REPLAN-TRIGGER.md`), content); +} + /** Standard roadmap with one slice having no dependencies */ const ROADMAP_ONE_SLICE = `# M001: Test Milestone @@ -535,4 +541,72 @@ console.log('\n=== artifact: verifyExpectedArtifact passes when REPLAN.md exists rmSync(base, { recursive: true, force: true }); } +// ═══════════════════════════════════════════════════════════════════════════ +// REPLAN-TRIGGER.md detection (triage-initiated replan, #1701) +// ═══════════════════════════════════════════════════════════════════════════ + +// (a) REPLAN-TRIGGER.md exists + no REPLAN.md → replanning-slice +console.log('\n=== deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanning-slice (#1701) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + // No blocker in task summary — the trigger comes from triage, not blocker_discovered + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); + writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n'); + + const state = await deriveState(base); + assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when REPLAN-TRIGGER.md exists'); + assertTrue(state.blockers.length > 0, 'blockers array is non-empty for triage replan trigger'); + assertTrue(state.nextAction.includes('Triage replan'), 'nextAction mentions triage replan'); + assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01'); + assertEq(state.activeTask?.id, 'T02', 'activeTask is T02 (next incomplete task)'); + rmSync(base, { recursive: true, force: true }); +} + +// (b) REPLAN-TRIGGER.md + REPLAN.md both exist → executing (loop protection) +console.log('\n=== deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loop protection, #1701) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); + writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n'); + writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.'); + + const state = await deriveState(base); + assertEq(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)'); + assertEq(state.activeTask?.id, 'T02', 'activeTask is T02'); + rmSync(base, { recursive: true, force: true }); +} + +// (c) No REPLAN-TRIGGER.md, no blocker → executing (no false positive) +console.log('\n=== deriveState: no REPLAN-TRIGGER.md, no blocker → executing (#1701) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); + + const state = await deriveState(base); + assertEq(state.phase, 'executing', 'phase is executing when no trigger and no blocker'); + rmSync(base, { recursive: true, force: true }); +} + +// (d) blocker_discovered takes priority over REPLAN-TRIGGER.md +console.log('\n=== deriveState: blocker_discovered takes priority over REPLAN-TRIGGER.md (#1701) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); + writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n'); + + const state = await deriveState(base); + assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice'); + // blocker_discovered path should fire first (blockerTaskId is set, so REPLAN-TRIGGER check is skipped) + assertTrue(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01 (blocker path, not trigger path)'); + rmSync(base, { recursive: true, force: true }); +} + report();