diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 3655281a7..2c65cfbbf 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -333,9 +333,9 @@ async function _deriveStateImpl(basePath: string): Promise { // Check milestone-level dependencies before promoting to active. // Without this, a queued milestone with depends_on in its CONTEXT - // frontmatter would be promoted to active even when its deps are unmet - // (the dep check only existed in the has-roadmap path previously). - const deps = parseContextDependsOn(contextContent); + // or CONTEXT-DRAFT frontmatter would be promoted to active even when + // its deps are unmet. + const deps = parseContextDependsOn(contextContent ?? draftContent); const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); if (depsUnmet) { registry.push({ id: mid, title, status: 'pending', dependsOn: deps }); @@ -397,8 +397,10 @@ async function _deriveStateImpl(basePath: string): Promise { } else if (!activeMilestoneFound) { // Check milestone-level dependencies before promoting to active const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; - const deps = parseContextDependsOn(contextContent); + const draftContent = draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const deps = parseContextDependsOn(contextContent ?? draftContent); const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); if (depsUnmet) { registry.push({ id: mid, title, status: 'pending', dependsOn: deps }); diff --git a/src/resources/extensions/gsd/tests/derive-state-deps.test.ts b/src/resources/extensions/gsd/tests/derive-state-deps.test.ts index 47ec46f9d..db8ac3040 100644 --- a/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state-deps.test.ts @@ -42,6 +42,12 @@ function writeContext(base: string, mid: string, frontmatter: string): void { writeFileSync(join(dir, `${mid}-CONTEXT.md`), `---\n${frontmatter}\n---\n`); } +function writeContextDraft(base: string, mid: string, frontmatter: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), `---\n${frontmatter}\n---\n`); +} + function writeSlicePlan(base: string, mid: string, sid: string, content: string): void { const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); mkdirSync(join(dir, 'tasks'), { recursive: true }); @@ -391,7 +397,79 @@ async function main(): Promise { } } - // ─── Test Group 9: parseContextDependsOn preserves case ─────────────── + // ─── Test Group 9: draft-context-deps ──────────────────────────────── + // M001 is incomplete, M002 has only CONTEXT-DRAFT.md (no CONTEXT.md) with + // depends_on: [M001] → M002 should remain pending, not be promoted to active. + console.log('\n=== draft-context-deps: depends_on read from CONTEXT-DRAFT.md ==='); + { + const base = createFixtureBase(); + try { + // M001: incomplete (one slice, no SUMMARY) + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** First milestone still in progress. + +## Slices + +- [ ] **S01: Incomplete Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeSlicePlan(base, 'M001', 'S01', `# S01: Incomplete Slice + +**Goal:** Test draft dep blocking. +**Demo:** Tests pass. + +## Tasks + +- [ ] **T01: Do work** \`est:15m\` + First task still in progress. +`); + + // M002: only CONTEXT-DRAFT.md (no CONTEXT.md), depends on M001 + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Second milestone blocked by M001 via draft context. + +## Slices + +- [ ] **S01: Blocked Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContextDraft(base, 'M002', 'depends_on: [M001]'); + + const state = await deriveState(base); + + assertEq(state.registry[0]?.status, 'active', 'draft-context-deps: M001 is active'); + assertEq(state.registry[1]?.status, 'pending', 'draft-context-deps: M002 is pending (dep-blocked via draft)'); + assertEq(state.activeMilestone?.id, 'M001', 'draft-context-deps: activeMilestone is M001'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 10: draft-context-deps-no-roadmap ────────────────────── + // Same as above but without roadmaps — milestones discovered from directory only. + console.log('\n=== draft-context-deps-no-roadmap: depends_on from draft without roadmap ==='); + { + const base = createFixtureBase(); + try { + // M001: exists as directory only (no roadmap, no summary) + const m001Dir = join(base, '.gsd', 'milestones', 'M001'); + mkdirSync(m001Dir, { recursive: true }); + + // M002: only CONTEXT-DRAFT.md, depends on M001 + writeContextDraft(base, 'M002', 'depends_on: [M001]'); + + const state = await deriveState(base); + + const m002Entry = state.registry.find(e => e.id === 'M002'); + assertEq(m002Entry?.status, 'pending', 'draft-no-roadmap: M002 is pending (dep-blocked via draft)'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 11: parseContextDependsOn preserves case ────────────── // Direct unit test: verify the parsed dep ID matches the input exactly console.log('\n=== parseContextDependsOn: preserves case of unique IDs ==='); {