feat: multi-milestone readiness flow with per-milestone discussion gate (#377)
This commit is contained in:
parent
a4a909aaf5
commit
8a64ac054c
11 changed files with 970 additions and 11 deletions
|
|
@ -845,13 +845,15 @@ async function showStepWizard(
|
|||
/**
|
||||
* Describe what the next unit will be, based on current state.
|
||||
*/
|
||||
function describeNextUnit(state: GSDState): { label: string; description: string } {
|
||||
export function describeNextUnit(state: GSDState): { label: string; description: string } {
|
||||
const sid = state.activeSlice?.id;
|
||||
const sTitle = state.activeSlice?.title;
|
||||
const tid = state.activeTask?.id;
|
||||
const tTitle = state.activeTask?.title;
|
||||
|
||||
switch (state.phase) {
|
||||
case "needs-discussion":
|
||||
return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." };
|
||||
case "pre-planning":
|
||||
return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
|
||||
case "planning":
|
||||
|
|
@ -1528,6 +1530,19 @@ async function dispatchNextUnit(
|
|||
unitType = "reassess-roadmap";
|
||||
unitId = `${mid}/${needsReassess.sliceId}`;
|
||||
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
|
||||
} else if (state.phase === "needs-discussion") {
|
||||
// Draft milestone — pause auto-mode and notify user.
|
||||
// This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion
|
||||
// where the user chose "Needs own discussion". Auto-mode cannot proceed because
|
||||
// the draft is seed material, not a finalized context — planning requires a
|
||||
// dedicated discussion first.
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify(
|
||||
`${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
|
||||
} else if (state.phase === "pre-planning") {
|
||||
// Need roadmap — check if context exists
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "./paths.js";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
|
|
@ -55,6 +55,13 @@ export function checkAutoStartAfterDiscuss(): boolean {
|
|||
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
|
||||
if (!contextFile) return false; // no context yet — keep waiting
|
||||
|
||||
// Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
|
||||
// CONTEXT.md, delete the draft — it's been consumed by the discussion.
|
||||
try {
|
||||
const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
|
||||
if (draftFile) unlinkSync(draftFile);
|
||||
} catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ }
|
||||
|
||||
pendingAutoStart = null;
|
||||
startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
|
||||
return true;
|
||||
|
|
@ -248,7 +255,7 @@ export async function showQueue(
|
|||
* Build a context block describing all existing milestones for the queue prompt.
|
||||
* Gives the LLM enough information to dedup, sequence, and dependency-check.
|
||||
*/
|
||||
async function buildExistingMilestonesContext(
|
||||
export async function buildExistingMilestonesContext(
|
||||
basePath: string,
|
||||
milestoneIds: string[],
|
||||
state: import("./types.js").GSDState,
|
||||
|
|
@ -289,6 +296,15 @@ async function buildExistingMilestonesContext(
|
|||
if (content) {
|
||||
parts.push(`\n**Context:**\n${content.trim()}`);
|
||||
}
|
||||
} else {
|
||||
// No full CONTEXT.md — check for CONTEXT-DRAFT.md (draft seed from prior discussion)
|
||||
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
||||
if (draftFile) {
|
||||
const draftContent = await loadFile(draftFile);
|
||||
if (draftContent) {
|
||||
parts.push(`\n**Draft context available:**\n${draftContent.trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For completed milestones, include the summary if it exists
|
||||
|
|
@ -637,6 +653,62 @@ export async function showSmartEntry(
|
|||
return;
|
||||
}
|
||||
|
||||
// ── Draft milestone — needs discussion before planning ────────────────
|
||||
if (state.phase === "needs-discussion") {
|
||||
const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
|
||||
const draftContent = draftFile ? await loadFile(draftFile) : null;
|
||||
|
||||
const choice = await showNextAction(ctx as any, {
|
||||
title: `GSD — ${milestoneId}: ${milestoneTitle}`,
|
||||
summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
|
||||
actions: [
|
||||
{
|
||||
id: "discuss_draft",
|
||||
label: "Discuss from draft",
|
||||
description: "Continue where the prior discussion left off — seed material is loaded automatically.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "discuss_fresh",
|
||||
label: "Start fresh discussion",
|
||||
description: "Discard the draft and start a new discussion from scratch.",
|
||||
},
|
||||
{
|
||||
id: "skip_milestone",
|
||||
label: "Skip — create new milestone",
|
||||
description: "Leave this milestone as-is and start something new.",
|
||||
},
|
||||
],
|
||||
notYetMessage: "Run /gsd when ready to discuss this milestone.",
|
||||
});
|
||||
|
||||
if (choice === "discuss_draft") {
|
||||
const basePrompt = loadPrompt("guided-discuss-milestone", {
|
||||
milestoneId, milestoneTitle,
|
||||
});
|
||||
const seed = draftContent
|
||||
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
|
||||
: basePrompt;
|
||||
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
||||
dispatchWorkflow(pi, seed, "gsd-discuss");
|
||||
} else if (choice === "discuss_fresh") {
|
||||
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
||||
dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
||||
milestoneId, milestoneTitle,
|
||||
}), "gsd-discuss");
|
||||
} else if (choice === "skip_milestone") {
|
||||
const milestoneIds = findMilestoneIds(basePath);
|
||||
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
||||
const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
|
||||
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
||||
dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
||||
`New milestone ${nextId}.`,
|
||||
basePath
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── No active slice ──────────────────────────────────────────────────
|
||||
if (!state.activeSlice) {
|
||||
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
||||
|
|
|
|||
|
|
@ -201,13 +201,34 @@ After writing the files and committing, say exactly: "Milestone {{milestoneId}}
|
|||
|
||||
### Multi-Milestone
|
||||
|
||||
Once the user confirms the milestone split, in a single pass:
|
||||
Once the user confirms the milestone split:
|
||||
|
||||
#### Phase 1: Shared artifacts
|
||||
|
||||
1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` for each milestone
|
||||
2. Write `.gsd/PROJECT.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/project.md` first.
|
||||
3. Write `.gsd/REQUIREMENTS.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/requirements.md` first. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet.
|
||||
4. Write a `CONTEXT.md` for **every** milestone — capture the intent, scope, risks, constraints, user-visible outcome, completion class, final integrated acceptance, and relevant requirements for each. Each future milestone's CONTEXT.md should be rich enough that a planning agent encountering it fresh — with no memory of this conversation — can understand the intent, constraints, dependencies, what this milestone unlocks, and what "done" looks like.
|
||||
5. Write a `ROADMAP.md` for **only the first milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done.
|
||||
6. Seed `.gsd/DECISIONS.md`.
|
||||
4. Seed `.gsd/DECISIONS.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/decisions.md` first.
|
||||
|
||||
#### Phase 2: Primary milestone
|
||||
|
||||
5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth).
|
||||
6. Write a `ROADMAP.md` for **only the primary milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done.
|
||||
|
||||
#### Phase 3: Sequential readiness gate for remaining milestones
|
||||
|
||||
For each remaining milestone **one at a time, in sequence**, use `ask_user_questions` to assess readiness. Present three options:
|
||||
|
||||
- **"Discuss now"** — The user wants to conduct a focused discussion for this milestone in the current session, while the context from the broader discussion is still fresh. Proceed with a focused discussion for this milestone (reflection → investigation → questioning → depth verification). When the discussion concludes, write a full `CONTEXT.md`. Then move to the gate for the next milestone.
|
||||
- **"Write draft for later"** — This milestone has seed material from the current conversation but needs its own dedicated discussion in a future session. Write a `CONTEXT-DRAFT.md` capturing the seed material (what was discussed, key ideas, provisional scope, open questions). Mark it clearly as a draft, not a finalized context. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user: "M00x has draft context — needs discussion. Run /gsd." The `/gsd` wizard shows a "Discuss from draft" option that seeds the new discussion with this draft, so nothing from the current conversation is lost. After the dedicated discussion produces a full CONTEXT.md, the draft file is automatically deleted.
|
||||
- **"Just queue it"** — This milestone is identified but intentionally left without context. No context file is written — the directory already exists from Phase 1. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user to run /gsd. The wizard starts a full discussion from scratch.
|
||||
|
||||
**Why sequential, not batch:** After writing the primary milestone's context and roadmap, the agent still has context window capacity. Asking one milestone at a time lets the user decide per-milestone whether to invest that remaining capacity in a focused discussion now, or defer to a future session. A batch question ("Ready/Draft/Queue for M002, M003, M004?") forces the user to decide everything upfront without knowing how much session capacity remains.
|
||||
|
||||
Each context file (full or draft) should be rich enough that a future agent encountering it fresh — with no memory of this conversation — can understand the intent, constraints, dependencies, what this milestone unlocks, and what "done" looks like.
|
||||
|
||||
#### Phase 4: Finalize
|
||||
|
||||
7. Update `.gsd/STATE.md`
|
||||
8. Commit: `docs: project plan — N milestones` (replace N with the actual milestone count)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
{{preamble}}
|
||||
|
||||
## Draft Awareness
|
||||
|
||||
Drafts are milestones that were identified during a prior multi-milestone discussion where the user chose "Needs own discussion" instead of "Ready for auto-planning." A `CONTEXT-DRAFT.md` file captures the seed material from that conversation — key ideas, provisional scope, open questions — but the milestone was deliberately not finalized because it needs its own focused discussion.
|
||||
|
||||
Before asking "What do you want to add?", check the existing milestones context below. If any milestone is marked **"Draft context available"**, surface these drafts to the user first:
|
||||
|
||||
1. Tell the user which milestones have draft contexts and briefly summarize what each draft contains (read the draft file).
|
||||
2. Use `ask_user_questions` to ask per-draft milestone:
|
||||
- **"Discuss now"** — Treat this draft as the primary topic. Read the draft content, use it as seed material, and conduct a focused discussion following the standard discussion flow (reflection → investigation → questioning → depth verification → requirements → roadmap). After the discussion, write the full CONTEXT.md and delete the `CONTEXT-DRAFT.md` file. The milestone is then ready for auto-planning.
|
||||
- **"Leave for later"** — Keep the draft as-is. The user will discuss it in a future session. Auto-mode will continue to pause when it reaches this milestone.
|
||||
3. Handle all draft discussions before proceeding to new queue work.
|
||||
4. If no drafts exist in the context, skip this section entirely and proceed to "What do you want to add?"
|
||||
|
||||
Say exactly: "What do you want to add?" — nothing else. Wait for the user's answer.
|
||||
|
||||
## Discussion Phase
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|||
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
||||
if (summaryFile) continue; // completed milestone, skip
|
||||
return mid; // No roadmap and no summary — milestone is incomplete
|
||||
// Note: draft-awareness (CONTEXT-DRAFT.md) is handled in deriveState(), not here.
|
||||
// A draft milestone is still "active" — this function only determines which milestone is current.
|
||||
}
|
||||
const roadmap = parseRoadmap(content);
|
||||
if (!isMilestoneComplete(roadmap)) return mid;
|
||||
|
|
@ -120,6 +122,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|||
let activeMilestone: ActiveRef | null = null;
|
||||
let activeRoadmap: Roadmap | null = null;
|
||||
let activeMilestoneFound = false;
|
||||
let activeMilestoneHasDraft = false;
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
|
|
@ -138,6 +141,13 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
// No roadmap and no summary — treat as incomplete/active
|
||||
if (!activeMilestoneFound) {
|
||||
// Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones.
|
||||
// A draft seed means the milestone has discussion material but no full context yet.
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
if (!contextFile) {
|
||||
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
||||
if (draftFile) activeMilestoneHasDraft = true;
|
||||
}
|
||||
activeMilestone = { id: mid, title: mid };
|
||||
activeMilestoneFound = true;
|
||||
registry.push({ id: mid, title: mid, status: 'active' });
|
||||
|
|
@ -235,15 +245,21 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
|
||||
if (!activeRoadmap) {
|
||||
// Active milestone exists but has no roadmap yet — needs planning
|
||||
// Active milestone exists but has no roadmap yet.
|
||||
// If a CONTEXT-DRAFT.md seed exists, it needs discussion before planning.
|
||||
// Otherwise, it's a blank milestone ready for initial planning.
|
||||
const phase = activeMilestoneHasDraft ? 'needs-discussion' as const : 'pre-planning' as const;
|
||||
const nextAction = activeMilestoneHasDraft
|
||||
? `Discuss draft context for milestone ${activeMilestone.id}.`
|
||||
: `Plan milestone ${activeMilestone.id}.`;
|
||||
return {
|
||||
activeMilestone,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
phase,
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: `Plan milestone ${activeMilestone.id}.`,
|
||||
nextAction,
|
||||
registry,
|
||||
requirements,
|
||||
progress: {
|
||||
|
|
|
|||
109
src/resources/extensions/gsd/tests/auto-draft-pause.test.ts
Normal file
109
src/resources/extensions/gsd/tests/auto-draft-pause.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describeNextUnit } from "../auto.js";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test describeNextUnit with 'needs-discussion' phase ──────────────────
|
||||
|
||||
const ndState = {
|
||||
phase: "needs-discussion" as const,
|
||||
activeMilestone: { id: "M007", title: "Future Milestone" },
|
||||
activeSlice: undefined,
|
||||
activeTask: undefined,
|
||||
milestoneRegistry: [],
|
||||
nextAction: "",
|
||||
};
|
||||
|
||||
const ndResult = describeNextUnit(ndState as any);
|
||||
assert(
|
||||
ndResult.label !== "Continue",
|
||||
`needs-discussion label should not be default "Continue", got: "${ndResult.label}"`,
|
||||
);
|
||||
assert(
|
||||
ndResult.label.toLowerCase().includes("draft") || ndResult.label.toLowerCase().includes("discuss"),
|
||||
`needs-discussion label should mention "draft" or "discuss", got: "${ndResult.label}"`,
|
||||
);
|
||||
assert(
|
||||
ndResult.description.toLowerCase().includes("discussion") || ndResult.description.toLowerCase().includes("draft"),
|
||||
`needs-discussion description should mention "discussion" or "draft", got: "${ndResult.description}"`,
|
||||
);
|
||||
|
||||
// ─── Backward compatibility: pre-planning still works ──────────────────────
|
||||
|
||||
const ppState = {
|
||||
phase: "pre-planning" as const,
|
||||
activeMilestone: { id: "M001", title: "Test" },
|
||||
activeSlice: undefined,
|
||||
activeTask: undefined,
|
||||
milestoneRegistry: [],
|
||||
nextAction: "",
|
||||
};
|
||||
|
||||
const ppResult = describeNextUnit(ppState as any);
|
||||
assert(
|
||||
ppResult.label === "Research & plan milestone",
|
||||
`pre-planning label should be "Research & plan milestone", got: "${ppResult.label}"`,
|
||||
);
|
||||
|
||||
// ─── Backward compatibility: executing still works ──────────────────────────
|
||||
|
||||
const exState = {
|
||||
phase: "executing" as const,
|
||||
activeMilestone: { id: "M001", title: "Test" },
|
||||
activeSlice: { id: "S01", title: "Test Slice" },
|
||||
activeTask: { id: "T01", title: "Test Task" },
|
||||
milestoneRegistry: [],
|
||||
nextAction: "",
|
||||
};
|
||||
|
||||
const exResult = describeNextUnit(exState as any);
|
||||
assert(
|
||||
exResult.label.includes("T01"),
|
||||
`executing label should include task ID, got: "${exResult.label}"`,
|
||||
);
|
||||
|
||||
// ─── Static verification: needs-discussion in dispatchNextUnit ──────────────
|
||||
|
||||
const autoSource = readFileSync(
|
||||
join(import.meta.dirname, "..", "auto.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Check describeNextUnit has the case
|
||||
const hasDescribeCase = autoSource.includes('case "needs-discussion"');
|
||||
assert(hasDescribeCase, "auto.ts describeNextUnit should have 'needs-discussion' case");
|
||||
|
||||
// Check dispatchNextUnit has the branch
|
||||
const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"');
|
||||
assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch");
|
||||
|
||||
// Check the dispatch branch calls stopAuto
|
||||
const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"');
|
||||
const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600);
|
||||
assert(
|
||||
nextChunk.includes("stopAuto"),
|
||||
"needs-discussion dispatch branch should call stopAuto",
|
||||
);
|
||||
|
||||
// Check notification includes /gsd guidance
|
||||
assert(
|
||||
nextChunk.includes("/gsd"),
|
||||
"needs-discussion notification should tell user to run /gsd",
|
||||
);
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\nauto-draft-pause: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
299
src/resources/extensions/gsd/tests/derive-state-draft.test.ts
Normal file
299
src/resources/extensions/gsd/tests/derive-state-draft.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveState } from '../state.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-draft-test-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function writeContextDraft(base: string, mid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), content);
|
||||
}
|
||||
|
||||
function writeContext(base: string, mid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-CONTEXT.md`), content);
|
||||
}
|
||||
|
||||
function writeRoadmap(base: string, mid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-ROADMAP.md`), content);
|
||||
}
|
||||
|
||||
function writePlan(base: string, mid: string, sid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
|
||||
mkdirSync(join(dir, 'tasks'), { recursive: true });
|
||||
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
|
||||
}
|
||||
|
||||
function writeMilestoneSummary(base: string, mid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test Groups
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── Test 1: CONTEXT-DRAFT.md only → needs-discussion ──────────────────
|
||||
console.log('\n=== CONTEXT-DRAFT.md only → needs-discussion ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001 directory with only CONTEXT-DRAFT.md — no CONTEXT.md, no ROADMAP.md
|
||||
writeContextDraft(base, 'M001', '# Draft Context\n\nSeed discussion material.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion');
|
||||
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
|
||||
assertEq(state.activeSlice, null, 'activeSlice is null');
|
||||
assertEq(state.activeTask, null, 'activeTask is null');
|
||||
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
||||
assertEq(
|
||||
state.nextAction.includes('Discuss'),
|
||||
true,
|
||||
'nextAction mentions Discuss'
|
||||
);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 2: CONTEXT.md only → pre-planning (unchanged) ───────────────
|
||||
console.log('\n=== CONTEXT.md only → pre-planning (unchanged) ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001 directory with CONTEXT.md but no ROADMAP.md
|
||||
writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady for planning.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'pre-planning', 'phase is pre-planning with CONTEXT.md');
|
||||
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
|
||||
assertEq(state.activeSlice, null, 'activeSlice is null');
|
||||
assertEq(state.activeTask, null, 'activeTask is null');
|
||||
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 3: Both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ──────
|
||||
console.log('\n=== both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001 has both files — CONTEXT.md should take precedence
|
||||
writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady.');
|
||||
writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'pre-planning', 'phase is pre-planning when CONTEXT.md exists');
|
||||
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
|
||||
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 4: M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ──
|
||||
console.log('\n=== M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001: complete (roadmap with all slices done + summary)
|
||||
writeRoadmap(base, 'M001', `# M001: First Milestone
|
||||
|
||||
**Vision:** Already done.
|
||||
|
||||
## Slices
|
||||
|
||||
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
||||
> After this: Done.
|
||||
`);
|
||||
writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone complete.');
|
||||
|
||||
// M002: only CONTEXT-DRAFT.md
|
||||
writeContextDraft(base, 'M002', '# Draft for M002\n\nSeed material.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002');
|
||||
assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone id is M002');
|
||||
assertEq(state.activeSlice, null, 'activeSlice is null');
|
||||
assertEq(state.registry.length, 2, 'registry has 2 entries');
|
||||
assertEq(state.registry[0]?.status, 'complete', 'M001 is complete');
|
||||
assertEq(state.registry[1]?.status, 'active', 'M002 is active');
|
||||
assertEq(state.progress?.milestones?.done, 1, 'milestones done = 1');
|
||||
assertEq(state.progress?.milestones?.total, 2, 'milestones total = 2');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 5: Multi-milestone: M001 complete, M002 CONTEXT-DRAFT, M003 pending ──
|
||||
console.log('\n=== multi-milestone: M001 complete, M002 draft, M003 pending ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001: complete
|
||||
writeRoadmap(base, 'M001', `# M001: First
|
||||
|
||||
**Vision:** Done.
|
||||
|
||||
## Slices
|
||||
|
||||
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
||||
> After this: Done.
|
||||
`);
|
||||
writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nComplete.');
|
||||
|
||||
// M002: draft only — should become active with needs-discussion
|
||||
writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.');
|
||||
|
||||
// M003: blank milestone directory — should be pending
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true });
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002');
|
||||
assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone is M002');
|
||||
assertEq(state.registry.length, 3, 'registry has 3 entries');
|
||||
assertEq(state.registry[0]?.status, 'complete', 'M001 is complete');
|
||||
assertEq(state.registry[1]?.status, 'active', 'M002 is active');
|
||||
assertEq(state.registry[2]?.status, 'pending', 'M003 is pending');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 6: Milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence ──
|
||||
console.log('\n=== milestone with ROADMAP + CONTEXT-DRAFT → normal execution ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001 has ROADMAP.md (active slice, incomplete tasks) and CONTEXT-DRAFT.md
|
||||
// The ROADMAP should take precedence — we're past the draft phase
|
||||
writeRoadmap(base, 'M001', `# M001: Active Milestone
|
||||
|
||||
**Vision:** In progress.
|
||||
|
||||
## Slices
|
||||
|
||||
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
|
||||
> After this: First slice done.
|
||||
`);
|
||||
writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored — roadmap exists.');
|
||||
|
||||
// Add a plan so it goes to executing phase
|
||||
writePlan(base, 'M001', 'S01', `# S01: First Slice
|
||||
|
||||
**Goal:** Do something.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T01: First Task** \`est:30m\`
|
||||
`);
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'executing', 'phase is executing (ROADMAP takes precedence over CONTEXT-DRAFT)');
|
||||
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
|
||||
assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01');
|
||||
assertEq(state.activeTask?.id, 'T01', 'activeTask is T01');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 7: Empty milestone dir (no files at all) → pre-planning ─────
|
||||
console.log('\n=== empty milestone dir (no files) → pre-planning ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001: just a directory, no files at all
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'pre-planning', 'phase is pre-planning for blank milestone');
|
||||
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
|
||||
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 8: CONTEXT-DRAFT on non-first active milestone ──────────────
|
||||
// M001 has no summary and no roadmap (active), M002 has CONTEXT-DRAFT
|
||||
// M001 should be active (pre-planning), M002 should be pending
|
||||
console.log('\n=== CONTEXT-DRAFT on non-active milestone → pending ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001: blank (no roadmap, no summary) → becomes active first
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
|
||||
|
||||
// M002: has CONTEXT-DRAFT but isn't active (M001 is first)
|
||||
writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
|
||||
assertEq(state.phase, 'pre-planning', 'phase is pre-planning (M001 is active, not M002)');
|
||||
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
|
||||
assertEq(state.registry[0]?.status, 'active', 'M001 is active');
|
||||
assertEq(state.registry[1]?.status, 'pending', 'M002 is pending');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Summary
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(`Draft-aware state derivation tests: ${passed} passed, ${failed} failed`);
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Test suite error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
165
src/resources/extensions/gsd/tests/draft-promotion.test.ts
Normal file
165
src/resources/extensions/gsd/tests/draft-promotion.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { deriveState } from "../state.js";
|
||||
import { resolveMilestoneFile } from "../paths.js";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Full state transition: needs-discussion → pre-planning ─────────────
|
||||
|
||||
console.log("=== Draft promotion: full state transition ===");
|
||||
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-test-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
|
||||
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
||||
|
||||
// Step 1: Create CONTEXT-DRAFT.md only → needs-discussion
|
||||
const draftPath = join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
|
||||
writeFileSync(draftPath, "# M001: Draft\n\nSeed material.\n");
|
||||
|
||||
const state1 = await deriveState(tmpBase);
|
||||
assert(
|
||||
state1.phase === "needs-discussion",
|
||||
`draft-only should be 'needs-discussion', got: "${state1.phase}"`,
|
||||
);
|
||||
|
||||
// Step 2: Write CONTEXT.md (simulating discussion output) → pre-planning
|
||||
const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md");
|
||||
writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n");
|
||||
|
||||
const state2 = await deriveState(tmpBase);
|
||||
assert(
|
||||
state2.phase === "pre-planning",
|
||||
`after CONTEXT.md written, should be 'pre-planning', got: "${state2.phase}"`,
|
||||
);
|
||||
|
||||
// Step 3: Simulate draft cleanup (what checkAutoStartAfterDiscuss does)
|
||||
const resolvedDraft = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
|
||||
assert(
|
||||
resolvedDraft !== null && resolvedDraft !== undefined,
|
||||
"CONTEXT-DRAFT.md should still exist before cleanup",
|
||||
);
|
||||
|
||||
// Delete the draft (simulating the cleanup in checkAutoStartAfterDiscuss)
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
try {
|
||||
if (resolvedDraft) unlinkSync(resolvedDraft);
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
assert(
|
||||
!existsSync(draftPath),
|
||||
"CONTEXT-DRAFT.md should be deleted after promotion cleanup",
|
||||
);
|
||||
|
||||
// Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists)
|
||||
const state3 = await deriveState(tmpBase);
|
||||
assert(
|
||||
state3.phase === "pre-planning",
|
||||
`after cleanup, should still be 'pre-planning', got: "${state3.phase}"`,
|
||||
);
|
||||
|
||||
// ─── No-draft case: cleanup is a no-op ──────────────────────────────────
|
||||
|
||||
console.log("=== No-draft cleanup: no-op ===");
|
||||
|
||||
const tmpBase2 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-noop-"));
|
||||
const gsd2 = join(tmpBase2, ".gsd");
|
||||
|
||||
mkdirSync(join(gsd2, "milestones", "M001"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd2, "milestones", "M001", "M001-CONTEXT.md"),
|
||||
"# M001: Normal\n\nStandard discussion output.\n",
|
||||
);
|
||||
|
||||
// No CONTEXT-DRAFT.md exists — cleanup should be a no-op
|
||||
const noDraft = resolveMilestoneFile(tmpBase2, "M001", "CONTEXT-DRAFT");
|
||||
assert(
|
||||
noDraft === null || noDraft === undefined,
|
||||
"no CONTEXT-DRAFT.md should exist for standard discussion milestone",
|
||||
);
|
||||
|
||||
// deriveState should return pre-planning normally
|
||||
const state4 = await deriveState(tmpBase2);
|
||||
assert(
|
||||
state4.phase === "pre-planning",
|
||||
`standard discussion milestone should be 'pre-planning', got: "${state4.phase}"`,
|
||||
);
|
||||
|
||||
// ─── Both files exist → CONTEXT.md wins, draft cleanup works ───────────
|
||||
|
||||
console.log("=== Both files: CONTEXT wins, draft cleanable ===");
|
||||
|
||||
const tmpBase3 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-both-"));
|
||||
const gsd3 = join(tmpBase3, ".gsd");
|
||||
|
||||
mkdirSync(join(gsd3, "milestones", "M001"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd3, "milestones", "M001", "M001-CONTEXT.md"),
|
||||
"# M001: Full\n\nFull context.\n",
|
||||
);
|
||||
const bothDraftPath = join(gsd3, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
|
||||
writeFileSync(bothDraftPath, "# M001: Draft\n\nStale draft.\n");
|
||||
|
||||
const state5 = await deriveState(tmpBase3);
|
||||
assert(
|
||||
state5.phase === "pre-planning",
|
||||
`both files: CONTEXT.md wins, should be 'pre-planning', got: "${state5.phase}"`,
|
||||
);
|
||||
|
||||
// Cleanup the stale draft
|
||||
const bothDraft = resolveMilestoneFile(tmpBase3, "M001", "CONTEXT-DRAFT");
|
||||
try {
|
||||
if (bothDraft) unlinkSync(bothDraft);
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
assert(
|
||||
!existsSync(bothDraftPath),
|
||||
"stale CONTEXT-DRAFT.md should be deleted in both-files case",
|
||||
);
|
||||
|
||||
// ─── Static: guided-flow.ts has cleanup code ───────────────────────────
|
||||
|
||||
console.log("=== Static: cleanup code in guided-flow.ts ===");
|
||||
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const guidedFlowSource = readFileSync(
|
||||
join(import.meta.dirname, "..", "guided-flow.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss");
|
||||
const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200);
|
||||
|
||||
assert(
|
||||
checkFnChunk.includes("CONTEXT-DRAFT"),
|
||||
"checkAutoStartAfterDiscuss should reference CONTEXT-DRAFT for cleanup",
|
||||
);
|
||||
|
||||
assert(
|
||||
checkFnChunk.includes("unlinkSync"),
|
||||
"checkAutoStartAfterDiscuss should use unlinkSync to delete the draft",
|
||||
);
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────
|
||||
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
rmSync(tmpBase2, { recursive: true, force: true });
|
||||
rmSync(tmpBase3, { recursive: true, force: true });
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\ndraft-promotion: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
126
src/resources/extensions/gsd/tests/queue-draft-detection.test.ts
Normal file
126
src/resources/extensions/gsd/tests/queue-draft-detection.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { deriveState } from "../state.js";
|
||||
import { buildExistingMilestonesContext } from "../guided-flow.js";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixture setup ──────────────────────────────────────────────────────
|
||||
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
|
||||
// M001: has only CONTEXT-DRAFT.md (draft milestone)
|
||||
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
|
||||
"# M001: Draft Milestone\n\nSeed material from prior discussion.\n",
|
||||
);
|
||||
|
||||
// M002: has full CONTEXT.md (ready milestone)
|
||||
mkdirSync(join(gsd, "milestones", "M002"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M002", "M002-CONTEXT.md"),
|
||||
"# M002: Ready Milestone\n\nFull context from deep discussion.\n",
|
||||
);
|
||||
|
||||
// M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins)
|
||||
mkdirSync(join(gsd, "milestones", "M003"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M003", "M003-CONTEXT.md"),
|
||||
"# M003: Full Context\n\nThis is the real context.\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"),
|
||||
"# M003: Draft\n\nThis should be ignored.\n",
|
||||
);
|
||||
|
||||
// M004: has neither (empty milestone dir)
|
||||
mkdirSync(join(gsd, "milestones", "M004"), { recursive: true });
|
||||
|
||||
// ─── Build context ──────────────────────────────────────────────────────
|
||||
|
||||
const state = await deriveState(tmpBase);
|
||||
const milestoneIds = ["M001", "M002", "M003", "M004"];
|
||||
const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state);
|
||||
|
||||
// ─── Test: draft-only milestone includes "Draft context available" ──────
|
||||
|
||||
assert(
|
||||
context.includes("Draft context available"),
|
||||
"M001 (draft-only) should include 'Draft context available' label",
|
||||
);
|
||||
|
||||
assert(
|
||||
context.includes("Seed material from prior discussion"),
|
||||
"M001 draft content should be included in context output",
|
||||
);
|
||||
|
||||
// ─── Test: full-context milestone uses "Context:" label ────────────────
|
||||
|
||||
assert(
|
||||
context.includes("**Context:**"),
|
||||
"M002 (full context) should use 'Context:' label",
|
||||
);
|
||||
|
||||
assert(
|
||||
context.includes("Full context from deep discussion"),
|
||||
"M002 context content should be included",
|
||||
);
|
||||
|
||||
// ─── Test: both files → CONTEXT.md wins, no draft label ────────────────
|
||||
|
||||
// Find M003's section and check it has Context: but not Draft
|
||||
const m003Idx = context.indexOf("M003:");
|
||||
const m003Section = context.slice(m003Idx, m003Idx + 500);
|
||||
|
||||
assert(
|
||||
m003Section.includes("**Context:**"),
|
||||
"M003 (both files) should use 'Context:' label (CONTEXT.md wins)",
|
||||
);
|
||||
|
||||
assert(
|
||||
!m003Section.includes("Draft context available"),
|
||||
"M003 (both files) should NOT show draft label — CONTEXT.md takes precedence",
|
||||
);
|
||||
|
||||
assert(
|
||||
m003Section.includes("This is the real context"),
|
||||
"M003 should show CONTEXT.md content, not draft content",
|
||||
);
|
||||
|
||||
// ─── Test: neither file → no context section ───────────────────────────
|
||||
|
||||
const m004Idx = context.indexOf("M004:");
|
||||
const m004Section = context.slice(m004Idx, m004Idx + 500);
|
||||
|
||||
assert(
|
||||
!m004Section.includes("**Context:**"),
|
||||
"M004 (neither file) should not have Context: label",
|
||||
);
|
||||
|
||||
assert(
|
||||
!m004Section.includes("Draft context available"),
|
||||
"M004 (neither file) should not have Draft label",
|
||||
);
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────
|
||||
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\nqueue-draft-detection: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
123
src/resources/extensions/gsd/tests/smart-entry-draft.test.ts
Normal file
123
src/resources/extensions/gsd/tests/smart-entry-draft.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { deriveState } from "../state.js";
|
||||
import { resolveMilestoneFile } from "../paths.js";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixture: milestone with only CONTEXT-DRAFT.md ──────────────────────
|
||||
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-smart-entry-draft-test-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
|
||||
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
||||
|
||||
const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`;
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
|
||||
draftContent,
|
||||
);
|
||||
|
||||
// ─── Test: deriveState returns 'needs-discussion' for draft-only milestone ───
|
||||
|
||||
const state = await deriveState(tmpBase);
|
||||
|
||||
assert(
|
||||
state.phase === "needs-discussion",
|
||||
`phase should be 'needs-discussion' for draft-only milestone, got: "${state.phase}"`,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.activeMilestone?.id === "M001",
|
||||
`active milestone should be M001, got: "${state.activeMilestone?.id}"`,
|
||||
);
|
||||
|
||||
// ─── Test: resolveMilestoneFile resolves CONTEXT-DRAFT ─────────────────────
|
||||
|
||||
const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
|
||||
|
||||
assert(
|
||||
draftFile !== null && draftFile !== undefined,
|
||||
`resolveMilestoneFile should resolve CONTEXT-DRAFT, got: ${draftFile}`,
|
||||
);
|
||||
|
||||
assert(
|
||||
draftFile!.endsWith("M001-CONTEXT-DRAFT.md"),
|
||||
`resolved path should end with M001-CONTEXT-DRAFT.md, got: "${draftFile}"`,
|
||||
);
|
||||
|
||||
// ─── Test: CONTEXT.md is NOT resolved (only draft exists) ──────────────────
|
||||
|
||||
const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT");
|
||||
|
||||
assert(
|
||||
contextFile === null || contextFile === undefined,
|
||||
`resolveMilestoneFile should NOT resolve CONTEXT when only CONTEXT-DRAFT exists, got: "${contextFile}"`,
|
||||
);
|
||||
|
||||
// ─── Static: guided-flow.ts has 'needs-discussion' branch ─────────────────
|
||||
|
||||
const guidedFlowSource = readFileSync(
|
||||
join(import.meta.dirname, "..", "guided-flow.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
assert(
|
||||
guidedFlowSource.includes('state.phase === "needs-discussion"'),
|
||||
"guided-flow.ts should have 'needs-discussion' phase check in showSmartEntry",
|
||||
);
|
||||
|
||||
// Check the branch has draft-aware menu options
|
||||
const branchIdx = guidedFlowSource.indexOf('state.phase === "needs-discussion"');
|
||||
const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 3000);
|
||||
|
||||
assert(
|
||||
branchChunk.includes("discuss_draft"),
|
||||
"needs-discussion branch should have 'discuss_draft' option",
|
||||
);
|
||||
|
||||
assert(
|
||||
branchChunk.includes("discuss_fresh"),
|
||||
"needs-discussion branch should have 'discuss_fresh' option",
|
||||
);
|
||||
|
||||
assert(
|
||||
branchChunk.includes("skip_milestone"),
|
||||
"needs-discussion branch should have 'skip_milestone' option",
|
||||
);
|
||||
|
||||
assert(
|
||||
branchChunk.includes("CONTEXT-DRAFT"),
|
||||
"needs-discussion branch should load CONTEXT-DRAFT via resolveMilestoneFile",
|
||||
);
|
||||
|
||||
assert(
|
||||
branchChunk.includes("Draft Seed") || branchChunk.includes("draftContent"),
|
||||
"discuss_draft path should include draft content as seed in the dispatched prompt",
|
||||
);
|
||||
|
||||
assert(
|
||||
branchChunk.includes("return"),
|
||||
"needs-discussion branch should return early (not fall through to generic no-roadmap menu)",
|
||||
);
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────
|
||||
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\nsmart-entry-draft: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
// ─── Enums & Literal Unions ────────────────────────────────────────────────
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
export type Phase = 'pre-planning' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
|
||||
export type Phase = 'pre-planning' | 'needs-discussion' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
|
||||
export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted';
|
||||
|
||||
// ─── Roadmap (Milestone-level) ─────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue