gsd-pi 2.10.10 → 2.10.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/loader.js +21 -1
- package/package.json +33 -3
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/src/resources/extensions/gsd/auto.ts +16 -1
- package/src/resources/extensions/gsd/guided-flow.ts +74 -2
- package/src/resources/extensions/gsd/prompts/discuss.md +25 -4
- package/src/resources/extensions/gsd/prompts/queue.md +13 -0
- package/src/resources/extensions/gsd/state.ts +19 -3
- package/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts +109 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +299 -0
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +165 -0
- package/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts +126 -0
- package/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +123 -0
- package/src/resources/extensions/gsd/types.ts +1 -1
- package/src/resources/extensions/voice/index.ts +6 -13
package/dist/loader.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { dirname, resolve, join } from 'path';
|
|
4
|
-
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs';
|
|
5
5
|
import { agentDir, appRoot } from './app-paths.js';
|
|
6
6
|
import { renderLogo } from './logo.js';
|
|
7
7
|
// pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
|
|
@@ -90,5 +90,25 @@ process.env.GSD_BUNDLED_EXTENSION_PATHS = [
|
|
|
90
90
|
// must set it here before any SDK clients are created.
|
|
91
91
|
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
|
92
92
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
93
|
+
// Ensure workspace packages are linked before importing cli.js (which imports @gsd/*).
|
|
94
|
+
// npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.
|
|
95
|
+
const gsdScopeDir = join(gsdNodeModules, '@gsd');
|
|
96
|
+
const packagesDir = join(gsdRoot, 'packages');
|
|
97
|
+
const wsPackages = ['native', 'pi-agent-core', 'pi-ai', 'pi-coding-agent', 'pi-tui'];
|
|
98
|
+
try {
|
|
99
|
+
if (!existsSync(gsdScopeDir))
|
|
100
|
+
mkdirSync(gsdScopeDir, { recursive: true });
|
|
101
|
+
for (const pkg of wsPackages) {
|
|
102
|
+
const target = join(gsdScopeDir, pkg);
|
|
103
|
+
const source = join(packagesDir, pkg);
|
|
104
|
+
if (existsSync(source) && !existsSync(target)) {
|
|
105
|
+
try {
|
|
106
|
+
symlinkSync(source, target, 'junction');
|
|
107
|
+
}
|
|
108
|
+
catch { /* non-fatal */ }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch { /* non-fatal */ }
|
|
93
113
|
// Dynamic import defers ESM evaluation — config.js will see PI_PACKAGE_DIR above
|
|
94
114
|
await import('./cli.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-pi",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.12",
|
|
4
4
|
"description": "GSD — Get Shit Done coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -62,11 +62,40 @@
|
|
|
62
62
|
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1) && npm run build && npm run validate-pack"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
+
"@anthropic-ai/sdk": "^0.73.0",
|
|
66
|
+
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
|
65
67
|
"@clack/prompts": "^1.1.0",
|
|
68
|
+
"@google/genai": "^1.40.0",
|
|
69
|
+
"@mariozechner/jiti": "^2.6.2",
|
|
70
|
+
"@mistralai/mistralai": "1.14.1",
|
|
71
|
+
"@silvia-odwyer/photon-node": "^0.3.4",
|
|
72
|
+
"@sinclair/typebox": "^0.34.41",
|
|
73
|
+
"@types/mime-types": "^2.1.4",
|
|
74
|
+
"ajv": "^8.17.1",
|
|
75
|
+
"ajv-formats": "^3.0.1",
|
|
76
|
+
"chalk": "^5.5.0",
|
|
77
|
+
"diff": "^8.0.2",
|
|
78
|
+
"extract-zip": "^2.0.1",
|
|
79
|
+
"file-type": "^21.1.1",
|
|
80
|
+
"get-east-asian-width": "^1.3.0",
|
|
81
|
+
"glob": "^13.0.1",
|
|
82
|
+
"hosted-git-info": "^9.0.2",
|
|
83
|
+
"ignore": "^7.0.5",
|
|
84
|
+
"marked": "^15.0.12",
|
|
85
|
+
"mime-types": "^3.0.1",
|
|
86
|
+
"minimatch": "^10.2.3",
|
|
87
|
+
"openai": "6.26.0",
|
|
66
88
|
"picocolors": "^1.1.1",
|
|
67
89
|
"picomatch": "^4.0.3",
|
|
68
90
|
"playwright": "^1.58.2",
|
|
69
|
-
"
|
|
91
|
+
"proper-lockfile": "^4.1.2",
|
|
92
|
+
"proxy-agent": "^6.5.0",
|
|
93
|
+
"sharp": "^0.34.5",
|
|
94
|
+
"sql.js": "^1.14.1",
|
|
95
|
+
"strip-ansi": "^7.1.0",
|
|
96
|
+
"undici": "^7.24.2",
|
|
97
|
+
"yaml": "^2.8.2",
|
|
98
|
+
"zod-to-json-schema": "^3.24.6"
|
|
70
99
|
},
|
|
71
100
|
"devDependencies": {
|
|
72
101
|
"@types/node": "^22.0.0",
|
|
@@ -80,7 +109,8 @@
|
|
|
80
109
|
"@gsd-build/engine-linux-x64-gnu": ">=2.10.2",
|
|
81
110
|
"@gsd-build/engine-linux-arm64-gnu": ">=2.10.2",
|
|
82
111
|
"@gsd-build/engine-win32-x64-msvc": ">=2.10.2",
|
|
83
|
-
"fsevents": "~2.3.3"
|
|
112
|
+
"fsevents": "~2.3.3",
|
|
113
|
+
"koffi": "^2.9.0"
|
|
84
114
|
},
|
|
85
115
|
"overrides": {
|
|
86
116
|
"gaxios": "7.1.4"
|
|
@@ -845,13 +845,15 @@ async function showStepWizard(
|
|
|
845
845
|
/**
|
|
846
846
|
* Describe what the next unit will be, based on current state.
|
|
847
847
|
*/
|
|
848
|
-
function describeNextUnit(state: GSDState): { label: string; description: string } {
|
|
848
|
+
export function describeNextUnit(state: GSDState): { label: string; description: string } {
|
|
849
849
|
const sid = state.activeSlice?.id;
|
|
850
850
|
const sTitle = state.activeSlice?.title;
|
|
851
851
|
const tid = state.activeTask?.id;
|
|
852
852
|
const tTitle = state.activeTask?.title;
|
|
853
853
|
|
|
854
854
|
switch (state.phase) {
|
|
855
|
+
case "needs-discussion":
|
|
856
|
+
return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." };
|
|
855
857
|
case "pre-planning":
|
|
856
858
|
return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
|
|
857
859
|
case "planning":
|
|
@@ -1528,6 +1530,19 @@ async function dispatchNextUnit(
|
|
|
1528
1530
|
unitType = "reassess-roadmap";
|
|
1529
1531
|
unitId = `${mid}/${needsReassess.sliceId}`;
|
|
1530
1532
|
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
|
|
1533
|
+
} else if (state.phase === "needs-discussion") {
|
|
1534
|
+
// Draft milestone — pause auto-mode and notify user.
|
|
1535
|
+
// This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion
|
|
1536
|
+
// where the user chose "Needs own discussion". Auto-mode cannot proceed because
|
|
1537
|
+
// the draft is seed material, not a finalized context — planning requires a
|
|
1538
|
+
// dedicated discussion first.
|
|
1539
|
+
await stopAuto(ctx, pi);
|
|
1540
|
+
ctx.ui.notify(
|
|
1541
|
+
`${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
|
|
1542
|
+
"warning",
|
|
1543
|
+
);
|
|
1544
|
+
return;
|
|
1545
|
+
|
|
1531
1546
|
} else if (state.phase === "pre-planning") {
|
|
1532
1547
|
// Need roadmap — check if context exists
|
|
1533
1548
|
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./paths.js";
|
|
21
21
|
import { randomInt } from "node:crypto";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
23
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
|
24
24
|
import { execSync, execFileSync } from "node:child_process";
|
|
25
25
|
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
|
|
26
26
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
@@ -55,6 +55,13 @@ export function checkAutoStartAfterDiscuss(): boolean {
|
|
|
55
55
|
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
|
|
56
56
|
if (!contextFile) return false; // no context yet — keep waiting
|
|
57
57
|
|
|
58
|
+
// Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
|
|
59
|
+
// CONTEXT.md, delete the draft — it's been consumed by the discussion.
|
|
60
|
+
try {
|
|
61
|
+
const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
|
|
62
|
+
if (draftFile) unlinkSync(draftFile);
|
|
63
|
+
} catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ }
|
|
64
|
+
|
|
58
65
|
pendingAutoStart = null;
|
|
59
66
|
startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
|
|
60
67
|
return true;
|
|
@@ -248,7 +255,7 @@ export async function showQueue(
|
|
|
248
255
|
* Build a context block describing all existing milestones for the queue prompt.
|
|
249
256
|
* Gives the LLM enough information to dedup, sequence, and dependency-check.
|
|
250
257
|
*/
|
|
251
|
-
async function buildExistingMilestonesContext(
|
|
258
|
+
export async function buildExistingMilestonesContext(
|
|
252
259
|
basePath: string,
|
|
253
260
|
milestoneIds: string[],
|
|
254
261
|
state: import("./types.js").GSDState,
|
|
@@ -289,6 +296,15 @@ async function buildExistingMilestonesContext(
|
|
|
289
296
|
if (content) {
|
|
290
297
|
parts.push(`\n**Context:**\n${content.trim()}`);
|
|
291
298
|
}
|
|
299
|
+
} else {
|
|
300
|
+
// No full CONTEXT.md — check for CONTEXT-DRAFT.md (draft seed from prior discussion)
|
|
301
|
+
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
|
302
|
+
if (draftFile) {
|
|
303
|
+
const draftContent = await loadFile(draftFile);
|
|
304
|
+
if (draftContent) {
|
|
305
|
+
parts.push(`\n**Draft context available:**\n${draftContent.trim()}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
292
308
|
}
|
|
293
309
|
|
|
294
310
|
// For completed milestones, include the summary if it exists
|
|
@@ -637,6 +653,62 @@ export async function showSmartEntry(
|
|
|
637
653
|
return;
|
|
638
654
|
}
|
|
639
655
|
|
|
656
|
+
// ── Draft milestone — needs discussion before planning ────────────────
|
|
657
|
+
if (state.phase === "needs-discussion") {
|
|
658
|
+
const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
|
|
659
|
+
const draftContent = draftFile ? await loadFile(draftFile) : null;
|
|
660
|
+
|
|
661
|
+
const choice = await showNextAction(ctx as any, {
|
|
662
|
+
title: `GSD — ${milestoneId}: ${milestoneTitle}`,
|
|
663
|
+
summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
|
|
664
|
+
actions: [
|
|
665
|
+
{
|
|
666
|
+
id: "discuss_draft",
|
|
667
|
+
label: "Discuss from draft",
|
|
668
|
+
description: "Continue where the prior discussion left off — seed material is loaded automatically.",
|
|
669
|
+
recommended: true,
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
id: "discuss_fresh",
|
|
673
|
+
label: "Start fresh discussion",
|
|
674
|
+
description: "Discard the draft and start a new discussion from scratch.",
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
id: "skip_milestone",
|
|
678
|
+
label: "Skip — create new milestone",
|
|
679
|
+
description: "Leave this milestone as-is and start something new.",
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
notYetMessage: "Run /gsd when ready to discuss this milestone.",
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
if (choice === "discuss_draft") {
|
|
686
|
+
const basePrompt = loadPrompt("guided-discuss-milestone", {
|
|
687
|
+
milestoneId, milestoneTitle,
|
|
688
|
+
});
|
|
689
|
+
const seed = draftContent
|
|
690
|
+
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
|
|
691
|
+
: basePrompt;
|
|
692
|
+
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
693
|
+
dispatchWorkflow(pi, seed, "gsd-discuss");
|
|
694
|
+
} else if (choice === "discuss_fresh") {
|
|
695
|
+
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
696
|
+
dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
|
697
|
+
milestoneId, milestoneTitle,
|
|
698
|
+
}), "gsd-discuss");
|
|
699
|
+
} else if (choice === "skip_milestone") {
|
|
700
|
+
const milestoneIds = findMilestoneIds(basePath);
|
|
701
|
+
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
702
|
+
const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
|
|
703
|
+
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
704
|
+
dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
705
|
+
`New milestone ${nextId}.`,
|
|
706
|
+
basePath
|
|
707
|
+
));
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
640
712
|
// ── No active slice ──────────────────────────────────────────────────
|
|
641
713
|
if (!state.activeSlice) {
|
|
642
714
|
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
@@ -201,13 +201,34 @@ After writing the files and committing, say exactly: "Milestone {{milestoneId}}
|
|
|
201
201
|
|
|
202
202
|
### Multi-Milestone
|
|
203
203
|
|
|
204
|
-
Once the user confirms the milestone split
|
|
204
|
+
Once the user confirms the milestone split:
|
|
205
|
+
|
|
206
|
+
#### Phase 1: Shared artifacts
|
|
207
|
+
|
|
205
208
|
1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` for each milestone
|
|
206
209
|
2. Write `.gsd/PROJECT.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/project.md` first.
|
|
207
210
|
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.
|
|
208
|
-
4.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
4. Seed `.gsd/DECISIONS.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/decisions.md` first.
|
|
212
|
+
|
|
213
|
+
#### Phase 2: Primary milestone
|
|
214
|
+
|
|
215
|
+
5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth).
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
#### Phase 3: Sequential readiness gate for remaining milestones
|
|
219
|
+
|
|
220
|
+
For each remaining milestone **one at a time, in sequence**, use `ask_user_questions` to assess readiness. Present three options:
|
|
221
|
+
|
|
222
|
+
- **"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.
|
|
223
|
+
- **"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.
|
|
224
|
+
- **"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.
|
|
225
|
+
|
|
226
|
+
**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.
|
|
227
|
+
|
|
228
|
+
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.
|
|
229
|
+
|
|
230
|
+
#### Phase 4: Finalize
|
|
231
|
+
|
|
211
232
|
7. Update `.gsd/STATE.md`
|
|
212
233
|
8. Commit: `docs: project plan — N milestones` (replace N with the actual milestone count)
|
|
213
234
|
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
{{preamble}}
|
|
2
2
|
|
|
3
|
+
## Draft Awareness
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
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:
|
|
8
|
+
|
|
9
|
+
1. Tell the user which milestones have draft contexts and briefly summarize what each draft contains (read the draft file).
|
|
10
|
+
2. Use `ask_user_questions` to ask per-draft milestone:
|
|
11
|
+
- **"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.
|
|
12
|
+
- **"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.
|
|
13
|
+
3. Handle all draft discussions before proceeding to new queue work.
|
|
14
|
+
4. If no drafts exist in the context, skip this section entirely and proceed to "What do you want to add?"
|
|
15
|
+
|
|
3
16
|
Say exactly: "What do you want to add?" — nothing else. Wait for the user's answer.
|
|
4
17
|
|
|
5
18
|
## Discussion Phase
|
|
@@ -65,6 +65,8 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|
|
65
65
|
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
66
66
|
if (summaryFile) continue; // completed milestone, skip
|
|
67
67
|
return mid; // No roadmap and no summary — milestone is incomplete
|
|
68
|
+
// Note: draft-awareness (CONTEXT-DRAFT.md) is handled in deriveState(), not here.
|
|
69
|
+
// A draft milestone is still "active" — this function only determines which milestone is current.
|
|
68
70
|
}
|
|
69
71
|
const roadmap = parseRoadmap(content);
|
|
70
72
|
if (!isMilestoneComplete(roadmap)) return mid;
|
|
@@ -120,6 +122,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
120
122
|
let activeMilestone: ActiveRef | null = null;
|
|
121
123
|
let activeRoadmap: Roadmap | null = null;
|
|
122
124
|
let activeMilestoneFound = false;
|
|
125
|
+
let activeMilestoneHasDraft = false;
|
|
123
126
|
|
|
124
127
|
for (const mid of milestoneIds) {
|
|
125
128
|
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
@@ -138,6 +141,13 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
138
141
|
}
|
|
139
142
|
// No roadmap and no summary — treat as incomplete/active
|
|
140
143
|
if (!activeMilestoneFound) {
|
|
144
|
+
// Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones.
|
|
145
|
+
// A draft seed means the milestone has discussion material but no full context yet.
|
|
146
|
+
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
|
147
|
+
if (!contextFile) {
|
|
148
|
+
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
|
149
|
+
if (draftFile) activeMilestoneHasDraft = true;
|
|
150
|
+
}
|
|
141
151
|
activeMilestone = { id: mid, title: mid };
|
|
142
152
|
activeMilestoneFound = true;
|
|
143
153
|
registry.push({ id: mid, title: mid, status: 'active' });
|
|
@@ -235,15 +245,21 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
235
245
|
}
|
|
236
246
|
|
|
237
247
|
if (!activeRoadmap) {
|
|
238
|
-
// Active milestone exists but has no roadmap yet
|
|
248
|
+
// Active milestone exists but has no roadmap yet.
|
|
249
|
+
// If a CONTEXT-DRAFT.md seed exists, it needs discussion before planning.
|
|
250
|
+
// Otherwise, it's a blank milestone ready for initial planning.
|
|
251
|
+
const phase = activeMilestoneHasDraft ? 'needs-discussion' as const : 'pre-planning' as const;
|
|
252
|
+
const nextAction = activeMilestoneHasDraft
|
|
253
|
+
? `Discuss draft context for milestone ${activeMilestone.id}.`
|
|
254
|
+
: `Plan milestone ${activeMilestone.id}.`;
|
|
239
255
|
return {
|
|
240
256
|
activeMilestone,
|
|
241
257
|
activeSlice: null,
|
|
242
258
|
activeTask: null,
|
|
243
|
-
phase
|
|
259
|
+
phase,
|
|
244
260
|
recentDecisions: [],
|
|
245
261
|
blockers: [],
|
|
246
|
-
nextAction
|
|
262
|
+
nextAction,
|
|
247
263
|
registry,
|
|
248
264
|
requirements,
|
|
249
265
|
progress: {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { describeNextUnit } from "../auto.js";
|
|
5
|
+
|
|
6
|
+
let passed = 0;
|
|
7
|
+
let failed = 0;
|
|
8
|
+
|
|
9
|
+
function assert(condition: boolean, message: string): void {
|
|
10
|
+
if (condition) {
|
|
11
|
+
passed++;
|
|
12
|
+
} else {
|
|
13
|
+
failed++;
|
|
14
|
+
console.error(` FAIL: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Test describeNextUnit with 'needs-discussion' phase ──────────────────
|
|
19
|
+
|
|
20
|
+
const ndState = {
|
|
21
|
+
phase: "needs-discussion" as const,
|
|
22
|
+
activeMilestone: { id: "M007", title: "Future Milestone" },
|
|
23
|
+
activeSlice: undefined,
|
|
24
|
+
activeTask: undefined,
|
|
25
|
+
milestoneRegistry: [],
|
|
26
|
+
nextAction: "",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ndResult = describeNextUnit(ndState as any);
|
|
30
|
+
assert(
|
|
31
|
+
ndResult.label !== "Continue",
|
|
32
|
+
`needs-discussion label should not be default "Continue", got: "${ndResult.label}"`,
|
|
33
|
+
);
|
|
34
|
+
assert(
|
|
35
|
+
ndResult.label.toLowerCase().includes("draft") || ndResult.label.toLowerCase().includes("discuss"),
|
|
36
|
+
`needs-discussion label should mention "draft" or "discuss", got: "${ndResult.label}"`,
|
|
37
|
+
);
|
|
38
|
+
assert(
|
|
39
|
+
ndResult.description.toLowerCase().includes("discussion") || ndResult.description.toLowerCase().includes("draft"),
|
|
40
|
+
`needs-discussion description should mention "discussion" or "draft", got: "${ndResult.description}"`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// ─── Backward compatibility: pre-planning still works ──────────────────────
|
|
44
|
+
|
|
45
|
+
const ppState = {
|
|
46
|
+
phase: "pre-planning" as const,
|
|
47
|
+
activeMilestone: { id: "M001", title: "Test" },
|
|
48
|
+
activeSlice: undefined,
|
|
49
|
+
activeTask: undefined,
|
|
50
|
+
milestoneRegistry: [],
|
|
51
|
+
nextAction: "",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ppResult = describeNextUnit(ppState as any);
|
|
55
|
+
assert(
|
|
56
|
+
ppResult.label === "Research & plan milestone",
|
|
57
|
+
`pre-planning label should be "Research & plan milestone", got: "${ppResult.label}"`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ─── Backward compatibility: executing still works ──────────────────────────
|
|
61
|
+
|
|
62
|
+
const exState = {
|
|
63
|
+
phase: "executing" as const,
|
|
64
|
+
activeMilestone: { id: "M001", title: "Test" },
|
|
65
|
+
activeSlice: { id: "S01", title: "Test Slice" },
|
|
66
|
+
activeTask: { id: "T01", title: "Test Task" },
|
|
67
|
+
milestoneRegistry: [],
|
|
68
|
+
nextAction: "",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const exResult = describeNextUnit(exState as any);
|
|
72
|
+
assert(
|
|
73
|
+
exResult.label.includes("T01"),
|
|
74
|
+
`executing label should include task ID, got: "${exResult.label}"`,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// ─── Static verification: needs-discussion in dispatchNextUnit ──────────────
|
|
78
|
+
|
|
79
|
+
const autoSource = readFileSync(
|
|
80
|
+
join(import.meta.dirname, "..", "auto.ts"),
|
|
81
|
+
"utf-8",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Check describeNextUnit has the case
|
|
85
|
+
const hasDescribeCase = autoSource.includes('case "needs-discussion"');
|
|
86
|
+
assert(hasDescribeCase, "auto.ts describeNextUnit should have 'needs-discussion' case");
|
|
87
|
+
|
|
88
|
+
// Check dispatchNextUnit has the branch
|
|
89
|
+
const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"');
|
|
90
|
+
assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch");
|
|
91
|
+
|
|
92
|
+
// Check the dispatch branch calls stopAuto
|
|
93
|
+
const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"');
|
|
94
|
+
const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600);
|
|
95
|
+
assert(
|
|
96
|
+
nextChunk.includes("stopAuto"),
|
|
97
|
+
"needs-discussion dispatch branch should call stopAuto",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Check notification includes /gsd guidance
|
|
101
|
+
assert(
|
|
102
|
+
nextChunk.includes("/gsd"),
|
|
103
|
+
"needs-discussion notification should tell user to run /gsd",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// ─── Results ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
console.log(`\nauto-draft-pause: ${passed} passed, ${failed} failed`);
|
|
109
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
import { deriveState } from '../state.js';
|
|
6
|
+
|
|
7
|
+
let passed = 0;
|
|
8
|
+
let failed = 0;
|
|
9
|
+
|
|
10
|
+
function assertEq<T>(actual: T, expected: T, message: string): void {
|
|
11
|
+
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
|
12
|
+
passed++;
|
|
13
|
+
} else {
|
|
14
|
+
failed++;
|
|
15
|
+
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function createFixtureBase(): string {
|
|
22
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-draft-test-'));
|
|
23
|
+
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
|
24
|
+
return base;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeContextDraft(base: string, mid: string, content: string): void {
|
|
28
|
+
const dir = join(base, '.gsd', 'milestones', mid);
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), content);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeContext(base: string, mid: string, content: string): void {
|
|
34
|
+
const dir = join(base, '.gsd', 'milestones', mid);
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
writeFileSync(join(dir, `${mid}-CONTEXT.md`), content);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeRoadmap(base: string, mid: string, content: string): void {
|
|
40
|
+
const dir = join(base, '.gsd', 'milestones', mid);
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(join(dir, `${mid}-ROADMAP.md`), content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writePlan(base: string, mid: string, sid: string, content: string): void {
|
|
46
|
+
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
|
|
47
|
+
mkdirSync(join(dir, 'tasks'), { recursive: true });
|
|
48
|
+
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeMilestoneSummary(base: string, mid: string, content: string): void {
|
|
52
|
+
const dir = join(base, '.gsd', 'milestones', mid);
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cleanup(base: string): void {
|
|
58
|
+
rmSync(base, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
+
// Test Groups
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
|
|
65
|
+
async function main(): Promise<void> {
|
|
66
|
+
|
|
67
|
+
// ─── Test 1: CONTEXT-DRAFT.md only → needs-discussion ──────────────────
|
|
68
|
+
console.log('\n=== CONTEXT-DRAFT.md only → needs-discussion ===');
|
|
69
|
+
{
|
|
70
|
+
const base = createFixtureBase();
|
|
71
|
+
try {
|
|
72
|
+
// M001 directory with only CONTEXT-DRAFT.md — no CONTEXT.md, no ROADMAP.md
|
|
73
|
+
writeContextDraft(base, 'M001', '# Draft Context\n\nSeed discussion material.');
|
|
74
|
+
|
|
75
|
+
const state = await deriveState(base);
|
|
76
|
+
|
|
77
|
+
assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion');
|
|
78
|
+
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
|
|
79
|
+
assertEq(state.activeSlice, null, 'activeSlice is null');
|
|
80
|
+
assertEq(state.activeTask, null, 'activeTask is null');
|
|
81
|
+
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
|
82
|
+
assertEq(
|
|
83
|
+
state.nextAction.includes('Discuss'),
|
|
84
|
+
true,
|
|
85
|
+
'nextAction mentions Discuss'
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
cleanup(base);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Test 2: CONTEXT.md only → pre-planning (unchanged) ───────────────
|
|
93
|
+
console.log('\n=== CONTEXT.md only → pre-planning (unchanged) ===');
|
|
94
|
+
{
|
|
95
|
+
const base = createFixtureBase();
|
|
96
|
+
try {
|
|
97
|
+
// M001 directory with CONTEXT.md but no ROADMAP.md
|
|
98
|
+
writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady for planning.');
|
|
99
|
+
|
|
100
|
+
const state = await deriveState(base);
|
|
101
|
+
|
|
102
|
+
assertEq(state.phase, 'pre-planning', 'phase is pre-planning with CONTEXT.md');
|
|
103
|
+
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
|
|
104
|
+
assertEq(state.activeSlice, null, 'activeSlice is null');
|
|
105
|
+
assertEq(state.activeTask, null, 'activeTask is null');
|
|
106
|
+
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
|
107
|
+
} finally {
|
|
108
|
+
cleanup(base);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Test 3: Both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ──────
|
|
113
|
+
console.log('\n=== both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ===');
|
|
114
|
+
{
|
|
115
|
+
const base = createFixtureBase();
|
|
116
|
+
try {
|
|
117
|
+
// M001 has both files — CONTEXT.md should take precedence
|
|
118
|
+
writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady.');
|
|
119
|
+
writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored.');
|
|
120
|
+
|
|
121
|
+
const state = await deriveState(base);
|
|
122
|
+
|
|
123
|
+
assertEq(state.phase, 'pre-planning', 'phase is pre-planning when CONTEXT.md exists');
|
|
124
|
+
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
|
|
125
|
+
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
|
126
|
+
} finally {
|
|
127
|
+
cleanup(base);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Test 4: M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ──
|
|
132
|
+
console.log('\n=== M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ===');
|
|
133
|
+
{
|
|
134
|
+
const base = createFixtureBase();
|
|
135
|
+
try {
|
|
136
|
+
// M001: complete (roadmap with all slices done + summary)
|
|
137
|
+
writeRoadmap(base, 'M001', `# M001: First Milestone
|
|
138
|
+
|
|
139
|
+
**Vision:** Already done.
|
|
140
|
+
|
|
141
|
+
## Slices
|
|
142
|
+
|
|
143
|
+
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
|
144
|
+
> After this: Done.
|
|
145
|
+
`);
|
|
146
|
+
writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone complete.');
|
|
147
|
+
|
|
148
|
+
// M002: only CONTEXT-DRAFT.md
|
|
149
|
+
writeContextDraft(base, 'M002', '# Draft for M002\n\nSeed material.');
|
|
150
|
+
|
|
151
|
+
const state = await deriveState(base);
|
|
152
|
+
|
|
153
|
+
assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002');
|
|
154
|
+
assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone id is M002');
|
|
155
|
+
assertEq(state.activeSlice, null, 'activeSlice is null');
|
|
156
|
+
assertEq(state.registry.length, 2, 'registry has 2 entries');
|
|
157
|
+
assertEq(state.registry[0]?.status, 'complete', 'M001 is complete');
|
|
158
|
+
assertEq(state.registry[1]?.status, 'active', 'M002 is active');
|
|
159
|
+
assertEq(state.progress?.milestones?.done, 1, 'milestones done = 1');
|
|
160
|
+
assertEq(state.progress?.milestones?.total, 2, 'milestones total = 2');
|
|
161
|
+
} finally {
|
|
162
|
+
cleanup(base);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Test 5: Multi-milestone: M001 complete, M002 CONTEXT-DRAFT, M003 pending ──
|
|
167
|
+
console.log('\n=== multi-milestone: M001 complete, M002 draft, M003 pending ===');
|
|
168
|
+
{
|
|
169
|
+
const base = createFixtureBase();
|
|
170
|
+
try {
|
|
171
|
+
// M001: complete
|
|
172
|
+
writeRoadmap(base, 'M001', `# M001: First
|
|
173
|
+
|
|
174
|
+
**Vision:** Done.
|
|
175
|
+
|
|
176
|
+
## Slices
|
|
177
|
+
|
|
178
|
+
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
|
179
|
+
> After this: Done.
|
|
180
|
+
`);
|
|
181
|
+
writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nComplete.');
|
|
182
|
+
|
|
183
|
+
// M002: draft only — should become active with needs-discussion
|
|
184
|
+
writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.');
|
|
185
|
+
|
|
186
|
+
// M003: blank milestone directory — should be pending
|
|
187
|
+
mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true });
|
|
188
|
+
|
|
189
|
+
const state = await deriveState(base);
|
|
190
|
+
|
|
191
|
+
assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002');
|
|
192
|
+
assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone is M002');
|
|
193
|
+
assertEq(state.registry.length, 3, 'registry has 3 entries');
|
|
194
|
+
assertEq(state.registry[0]?.status, 'complete', 'M001 is complete');
|
|
195
|
+
assertEq(state.registry[1]?.status, 'active', 'M002 is active');
|
|
196
|
+
assertEq(state.registry[2]?.status, 'pending', 'M003 is pending');
|
|
197
|
+
} finally {
|
|
198
|
+
cleanup(base);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Test 6: Milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence ──
|
|
203
|
+
console.log('\n=== milestone with ROADMAP + CONTEXT-DRAFT → normal execution ===');
|
|
204
|
+
{
|
|
205
|
+
const base = createFixtureBase();
|
|
206
|
+
try {
|
|
207
|
+
// M001 has ROADMAP.md (active slice, incomplete tasks) and CONTEXT-DRAFT.md
|
|
208
|
+
// The ROADMAP should take precedence — we're past the draft phase
|
|
209
|
+
writeRoadmap(base, 'M001', `# M001: Active Milestone
|
|
210
|
+
|
|
211
|
+
**Vision:** In progress.
|
|
212
|
+
|
|
213
|
+
## Slices
|
|
214
|
+
|
|
215
|
+
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
|
|
216
|
+
> After this: First slice done.
|
|
217
|
+
`);
|
|
218
|
+
writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored — roadmap exists.');
|
|
219
|
+
|
|
220
|
+
// Add a plan so it goes to executing phase
|
|
221
|
+
writePlan(base, 'M001', 'S01', `# S01: First Slice
|
|
222
|
+
|
|
223
|
+
**Goal:** Do something.
|
|
224
|
+
|
|
225
|
+
## Tasks
|
|
226
|
+
|
|
227
|
+
- [ ] **T01: First Task** \`est:30m\`
|
|
228
|
+
`);
|
|
229
|
+
|
|
230
|
+
const state = await deriveState(base);
|
|
231
|
+
|
|
232
|
+
assertEq(state.phase, 'executing', 'phase is executing (ROADMAP takes precedence over CONTEXT-DRAFT)');
|
|
233
|
+
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
|
|
234
|
+
assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01');
|
|
235
|
+
assertEq(state.activeTask?.id, 'T01', 'activeTask is T01');
|
|
236
|
+
} finally {
|
|
237
|
+
cleanup(base);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Test 7: Empty milestone dir (no files at all) → pre-planning ─────
|
|
242
|
+
console.log('\n=== empty milestone dir (no files) → pre-planning ===');
|
|
243
|
+
{
|
|
244
|
+
const base = createFixtureBase();
|
|
245
|
+
try {
|
|
246
|
+
// M001: just a directory, no files at all
|
|
247
|
+
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
|
|
248
|
+
|
|
249
|
+
const state = await deriveState(base);
|
|
250
|
+
|
|
251
|
+
assertEq(state.phase, 'pre-planning', 'phase is pre-planning for blank milestone');
|
|
252
|
+
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
|
|
253
|
+
assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
|
|
254
|
+
} finally {
|
|
255
|
+
cleanup(base);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Test 8: CONTEXT-DRAFT on non-first active milestone ──────────────
|
|
260
|
+
// M001 has no summary and no roadmap (active), M002 has CONTEXT-DRAFT
|
|
261
|
+
// M001 should be active (pre-planning), M002 should be pending
|
|
262
|
+
console.log('\n=== CONTEXT-DRAFT on non-active milestone → pending ===');
|
|
263
|
+
{
|
|
264
|
+
const base = createFixtureBase();
|
|
265
|
+
try {
|
|
266
|
+
// M001: blank (no roadmap, no summary) → becomes active first
|
|
267
|
+
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
|
|
268
|
+
|
|
269
|
+
// M002: has CONTEXT-DRAFT but isn't active (M001 is first)
|
|
270
|
+
writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.');
|
|
271
|
+
|
|
272
|
+
const state = await deriveState(base);
|
|
273
|
+
|
|
274
|
+
assertEq(state.phase, 'pre-planning', 'phase is pre-planning (M001 is active, not M002)');
|
|
275
|
+
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
|
|
276
|
+
assertEq(state.registry[0]?.status, 'active', 'M001 is active');
|
|
277
|
+
assertEq(state.registry[1]?.status, 'pending', 'M002 is pending');
|
|
278
|
+
} finally {
|
|
279
|
+
cleanup(base);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
284
|
+
// Summary
|
|
285
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
286
|
+
|
|
287
|
+
console.log(`\n${'═'.repeat(60)}`);
|
|
288
|
+
console.log(`Draft-aware state derivation tests: ${passed} passed, ${failed} failed`);
|
|
289
|
+
console.log('═'.repeat(60));
|
|
290
|
+
|
|
291
|
+
if (failed > 0) {
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
main().catch(err => {
|
|
297
|
+
console.error('Test suite error:', err);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { deriveState } from "../state.js";
|
|
6
|
+
import { resolveMilestoneFile } from "../paths.js";
|
|
7
|
+
|
|
8
|
+
let passed = 0;
|
|
9
|
+
let failed = 0;
|
|
10
|
+
|
|
11
|
+
function assert(condition: boolean, message: string): void {
|
|
12
|
+
if (condition) {
|
|
13
|
+
passed++;
|
|
14
|
+
} else {
|
|
15
|
+
failed++;
|
|
16
|
+
console.error(` FAIL: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Full state transition: needs-discussion → pre-planning ─────────────
|
|
21
|
+
|
|
22
|
+
console.log("=== Draft promotion: full state transition ===");
|
|
23
|
+
|
|
24
|
+
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-test-"));
|
|
25
|
+
const gsd = join(tmpBase, ".gsd");
|
|
26
|
+
|
|
27
|
+
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
|
28
|
+
|
|
29
|
+
// Step 1: Create CONTEXT-DRAFT.md only → needs-discussion
|
|
30
|
+
const draftPath = join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
|
|
31
|
+
writeFileSync(draftPath, "# M001: Draft\n\nSeed material.\n");
|
|
32
|
+
|
|
33
|
+
const state1 = await deriveState(tmpBase);
|
|
34
|
+
assert(
|
|
35
|
+
state1.phase === "needs-discussion",
|
|
36
|
+
`draft-only should be 'needs-discussion', got: "${state1.phase}"`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Step 2: Write CONTEXT.md (simulating discussion output) → pre-planning
|
|
40
|
+
const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md");
|
|
41
|
+
writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n");
|
|
42
|
+
|
|
43
|
+
const state2 = await deriveState(tmpBase);
|
|
44
|
+
assert(
|
|
45
|
+
state2.phase === "pre-planning",
|
|
46
|
+
`after CONTEXT.md written, should be 'pre-planning', got: "${state2.phase}"`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Step 3: Simulate draft cleanup (what checkAutoStartAfterDiscuss does)
|
|
50
|
+
const resolvedDraft = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
|
|
51
|
+
assert(
|
|
52
|
+
resolvedDraft !== null && resolvedDraft !== undefined,
|
|
53
|
+
"CONTEXT-DRAFT.md should still exist before cleanup",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Delete the draft (simulating the cleanup in checkAutoStartAfterDiscuss)
|
|
57
|
+
const { unlinkSync } = await import("node:fs");
|
|
58
|
+
try {
|
|
59
|
+
if (resolvedDraft) unlinkSync(resolvedDraft);
|
|
60
|
+
} catch { /* non-fatal */ }
|
|
61
|
+
|
|
62
|
+
assert(
|
|
63
|
+
!existsSync(draftPath),
|
|
64
|
+
"CONTEXT-DRAFT.md should be deleted after promotion cleanup",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists)
|
|
68
|
+
const state3 = await deriveState(tmpBase);
|
|
69
|
+
assert(
|
|
70
|
+
state3.phase === "pre-planning",
|
|
71
|
+
`after cleanup, should still be 'pre-planning', got: "${state3.phase}"`,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// ─── No-draft case: cleanup is a no-op ──────────────────────────────────
|
|
75
|
+
|
|
76
|
+
console.log("=== No-draft cleanup: no-op ===");
|
|
77
|
+
|
|
78
|
+
const tmpBase2 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-noop-"));
|
|
79
|
+
const gsd2 = join(tmpBase2, ".gsd");
|
|
80
|
+
|
|
81
|
+
mkdirSync(join(gsd2, "milestones", "M001"), { recursive: true });
|
|
82
|
+
writeFileSync(
|
|
83
|
+
join(gsd2, "milestones", "M001", "M001-CONTEXT.md"),
|
|
84
|
+
"# M001: Normal\n\nStandard discussion output.\n",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// No CONTEXT-DRAFT.md exists — cleanup should be a no-op
|
|
88
|
+
const noDraft = resolveMilestoneFile(tmpBase2, "M001", "CONTEXT-DRAFT");
|
|
89
|
+
assert(
|
|
90
|
+
noDraft === null || noDraft === undefined,
|
|
91
|
+
"no CONTEXT-DRAFT.md should exist for standard discussion milestone",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// deriveState should return pre-planning normally
|
|
95
|
+
const state4 = await deriveState(tmpBase2);
|
|
96
|
+
assert(
|
|
97
|
+
state4.phase === "pre-planning",
|
|
98
|
+
`standard discussion milestone should be 'pre-planning', got: "${state4.phase}"`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// ─── Both files exist → CONTEXT.md wins, draft cleanup works ───────────
|
|
102
|
+
|
|
103
|
+
console.log("=== Both files: CONTEXT wins, draft cleanable ===");
|
|
104
|
+
|
|
105
|
+
const tmpBase3 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-both-"));
|
|
106
|
+
const gsd3 = join(tmpBase3, ".gsd");
|
|
107
|
+
|
|
108
|
+
mkdirSync(join(gsd3, "milestones", "M001"), { recursive: true });
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(gsd3, "milestones", "M001", "M001-CONTEXT.md"),
|
|
111
|
+
"# M001: Full\n\nFull context.\n",
|
|
112
|
+
);
|
|
113
|
+
const bothDraftPath = join(gsd3, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
|
|
114
|
+
writeFileSync(bothDraftPath, "# M001: Draft\n\nStale draft.\n");
|
|
115
|
+
|
|
116
|
+
const state5 = await deriveState(tmpBase3);
|
|
117
|
+
assert(
|
|
118
|
+
state5.phase === "pre-planning",
|
|
119
|
+
`both files: CONTEXT.md wins, should be 'pre-planning', got: "${state5.phase}"`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Cleanup the stale draft
|
|
123
|
+
const bothDraft = resolveMilestoneFile(tmpBase3, "M001", "CONTEXT-DRAFT");
|
|
124
|
+
try {
|
|
125
|
+
if (bothDraft) unlinkSync(bothDraft);
|
|
126
|
+
} catch { /* non-fatal */ }
|
|
127
|
+
|
|
128
|
+
assert(
|
|
129
|
+
!existsSync(bothDraftPath),
|
|
130
|
+
"stale CONTEXT-DRAFT.md should be deleted in both-files case",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// ─── Static: guided-flow.ts has cleanup code ───────────────────────────
|
|
134
|
+
|
|
135
|
+
console.log("=== Static: cleanup code in guided-flow.ts ===");
|
|
136
|
+
|
|
137
|
+
const { readFileSync } = await import("node:fs");
|
|
138
|
+
const guidedFlowSource = readFileSync(
|
|
139
|
+
join(import.meta.dirname, "..", "guided-flow.ts"),
|
|
140
|
+
"utf-8",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss");
|
|
144
|
+
const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200);
|
|
145
|
+
|
|
146
|
+
assert(
|
|
147
|
+
checkFnChunk.includes("CONTEXT-DRAFT"),
|
|
148
|
+
"checkAutoStartAfterDiscuss should reference CONTEXT-DRAFT for cleanup",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
assert(
|
|
152
|
+
checkFnChunk.includes("unlinkSync"),
|
|
153
|
+
"checkAutoStartAfterDiscuss should use unlinkSync to delete the draft",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
159
|
+
rmSync(tmpBase2, { recursive: true, force: true });
|
|
160
|
+
rmSync(tmpBase3, { recursive: true, force: true });
|
|
161
|
+
|
|
162
|
+
// ─── Results ──────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
console.log(`\ndraft-promotion: ${passed} passed, ${failed} failed`);
|
|
165
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { deriveState } from "../state.js";
|
|
6
|
+
import { buildExistingMilestonesContext } from "../guided-flow.js";
|
|
7
|
+
|
|
8
|
+
let passed = 0;
|
|
9
|
+
let failed = 0;
|
|
10
|
+
|
|
11
|
+
function assert(condition: boolean, message: string): void {
|
|
12
|
+
if (condition) {
|
|
13
|
+
passed++;
|
|
14
|
+
} else {
|
|
15
|
+
failed++;
|
|
16
|
+
console.error(` FAIL: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Fixture setup ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-"));
|
|
23
|
+
const gsd = join(tmpBase, ".gsd");
|
|
24
|
+
|
|
25
|
+
// M001: has only CONTEXT-DRAFT.md (draft milestone)
|
|
26
|
+
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
|
|
29
|
+
"# M001: Draft Milestone\n\nSeed material from prior discussion.\n",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// M002: has full CONTEXT.md (ready milestone)
|
|
33
|
+
mkdirSync(join(gsd, "milestones", "M002"), { recursive: true });
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(gsd, "milestones", "M002", "M002-CONTEXT.md"),
|
|
36
|
+
"# M002: Ready Milestone\n\nFull context from deep discussion.\n",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins)
|
|
40
|
+
mkdirSync(join(gsd, "milestones", "M003"), { recursive: true });
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(gsd, "milestones", "M003", "M003-CONTEXT.md"),
|
|
43
|
+
"# M003: Full Context\n\nThis is the real context.\n",
|
|
44
|
+
);
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"),
|
|
47
|
+
"# M003: Draft\n\nThis should be ignored.\n",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// M004: has neither (empty milestone dir)
|
|
51
|
+
mkdirSync(join(gsd, "milestones", "M004"), { recursive: true });
|
|
52
|
+
|
|
53
|
+
// ─── Build context ──────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const state = await deriveState(tmpBase);
|
|
56
|
+
const milestoneIds = ["M001", "M002", "M003", "M004"];
|
|
57
|
+
const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state);
|
|
58
|
+
|
|
59
|
+
// ─── Test: draft-only milestone includes "Draft context available" ──────
|
|
60
|
+
|
|
61
|
+
assert(
|
|
62
|
+
context.includes("Draft context available"),
|
|
63
|
+
"M001 (draft-only) should include 'Draft context available' label",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
assert(
|
|
67
|
+
context.includes("Seed material from prior discussion"),
|
|
68
|
+
"M001 draft content should be included in context output",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// ─── Test: full-context milestone uses "Context:" label ────────────────
|
|
72
|
+
|
|
73
|
+
assert(
|
|
74
|
+
context.includes("**Context:**"),
|
|
75
|
+
"M002 (full context) should use 'Context:' label",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
assert(
|
|
79
|
+
context.includes("Full context from deep discussion"),
|
|
80
|
+
"M002 context content should be included",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// ─── Test: both files → CONTEXT.md wins, no draft label ────────────────
|
|
84
|
+
|
|
85
|
+
// Find M003's section and check it has Context: but not Draft
|
|
86
|
+
const m003Idx = context.indexOf("M003:");
|
|
87
|
+
const m003Section = context.slice(m003Idx, m003Idx + 500);
|
|
88
|
+
|
|
89
|
+
assert(
|
|
90
|
+
m003Section.includes("**Context:**"),
|
|
91
|
+
"M003 (both files) should use 'Context:' label (CONTEXT.md wins)",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert(
|
|
95
|
+
!m003Section.includes("Draft context available"),
|
|
96
|
+
"M003 (both files) should NOT show draft label — CONTEXT.md takes precedence",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
assert(
|
|
100
|
+
m003Section.includes("This is the real context"),
|
|
101
|
+
"M003 should show CONTEXT.md content, not draft content",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// ─── Test: neither file → no context section ───────────────────────────
|
|
105
|
+
|
|
106
|
+
const m004Idx = context.indexOf("M004:");
|
|
107
|
+
const m004Section = context.slice(m004Idx, m004Idx + 500);
|
|
108
|
+
|
|
109
|
+
assert(
|
|
110
|
+
!m004Section.includes("**Context:**"),
|
|
111
|
+
"M004 (neither file) should not have Context: label",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
assert(
|
|
115
|
+
!m004Section.includes("Draft context available"),
|
|
116
|
+
"M004 (neither file) should not have Draft label",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
122
|
+
|
|
123
|
+
// ─── Results ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
console.log(`\nqueue-draft-detection: ${passed} passed, ${failed} failed`);
|
|
126
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { deriveState } from "../state.js";
|
|
6
|
+
import { resolveMilestoneFile } from "../paths.js";
|
|
7
|
+
|
|
8
|
+
let passed = 0;
|
|
9
|
+
let failed = 0;
|
|
10
|
+
|
|
11
|
+
function assert(condition: boolean, message: string): void {
|
|
12
|
+
if (condition) {
|
|
13
|
+
passed++;
|
|
14
|
+
} else {
|
|
15
|
+
failed++;
|
|
16
|
+
console.error(` FAIL: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Fixture: milestone with only CONTEXT-DRAFT.md ──────────────────────
|
|
21
|
+
|
|
22
|
+
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-smart-entry-draft-test-"));
|
|
23
|
+
const gsd = join(tmpBase, ".gsd");
|
|
24
|
+
|
|
25
|
+
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
|
26
|
+
|
|
27
|
+
const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`;
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
|
|
30
|
+
draftContent,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// ─── Test: deriveState returns 'needs-discussion' for draft-only milestone ───
|
|
34
|
+
|
|
35
|
+
const state = await deriveState(tmpBase);
|
|
36
|
+
|
|
37
|
+
assert(
|
|
38
|
+
state.phase === "needs-discussion",
|
|
39
|
+
`phase should be 'needs-discussion' for draft-only milestone, got: "${state.phase}"`,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
assert(
|
|
43
|
+
state.activeMilestone?.id === "M001",
|
|
44
|
+
`active milestone should be M001, got: "${state.activeMilestone?.id}"`,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ─── Test: resolveMilestoneFile resolves CONTEXT-DRAFT ─────────────────────
|
|
48
|
+
|
|
49
|
+
const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
|
|
50
|
+
|
|
51
|
+
assert(
|
|
52
|
+
draftFile !== null && draftFile !== undefined,
|
|
53
|
+
`resolveMilestoneFile should resolve CONTEXT-DRAFT, got: ${draftFile}`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
assert(
|
|
57
|
+
draftFile!.endsWith("M001-CONTEXT-DRAFT.md"),
|
|
58
|
+
`resolved path should end with M001-CONTEXT-DRAFT.md, got: "${draftFile}"`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// ─── Test: CONTEXT.md is NOT resolved (only draft exists) ──────────────────
|
|
62
|
+
|
|
63
|
+
const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT");
|
|
64
|
+
|
|
65
|
+
assert(
|
|
66
|
+
contextFile === null || contextFile === undefined,
|
|
67
|
+
`resolveMilestoneFile should NOT resolve CONTEXT when only CONTEXT-DRAFT exists, got: "${contextFile}"`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// ─── Static: guided-flow.ts has 'needs-discussion' branch ─────────────────
|
|
71
|
+
|
|
72
|
+
const guidedFlowSource = readFileSync(
|
|
73
|
+
join(import.meta.dirname, "..", "guided-flow.ts"),
|
|
74
|
+
"utf-8",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
assert(
|
|
78
|
+
guidedFlowSource.includes('state.phase === "needs-discussion"'),
|
|
79
|
+
"guided-flow.ts should have 'needs-discussion' phase check in showSmartEntry",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Check the branch has draft-aware menu options
|
|
83
|
+
const branchIdx = guidedFlowSource.indexOf('state.phase === "needs-discussion"');
|
|
84
|
+
const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 3000);
|
|
85
|
+
|
|
86
|
+
assert(
|
|
87
|
+
branchChunk.includes("discuss_draft"),
|
|
88
|
+
"needs-discussion branch should have 'discuss_draft' option",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
assert(
|
|
92
|
+
branchChunk.includes("discuss_fresh"),
|
|
93
|
+
"needs-discussion branch should have 'discuss_fresh' option",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
assert(
|
|
97
|
+
branchChunk.includes("skip_milestone"),
|
|
98
|
+
"needs-discussion branch should have 'skip_milestone' option",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
assert(
|
|
102
|
+
branchChunk.includes("CONTEXT-DRAFT"),
|
|
103
|
+
"needs-discussion branch should load CONTEXT-DRAFT via resolveMilestoneFile",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
assert(
|
|
107
|
+
branchChunk.includes("Draft Seed") || branchChunk.includes("draftContent"),
|
|
108
|
+
"discuss_draft path should include draft content as seed in the dispatched prompt",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
assert(
|
|
112
|
+
branchChunk.includes("return"),
|
|
113
|
+
"needs-discussion branch should return early (not fall through to generic no-roadmap menu)",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
119
|
+
|
|
120
|
+
// ─── Results ──────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
console.log(`\nsmart-entry-draft: ${passed} passed, ${failed} failed`);
|
|
123
|
+
if (failed > 0) process.exit(1);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// ─── Enums & Literal Unions ────────────────────────────────────────────────
|
|
6
6
|
|
|
7
7
|
export type RiskLevel = 'low' | 'medium' | 'high';
|
|
8
|
-
export type Phase = 'pre-planning' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
|
|
8
|
+
export type Phase = 'pre-planning' | 'needs-discussion' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
|
|
9
9
|
export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted';
|
|
10
10
|
|
|
11
11
|
// ─── Roadmap (Milestone-level) ─────────────────────────────────────────────
|
|
@@ -44,6 +44,12 @@ let linuxReady = false;
|
|
|
44
44
|
function ensureLinuxReady(ctx: ExtensionContext): boolean {
|
|
45
45
|
if (linuxReady) return true;
|
|
46
46
|
|
|
47
|
+
// Check GROQ_API_KEY is available
|
|
48
|
+
if (!process.env.GROQ_API_KEY) {
|
|
49
|
+
ctx.ui.notify("Voice: GROQ_API_KEY not set — run 'gsd config' to configure", "error");
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
// Check python3 exists
|
|
48
54
|
try {
|
|
49
55
|
execSync("which python3", { stdio: "pipe" });
|
|
@@ -211,21 +217,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
217
|
onReady: () => void,
|
|
212
218
|
) {
|
|
213
219
|
if (IS_LINUX) {
|
|
214
|
-
// Pass GROQ_API_KEY to the Python process — check process.env, then .env file
|
|
215
|
-
const spawnEnv = { ...process.env };
|
|
216
|
-
if (!spawnEnv.GROQ_API_KEY) {
|
|
217
|
-
try {
|
|
218
|
-
const envPath = path.join(process.cwd(), ".env");
|
|
219
|
-
const envContent = fs.readFileSync(envPath, "utf-8");
|
|
220
|
-
const match = envContent.match(/^GROQ_API_KEY=(.+)$/m);
|
|
221
|
-
if (match) spawnEnv.GROQ_API_KEY = match[1].trim();
|
|
222
|
-
} catch {
|
|
223
|
-
// .env not found — Python script will emit ERROR if key needed
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
220
|
recognizerProcess = spawn(linuxPython(), [PYTHON_SCRIPT], {
|
|
227
221
|
stdio: ["pipe", "pipe", "pipe"],
|
|
228
|
-
env: spawnEnv,
|
|
229
222
|
});
|
|
230
223
|
} else {
|
|
231
224
|
recognizerProcess = spawn(RECOGNIZER_BIN, [], { stdio: ["pipe", "pipe", "pipe"] });
|