@opengsd/gsd-pi 1.2.0-dev.d6c5343c → 1.2.0-dev.e8563f58
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/mcp-server.js +2 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/orchestrator.js +28 -10
- package/dist/resources/extensions/gsd/auto-model-selection.js +11 -7
- package/dist/resources/extensions/gsd/auto.js +7 -0
- package/dist/resources/extensions/gsd/blocked-models.js +28 -0
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +26 -6
- package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -2
- package/dist/resources/extensions/gsd/closeout-wizard.js +92 -0
- package/dist/resources/extensions/gsd/commands-handlers.js +46 -3
- package/dist/resources/extensions/gsd/consent-question.js +16 -0
- package/dist/resources/extensions/gsd/doctor-git-checks.js +2 -18
- package/dist/resources/extensions/gsd/gsd-command-home.js +22 -12
- package/dist/resources/extensions/gsd/gsd-db.js +2 -1
- package/dist/resources/extensions/gsd/milestone-closeout.js +73 -2
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +67 -2
- package/dist/resources/extensions/shared/gsd-browser-cli.js +21 -2
- package/dist/resources/shared/gsd-browser-path-sync.js +214 -0
- package/dist/resources/shared/package-manager-detection.js +1 -1
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/update-check.d.ts +2 -0
- package/dist/update-check.js +24 -1
- package/dist/update-cmd.js +20 -3
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +8 -8
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +8 -8
- package/dist/web/standalone/.next/server/chunks/8357.js +2 -2
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
- package/package.json +1 -1
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/dist/moonshot-tool-schema.d.ts +29 -0
- package/packages/mcp-server/dist/moonshot-tool-schema.d.ts.map +1 -0
- package/packages/mcp-server/dist/moonshot-tool-schema.js +50 -0
- package/packages/mcp-server/dist/moonshot-tool-schema.js.map +1 -0
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +4 -0
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/workflow-tools.d.ts +18 -18
- package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
- package/packages/mcp-server/dist/workflow-tools.js +99 -38
- package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
- package/packages/mcp-server/package.json +5 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/dist/index.d.ts +2 -0
- package/packages/pi-ai/dist/index.d.ts.map +1 -1
- package/packages/pi-ai/dist/index.js +2 -0
- package/packages/pi-ai/dist/index.js.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +12 -7
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/google-shared.d.ts +5 -0
- package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google-shared.js +12 -3
- package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +7 -3
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/dist/utils/moonshot-tool-schema.d.ts +9 -0
- package/packages/pi-ai/dist/utils/moonshot-tool-schema.d.ts.map +1 -0
- package/packages/pi-ai/dist/utils/moonshot-tool-schema.js +34 -0
- package/packages/pi-ai/dist/utils/moonshot-tool-schema.js.map +1 -0
- package/packages/pi-ai/dist/utils/oauth/github-copilot.d.ts.map +1 -1
- package/packages/pi-ai/dist/utils/oauth/github-copilot.js +6 -2
- package/packages/pi-ai/dist/utils/oauth/github-copilot.js.map +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/browser-tools/tests/gsd-browser-launch-config.test.mjs +11 -0
- package/src/resources/extensions/gsd/auto/orchestrator.ts +28 -10
- package/src/resources/extensions/gsd/auto-model-selection.ts +16 -7
- package/src/resources/extensions/gsd/auto.ts +8 -0
- package/src/resources/extensions/gsd/blocked-models.ts +49 -0
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +34 -5
- package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -2
- package/src/resources/extensions/gsd/closeout-wizard.ts +102 -0
- package/src/resources/extensions/gsd/commands-handlers.ts +46 -3
- package/src/resources/extensions/gsd/consent-question.ts +15 -0
- package/src/resources/extensions/gsd/doctor-git-checks.ts +2 -19
- package/src/resources/extensions/gsd/gsd-command-home.ts +13 -3
- package/src/resources/extensions/gsd/gsd-db.ts +4 -3
- package/src/resources/extensions/gsd/milestone-closeout.ts +97 -2
- package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +97 -0
- package/src/resources/extensions/gsd/tests/blocked-models.test.ts +19 -0
- package/src/resources/extensions/gsd/tests/consent-question.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/doctor-git-checks-terminal.test.ts +73 -0
- package/src/resources/extensions/gsd/tests/gsd-command-home.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/milestone-closeout.test.ts +95 -4
- package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +0 -1
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +273 -38
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +81 -2
- package/src/resources/extensions/shared/gsd-browser-cli.ts +23 -2
- package/src/resources/shared/gsd-browser-path-sync.ts +273 -0
- package/src/resources/shared/package-manager-detection.ts +1 -1
- /package/dist/web/standalone/.next/static/{jmTLg6xZmAuq_LIqKOxrH → LDHRKiRBIVZmiuMjrL1Vy}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{jmTLg6xZmAuq_LIqKOxrH → LDHRKiRBIVZmiuMjrL1Vy}/_ssgManifest.js +0 -0
|
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent
|
|
|
9
9
|
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
|
-
import { join, resolve as resolvePath, sep } from "node:path";
|
|
12
|
+
import { join, resolve as resolvePath, sep, win32 as pathWin32 } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { deriveState } from "./state.js";
|
|
15
15
|
import { gsdRoot } from "./paths.js";
|
|
@@ -29,6 +29,7 @@ import { getAutoWorktreePath } from "./auto-worktree.js";
|
|
|
29
29
|
import { currentDirectoryRoot, projectRoot } from "./commands/context.js";
|
|
30
30
|
import { loadPrompt } from "./prompt-loader.js";
|
|
31
31
|
import { buildClaudeRuntimeFloorAdvisory } from "../../shared/claude-runtime-floor.js";
|
|
32
|
+
import { reconcileGsdBrowserPathAfterInstall } from "../../shared/gsd-browser-path-sync.js";
|
|
32
33
|
import { isPnpmInstall } from "../../shared/package-manager-detection.js";
|
|
33
34
|
import {
|
|
34
35
|
buildDoctorHealIssuePayload,
|
|
@@ -65,9 +66,33 @@ function isBunInstall(argv1: string | undefined = process.argv[1]): boolean {
|
|
|
65
66
|
function resolveInstallCommand(pkg: string): string {
|
|
66
67
|
if (isBunInstall()) return `bun add -g ${pkg}`;
|
|
67
68
|
if (isPnpmInstall()) return `pnpm add -g ${pkg}`;
|
|
69
|
+
const npmPrefix = resolveWindowsNpmGlobalPrefix();
|
|
70
|
+
if (npmPrefix) return `npm --prefix ${quoteWindowsArg(npmPrefix)} install -g ${pkg}`;
|
|
68
71
|
return `npm install -g ${pkg}`;
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
function resolveWindowsNpmGlobalPrefix(
|
|
75
|
+
argv1: string | undefined = process.argv[1],
|
|
76
|
+
platform: NodeJS.Platform = process.platform,
|
|
77
|
+
): string | null {
|
|
78
|
+
if (platform !== "win32" || !argv1) return null;
|
|
79
|
+
const normalized = pathWin32.normalize(argv1);
|
|
80
|
+
const marker = `${pathWin32.sep}node_modules${pathWin32.sep}`;
|
|
81
|
+
const index = normalized.toLowerCase().lastIndexOf(marker);
|
|
82
|
+
if (index <= 0) return null;
|
|
83
|
+
const prefix = normalized.slice(0, index);
|
|
84
|
+
// Verify this is a real npm global prefix: such a directory always contains
|
|
85
|
+
// npm's own bin shim (`npm.cmd`) as a sibling of `node_modules/`. Local
|
|
86
|
+
// project `node_modules/`, npx caches, and other non-global layouts do not,
|
|
87
|
+
// so without this check `--prefix` would target the wrong directory.
|
|
88
|
+
if (!existsSync(pathWin32.join(prefix, "npm.cmd"))) return null;
|
|
89
|
+
return prefix;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function quoteWindowsArg(value: string): string {
|
|
93
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
function notifyClaudeRuntimeFloorAdvisory(ctx: ExtensionCommandContext): void {
|
|
72
97
|
let advisory: string | null = null;
|
|
73
98
|
try {
|
|
@@ -576,12 +601,30 @@ export async function handleUpdate(ctx: ExtensionCommandContext, args = ""): Pro
|
|
|
576
601
|
execSync(installCmd, {
|
|
577
602
|
stdio: ["ignore", "pipe", "ignore"],
|
|
578
603
|
});
|
|
604
|
+
let reconcile: ReturnType<typeof reconcileGsdBrowserPathAfterInstall> | null = null;
|
|
605
|
+
if (browserUpdate) {
|
|
606
|
+
try {
|
|
607
|
+
reconcile = reconcileGsdBrowserPathAfterInstall({
|
|
608
|
+
latestVersion: latest,
|
|
609
|
+
compareSemver: compareSemverLocal,
|
|
610
|
+
resolvePathVersion: resolveGsdBrowserPathVersionForCommand,
|
|
611
|
+
});
|
|
612
|
+
} catch {
|
|
613
|
+
// Reconciliation is best-effort: the install above already succeeded,
|
|
614
|
+
// so a reconcile failure must not flip the result to "Update failed".
|
|
615
|
+
reconcile = null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
579
618
|
const newPathVersion = browserUpdate ? resolveGsdBrowserPathVersionForCommand() : null;
|
|
580
|
-
const
|
|
619
|
+
const pathNote = browserUpdate && !(newPathVersion && compareSemverLocal(newPathVersion, latest) >= 0)
|
|
620
|
+
? (reconcile?.message
|
|
621
|
+
?? "Ensure the npm global bin directory is on your PATH so MCP automation uses the updated binary.")
|
|
622
|
+
: "";
|
|
581
623
|
ctx.ui.notify(
|
|
582
624
|
browserUpdate
|
|
583
625
|
? `Updated gsd-browser to v${latest}. Restart your GSD session to use the new browser automation version.` +
|
|
584
|
-
(
|
|
626
|
+
(reconcile?.action === "synced" && reconcile.message ? `\n${reconcile.message}` : "") +
|
|
627
|
+
(pathNote ? `\nNote: ${pathNote}` : "")
|
|
585
628
|
: `Updated to v${latest}. Restart your GSD session to use the new version.`,
|
|
586
629
|
"info",
|
|
587
630
|
);
|
|
@@ -107,6 +107,20 @@ export function hasResearchDecisionQuestion(text: string): boolean {
|
|
|
107
107
|
return hasQuestionMatching(text, [RESEARCH_DECISION_QUESTION_RE]);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Detect a plain-text "Next steps:" menu — numbered options with an "Other"
|
|
112
|
+
* choice — emitted as prose instead of a structured ask_user_questions call.
|
|
113
|
+
* Without this, auto-mode treats the menu as informational and loops on its
|
|
114
|
+
* own turn until tokens are exhausted (#454).
|
|
115
|
+
*/
|
|
116
|
+
export function hasPlainTextNextStepsMenu(lines: string[]): boolean {
|
|
117
|
+
const nextStepsIndex = lines.findIndex((line) => /^next steps\s*:?$/i.test(line));
|
|
118
|
+
if (nextStepsIndex < 0) return false;
|
|
119
|
+
const menuLines = lines.slice(nextStepsIndex + 1);
|
|
120
|
+
const numberedOptions = menuLines.filter((line) => /^\d+[.)]\s+\S/.test(line));
|
|
121
|
+
return numberedOptions.length >= 2 && numberedOptions.some((line) => /\bother\b/i.test(line));
|
|
122
|
+
}
|
|
123
|
+
|
|
110
124
|
// ── Message text extraction (moved from user-input-boundary) ────────────────
|
|
111
125
|
|
|
112
126
|
function extractMessageText(msg: unknown, includeThinking: boolean): string {
|
|
@@ -347,6 +361,7 @@ export function isAwaitingUserInput(messages: unknown[] | undefined): boolean {
|
|
|
347
361
|
if (!text) return false;
|
|
348
362
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
349
363
|
if (lines.some((line) => line.endsWith("?"))) return true;
|
|
364
|
+
if (hasPlainTextNextStepsMenu(lines)) return true;
|
|
350
365
|
return hasApprovalQuestion(text);
|
|
351
366
|
}
|
|
352
367
|
|
|
@@ -5,10 +5,9 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
|
7
7
|
import { loadFile } from "./files.js";
|
|
8
|
-
import { parseRoadmap as parseLegacyRoadmap } from "./parsers-legacy.js";
|
|
9
|
-
import { isDbAvailable, getMilestone } from "./gsd-db.js";
|
|
10
8
|
import { resolveMilestoneFile } from "./paths.js";
|
|
11
|
-
import {
|
|
9
|
+
import { isCompletedMilestoneTerminal } from "./milestone-closeout.js";
|
|
10
|
+
import { deriveState } from "./state.js";
|
|
12
11
|
import { allWorktreesDirs, createWorktree, listWorktrees, resolveGitDir } from "./worktree-manager.js";
|
|
13
12
|
import { abortAndReset } from "./git-self-heal.js";
|
|
14
13
|
import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js";
|
|
@@ -146,22 +145,6 @@ function getSnapshotDiffCheckFailure(basePath: string): string | null {
|
|
|
146
145
|
return failures.length > 0 ? failures.join("\n") : null;
|
|
147
146
|
}
|
|
148
147
|
|
|
149
|
-
async function isCompletedMilestoneTerminal(basePath: string, milestoneId: string): Promise<boolean> {
|
|
150
|
-
const summaryPath = resolveMilestoneFile(basePath, milestoneId, "SUMMARY");
|
|
151
|
-
if (!summaryPath) return false;
|
|
152
|
-
|
|
153
|
-
if (isDbAvailable()) {
|
|
154
|
-
const milestone = getMilestone(milestoneId);
|
|
155
|
-
return !!milestone && milestone.status === "complete";
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
159
|
-
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
160
|
-
if (!roadmapContent) return false;
|
|
161
|
-
const roadmap = parseLegacyRoadmap(roadmapContent);
|
|
162
|
-
return isMilestoneComplete(roadmap);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
148
|
export async function checkGitHealth(
|
|
166
149
|
basePath: string,
|
|
167
150
|
issues: DoctorIssue[],
|
|
@@ -66,7 +66,7 @@ function disabled(description: string, reason: string): string {
|
|
|
66
66
|
|
|
67
67
|
export function buildGsdHomeModel(
|
|
68
68
|
state: GSDState,
|
|
69
|
-
closeout?: Pick<CloseoutContext, "strandedQuick" | "unmergedMilestones">,
|
|
69
|
+
closeout?: Pick<CloseoutContext, "strandedQuick" | "unmergedMilestones" | "idleResidueHint">,
|
|
70
70
|
): GsdHomeModel {
|
|
71
71
|
const blocked = isBlocked(state);
|
|
72
72
|
const complete = state.phase === "complete";
|
|
@@ -74,10 +74,14 @@ export function buildGsdHomeModel(
|
|
|
74
74
|
const workLabel = activeWorkLabel(state);
|
|
75
75
|
const strandedQuick = closeout?.strandedQuick ?? null;
|
|
76
76
|
const unmergedMilestone = closeout?.unmergedMilestones?.[0];
|
|
77
|
+
const idleResidueHint = closeout?.idleResidueHint ?? null;
|
|
78
|
+
const hasIdleResidue = Boolean(idleResidueHint);
|
|
77
79
|
const nextReason = complete
|
|
78
80
|
? "all milestones are complete"
|
|
79
81
|
: blocked
|
|
80
82
|
? "the active milestone is blocked"
|
|
83
|
+
: hasIdleResidue
|
|
84
|
+
? "milestone git residue needs recovery"
|
|
81
85
|
: !hasActiveWork
|
|
82
86
|
? "there is no active milestone"
|
|
83
87
|
: "";
|
|
@@ -91,6 +95,8 @@ export function buildGsdHomeModel(
|
|
|
91
95
|
? "finish_milestone"
|
|
92
96
|
: blocked
|
|
93
97
|
? "fix_recover"
|
|
98
|
+
: hasIdleResidue
|
|
99
|
+
? "fix_recover"
|
|
94
100
|
: canAdvance
|
|
95
101
|
? "continue_step"
|
|
96
102
|
: complete && unmappedActive > 0
|
|
@@ -107,6 +113,8 @@ export function buildGsdHomeModel(
|
|
|
107
113
|
? [`Quick task Q${strandedQuick.taskNum} finished on ${strandedQuick.quickBranch} but is not merged to ${strandedQuick.originalBranch}.`]
|
|
108
114
|
: unmergedMilestone
|
|
109
115
|
? [`${unmergedMilestone.milestoneId} is complete but not merged into ${unmergedMilestone.integrationBranch}.`]
|
|
116
|
+
: idleResidueHint
|
|
117
|
+
? [idleResidueHint.message]
|
|
110
118
|
: completionSummary;
|
|
111
119
|
|
|
112
120
|
return {
|
|
@@ -181,10 +189,12 @@ export function buildGsdHomeModel(
|
|
|
181
189
|
label: "Fix or recover",
|
|
182
190
|
description: blocked
|
|
183
191
|
? "Review the blocker and recovery commands for the active milestone."
|
|
192
|
+
: hasIdleResidue
|
|
193
|
+
? "Review stranded milestone worktrees/branches and run the suggested recovery command."
|
|
184
194
|
: disabled("This becomes active when closeout, validation, or state recovery is needed.", "no blocker is active"),
|
|
185
|
-
enabled: blocked,
|
|
195
|
+
enabled: blocked || hasIdleResidue,
|
|
186
196
|
recommended: recommended === "fix_recover",
|
|
187
|
-
disabledReason: blocked ? undefined : "no blocker is active",
|
|
197
|
+
disabledReason: blocked || hasIdleResidue ? undefined : "no blocker is active",
|
|
188
198
|
},
|
|
189
199
|
{
|
|
190
200
|
id: "start_configure",
|
|
@@ -246,9 +246,9 @@ export function insertMilestone(m: {
|
|
|
246
246
|
status?: string;
|
|
247
247
|
depends_on?: string[];
|
|
248
248
|
planning?: Partial<MilestonePlanningRecord>;
|
|
249
|
-
}):
|
|
249
|
+
}): boolean {
|
|
250
250
|
if (!getDbOrNull()!) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
|
251
|
-
getDbOrNull()!.prepare(
|
|
251
|
+
const result = getDbOrNull()!.prepare(
|
|
252
252
|
`INSERT OR IGNORE INTO milestones (
|
|
253
253
|
id, title, status, depends_on, created_at,
|
|
254
254
|
vision, success_criteria, key_risks, proof_strategy,
|
|
@@ -279,7 +279,8 @@ export function insertMilestone(m: {
|
|
|
279
279
|
":definition_of_done": JSON.stringify(m.planning?.definitionOfDone ?? []),
|
|
280
280
|
":requirement_coverage": m.planning?.requirementCoverage ?? "",
|
|
281
281
|
":boundary_map_markdown": m.planning?.boundaryMapMarkdown ?? "",
|
|
282
|
-
});
|
|
282
|
+
}) as { changes?: number };
|
|
283
|
+
return (result.changes ?? 0) > 0;
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
export function upsertMilestonePlanning(milestoneId: string, planning: Partial<MilestonePlanningRecord> & { title?: string; status?: string; depends_on?: string[] }): void {
|
|
@@ -5,10 +5,20 @@
|
|
|
5
5
|
// - postUnit: git commit, artifact verify, DB settle, then GitHub finalize
|
|
6
6
|
// - recovery: DB repair from artifacts, then GitHub finalize
|
|
7
7
|
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
|
|
8
10
|
import { loadFile } from "./files.js";
|
|
9
11
|
import { resolveMilestoneFile } from "./paths.js";
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getMilestone,
|
|
14
|
+
getClosedSliceIds,
|
|
15
|
+
getLatestAssessmentByScope,
|
|
16
|
+
getMilestoneSlices,
|
|
17
|
+
isDbAvailable,
|
|
18
|
+
} from "./gsd-db.js";
|
|
11
19
|
import { isClosedStatus } from "./status-guards.js";
|
|
20
|
+
import { resolveExpectedArtifactPath } from "./auto-artifact-paths.js";
|
|
21
|
+
import { handleCompleteMilestone } from "./tools/complete-milestone.js";
|
|
12
22
|
import { runSafely } from "./auto-utils.js";
|
|
13
23
|
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
|
|
14
24
|
import { uatSignoffBlockerGuidance } from "./guidance.js";
|
|
@@ -28,6 +38,76 @@ import {
|
|
|
28
38
|
const COMPLETE_MILESTONE_DB_SETTLE_MS = 1500;
|
|
29
39
|
const COMPLETE_MILESTONE_DB_SETTLE_POLL_MS = 100;
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* True when a milestone is terminal for git cleanup (orphaned worktrees, stale branches).
|
|
43
|
+
* DB-authoritative (ADR-017): closed status, or validation-pass with all slices closed.
|
|
44
|
+
* When the DB is unavailable we cannot make this decision and conservatively
|
|
45
|
+
* return false so callers leave the worktree/branch alone instead of cleaning
|
|
46
|
+
* up based on parsed projections.
|
|
47
|
+
*/
|
|
48
|
+
export async function isCompletedMilestoneTerminal(
|
|
49
|
+
_basePath: string,
|
|
50
|
+
milestoneId: string,
|
|
51
|
+
): Promise<boolean> {
|
|
52
|
+
if (!isDbAvailable()) return false;
|
|
53
|
+
|
|
54
|
+
const milestone = getMilestone(milestoneId);
|
|
55
|
+
if (!milestone) return false;
|
|
56
|
+
|
|
57
|
+
if (isClosedStatus(milestone.status)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const validation = getLatestAssessmentByScope(milestoneId, "milestone-validation");
|
|
62
|
+
if (validation?.status !== "pass") {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const slices = getMilestoneSlices(milestoneId);
|
|
67
|
+
if (slices.length === 0) return false;
|
|
68
|
+
return slices.every((slice) => isClosedStatus(slice.status));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Write a missing milestone SUMMARY projection when canonical DB closeout already settled. */
|
|
72
|
+
export async function repairMissingMilestoneSummaryProjection(
|
|
73
|
+
basePath: string,
|
|
74
|
+
milestoneId: string,
|
|
75
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
76
|
+
const milestone = getMilestone(milestoneId);
|
|
77
|
+
if (!milestone) {
|
|
78
|
+
return { ok: false, error: `milestone not found: ${milestoneId}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const artifactBasePath = resolveCanonicalMilestoneRoot(basePath, milestoneId);
|
|
82
|
+
const summaryPath = resolveExpectedArtifactPath("complete-milestone", milestoneId, artifactBasePath);
|
|
83
|
+
if (summaryPath && existsSync(summaryPath)) {
|
|
84
|
+
return { ok: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await handleCompleteMilestone(
|
|
88
|
+
{
|
|
89
|
+
milestoneId,
|
|
90
|
+
title: milestone.title,
|
|
91
|
+
oneLiner: "Canonical closeout completed; summary projection repaired automatically.",
|
|
92
|
+
narrative:
|
|
93
|
+
"The workflow database recorded this milestone as complete, but the milestone SUMMARY artifact was missing on disk. " +
|
|
94
|
+
"Dispatch policy repaired the projection so closeout proof and cleanup can proceed.",
|
|
95
|
+
verificationPassed: true,
|
|
96
|
+
triggerReason: "closeout-projection-repair",
|
|
97
|
+
},
|
|
98
|
+
basePath,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if ("error" in result) {
|
|
102
|
+
return { ok: false, error: result.error };
|
|
103
|
+
}
|
|
104
|
+
const writtenSummaryPath = result.summaryPath;
|
|
105
|
+
if (result.stale || !writtenSummaryPath || !existsSync(writtenSummaryPath)) {
|
|
106
|
+
return { ok: false, error: "milestone SUMMARY projection write failed" };
|
|
107
|
+
}
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
31
111
|
/**
|
|
32
112
|
* True when the milestone is closed in the DB and the completion summary artifact exists.
|
|
33
113
|
* Polls briefly so post-unit verification can observe the tool's DB write.
|
|
@@ -78,7 +158,22 @@ export async function evaluateCompleteMilestoneDispatch(
|
|
|
78
158
|
if (isDbAvailable()) {
|
|
79
159
|
const milestone = getMilestone(mid);
|
|
80
160
|
if (milestone && isClosedStatus(milestone.status)) {
|
|
81
|
-
|
|
161
|
+
const artifactBasePath = resolveCanonicalMilestoneRoot(basePath, mid);
|
|
162
|
+
const summaryPath = resolveExpectedArtifactPath("complete-milestone", mid, artifactBasePath);
|
|
163
|
+
const summaryMissing = !summaryPath || !existsSync(summaryPath);
|
|
164
|
+
if (summaryMissing) {
|
|
165
|
+
const repair = await repairMissingMilestoneSummaryProjection(basePath, mid);
|
|
166
|
+
if (!repair.ok) {
|
|
167
|
+
logWarning(
|
|
168
|
+
"dispatch",
|
|
169
|
+
`Milestone ${mid} is closed in DB but SUMMARY repair failed: ${repair.error}. Dispatching complete-milestone to retry.`,
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
return { action: "skip" };
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
return { action: "skip" };
|
|
176
|
+
}
|
|
82
177
|
}
|
|
83
178
|
}
|
|
84
179
|
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
|
|
10
10
|
import { ModelPolicyDispatchBlockedError, resolvePreferredModelConfig, resolveModelId, selectAndApplyModel, floorThinkingLevelForUnit } from "../auto-model-selection.js";
|
|
11
|
+
import { blockModelUntil, clearTemporaryModelBlocksForTest } from "../blocked-models.ts";
|
|
11
12
|
|
|
12
13
|
function makeTempDir(prefix: string): string {
|
|
13
14
|
return mkdtempSync(join(tmpdir(), prefix));
|
|
@@ -223,6 +224,74 @@ test("selectAndApplyModel honors explicit phase models without downgrading (#361
|
|
|
223
224
|
}
|
|
224
225
|
});
|
|
225
226
|
|
|
227
|
+
test("selectAndApplyModel skips a rate-limited primary until its reset time", async () => {
|
|
228
|
+
const originalCwd = process.cwd();
|
|
229
|
+
const originalGsdHome = process.env.GSD_HOME;
|
|
230
|
+
const tempProject = makeTempDir("gsd-rate-limit-fallback-project-");
|
|
231
|
+
const tempGsdHome = makeTempDir("gsd-rate-limit-fallback-home-");
|
|
232
|
+
const setModelCalls: string[] = [];
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
clearTemporaryModelBlocksForTest();
|
|
236
|
+
mkdirSync(join(tempProject, ".gsd"), { recursive: true });
|
|
237
|
+
writeFileSync(
|
|
238
|
+
join(tempProject, ".gsd", "PREFERENCES.md"),
|
|
239
|
+
[
|
|
240
|
+
"---",
|
|
241
|
+
"models:",
|
|
242
|
+
" execution:",
|
|
243
|
+
" model: gpt-5.5",
|
|
244
|
+
" provider: openai-codex",
|
|
245
|
+
" fallbacks:",
|
|
246
|
+
" - anthropic/claude-sonnet-4-6",
|
|
247
|
+
"---",
|
|
248
|
+
].join("\n"),
|
|
249
|
+
"utf-8",
|
|
250
|
+
);
|
|
251
|
+
process.env.GSD_HOME = tempGsdHome;
|
|
252
|
+
process.chdir(tempProject);
|
|
253
|
+
|
|
254
|
+
const availableModels = [
|
|
255
|
+
{ id: "gpt-5.5", provider: "openai-codex", api: "responses" },
|
|
256
|
+
{ id: "claude-sonnet-4-6", provider: "anthropic", api: "anthropic-messages" },
|
|
257
|
+
];
|
|
258
|
+
const ctx = {
|
|
259
|
+
modelRegistry: { getAvailable: () => availableModels },
|
|
260
|
+
sessionManager: { getSessionId: () => "test-session" },
|
|
261
|
+
ui: { notify: () => {} },
|
|
262
|
+
model: { provider: "openai-codex", id: "gpt-5.5", api: "responses" },
|
|
263
|
+
} as any;
|
|
264
|
+
const pi = {
|
|
265
|
+
setModel: async (model: { provider: string; id: string }) => {
|
|
266
|
+
setModelCalls.push(`${model.provider}/${model.id}`);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
emitBeforeModelSelect: async () => undefined,
|
|
270
|
+
getActiveTools: () => [],
|
|
271
|
+
emitAdjustToolSet: async () => undefined,
|
|
272
|
+
setActiveTools: () => {},
|
|
273
|
+
} as any;
|
|
274
|
+
|
|
275
|
+
blockModelUntil(tempProject, "openai-codex", "gpt-5.5", Date.now() + 60_000, "session limit");
|
|
276
|
+
await selectAndApplyModel(ctx, pi, "execute-task", "M001/S01/T01", tempProject, undefined, false, { provider: "openai-codex", id: "gpt-5.5" }, undefined, true);
|
|
277
|
+
|
|
278
|
+
blockModelUntil(tempProject, "openai-codex", "gpt-5.5", Date.now() - 1, "expired");
|
|
279
|
+
await selectAndApplyModel(ctx, pi, "execute-task", "M001/S01/T02", tempProject, undefined, false, { provider: "openai-codex", id: "gpt-5.5" }, undefined, true);
|
|
280
|
+
|
|
281
|
+
assert.deepEqual(setModelCalls, [
|
|
282
|
+
"anthropic/claude-sonnet-4-6",
|
|
283
|
+
"openai-codex/gpt-5.5",
|
|
284
|
+
]);
|
|
285
|
+
} finally {
|
|
286
|
+
clearTemporaryModelBlocksForTest();
|
|
287
|
+
process.chdir(originalCwd);
|
|
288
|
+
if (originalGsdHome === undefined) delete process.env.GSD_HOME;
|
|
289
|
+
else process.env.GSD_HOME = originalGsdHome;
|
|
290
|
+
rmSync(tempProject, { recursive: true, force: true });
|
|
291
|
+
rmSync(tempGsdHome, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
226
295
|
test("selectAndApplyModel lets explicit unit models bypass stale cross-provider lock (#116)", async () => {
|
|
227
296
|
const originalCwd = process.cwd();
|
|
228
297
|
const originalGsdHome = process.env.GSD_HOME;
|
|
@@ -1223,6 +1223,103 @@ test("decideOrchestratorDispatch forwards constructor session when advance input
|
|
|
1223
1223
|
}
|
|
1224
1224
|
});
|
|
1225
1225
|
|
|
1226
|
+
test("decideOrchestratorDispatch evaluates deep pre-planning rules without an active milestone", async (t) => {
|
|
1227
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-no-active-"));
|
|
1228
|
+
t.after(() => {
|
|
1229
|
+
resetRegistry();
|
|
1230
|
+
rmSync(base, { recursive: true, force: true });
|
|
1231
|
+
});
|
|
1232
|
+
resetRegistry();
|
|
1233
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
1234
|
+
writeFileSync(
|
|
1235
|
+
join(base, ".gsd", "PREFERENCES.md"),
|
|
1236
|
+
[
|
|
1237
|
+
"---",
|
|
1238
|
+
"planning_depth: deep",
|
|
1239
|
+
"workflow_prefs_captured: true",
|
|
1240
|
+
"---",
|
|
1241
|
+
"",
|
|
1242
|
+
].join("\n"),
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
const stateSnapshot: GSDState = {
|
|
1246
|
+
...makeState(),
|
|
1247
|
+
activeMilestone: null,
|
|
1248
|
+
phase: "pre-planning",
|
|
1249
|
+
nextAction: "All remaining milestones are parked (M027). Run /gsd unpark M027 or create a new milestone.",
|
|
1250
|
+
registry: [{ id: "M027", title: "Parked", status: "parked" }],
|
|
1251
|
+
};
|
|
1252
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1253
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1254
|
+
const session = {
|
|
1255
|
+
basePath: base,
|
|
1256
|
+
originalBasePath: base,
|
|
1257
|
+
currentMilestoneId: "M027",
|
|
1258
|
+
} as never;
|
|
1259
|
+
|
|
1260
|
+
const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
|
|
1261
|
+
|
|
1262
|
+
assert.ok(result && "unitType" in result, `expected project-level dispatch, got ${JSON.stringify(result)}`);
|
|
1263
|
+
assert.equal(result.unitType, "discuss-project");
|
|
1264
|
+
assert.equal(result.unitId, "PROJECT");
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("decideOrchestratorDispatch does not replay milestone-scoped verification retry when no milestone is active", async (t) => {
|
|
1268
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-no-active-retry-"));
|
|
1269
|
+
t.after(() => {
|
|
1270
|
+
resetRegistry();
|
|
1271
|
+
rmSync(base, { recursive: true, force: true });
|
|
1272
|
+
});
|
|
1273
|
+
resetRegistry();
|
|
1274
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
1275
|
+
writeFileSync(
|
|
1276
|
+
join(base, ".gsd", "PREFERENCES.md"),
|
|
1277
|
+
[
|
|
1278
|
+
"---",
|
|
1279
|
+
"planning_depth: deep",
|
|
1280
|
+
"workflow_prefs_captured: true",
|
|
1281
|
+
"---",
|
|
1282
|
+
"",
|
|
1283
|
+
].join("\n"),
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
const stateSnapshot: GSDState = {
|
|
1287
|
+
...makeState(),
|
|
1288
|
+
activeMilestone: null,
|
|
1289
|
+
phase: "pre-planning",
|
|
1290
|
+
nextAction: "All remaining milestones are parked (M027). Run /gsd unpark M027 or create a new milestone.",
|
|
1291
|
+
registry: [{ id: "M027", title: "Parked", status: "parked" }],
|
|
1292
|
+
};
|
|
1293
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1294
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1295
|
+
const stalePendingRetry = {
|
|
1296
|
+
unitType: "execute-task",
|
|
1297
|
+
unitId: "M027.S1.T1",
|
|
1298
|
+
prompt: "stale retry prompt",
|
|
1299
|
+
pauseAfterUatDispatch: false,
|
|
1300
|
+
state: stateSnapshot,
|
|
1301
|
+
mid: "M027",
|
|
1302
|
+
midTitle: "Parked",
|
|
1303
|
+
};
|
|
1304
|
+
const session = {
|
|
1305
|
+
basePath: base,
|
|
1306
|
+
originalBasePath: base,
|
|
1307
|
+
currentMilestoneId: "M027",
|
|
1308
|
+
pendingVerificationRetryDispatch: stalePendingRetry,
|
|
1309
|
+
} as never;
|
|
1310
|
+
|
|
1311
|
+
const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
|
|
1312
|
+
|
|
1313
|
+
assert.ok(result && "unitType" in result, `expected project-level dispatch, got ${JSON.stringify(result)}`);
|
|
1314
|
+
assert.equal(result.unitType, "discuss-project");
|
|
1315
|
+
assert.equal(result.unitId, "PROJECT");
|
|
1316
|
+
// The stale retry must be preserved for a future tick, not consumed by this
|
|
1317
|
+
// no-active-milestone path (mirrors pre-#712-fix behavior where !active
|
|
1318
|
+
// returned null before touching the retry).
|
|
1319
|
+
const sess = session as unknown as { pendingVerificationRetryDispatch: unknown };
|
|
1320
|
+
assert.equal(sess.pendingVerificationRetryDispatch, stalePendingRetry);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1226
1323
|
test("decideOrchestratorDispatch adopts next active milestone after the session milestone is closed", async (t) => {
|
|
1227
1324
|
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-milestone-adopt-"));
|
|
1228
1325
|
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
@@ -8,7 +8,10 @@ import { join } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
blockModel,
|
|
11
|
+
blockModelUntil,
|
|
12
|
+
clearTemporaryModelBlocksForTest,
|
|
11
13
|
isModelBlocked,
|
|
14
|
+
isModelTemporarilyUnavailable,
|
|
12
15
|
loadBlockedModels,
|
|
13
16
|
} from "../blocked-models.ts";
|
|
14
17
|
|
|
@@ -96,3 +99,19 @@ test("blocked-models: file created under .gsd/runtime/", () => {
|
|
|
96
99
|
rmSync(base, { recursive: true, force: true });
|
|
97
100
|
}
|
|
98
101
|
});
|
|
102
|
+
|
|
103
|
+
test("blocked-models: temporary rate-limit blocks expire without persisting", () => {
|
|
104
|
+
const base = mkBase();
|
|
105
|
+
try {
|
|
106
|
+
clearTemporaryModelBlocksForTest();
|
|
107
|
+
blockModelUntil(base, "openai-codex", "gpt-5.5", Date.now() + 60_000, "session limit");
|
|
108
|
+
assert.equal(isModelTemporarilyUnavailable(base, "openai-codex", "gpt-5.5"), true);
|
|
109
|
+
assert.equal(loadBlockedModels(base).length, 0, "rate-limit windows must not persist as account blocks");
|
|
110
|
+
|
|
111
|
+
blockModelUntil(base, "openai-codex", "gpt-5.5", Date.now() - 1, "expired");
|
|
112
|
+
assert.equal(isModelTemporarilyUnavailable(base, "openai-codex", "gpt-5.5"), false);
|
|
113
|
+
} finally {
|
|
114
|
+
clearTemporaryModelBlocksForTest();
|
|
115
|
+
rmSync(base, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
@@ -238,6 +238,21 @@ test("isAwaitingUserInput does not trigger on thinking-block approval phrases",
|
|
|
238
238
|
assert.equal(shouldPauseForQuestion("discuss-requirements", messages), false);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
test("isAwaitingUserInput treats plain-text next steps menus as waiting for the user (#454)", () => {
|
|
242
|
+
const messages = [
|
|
243
|
+
{
|
|
244
|
+
role: "assistant",
|
|
245
|
+
content: [
|
|
246
|
+
"Next steps:",
|
|
247
|
+
"1. Walk through the runtime placement check above.",
|
|
248
|
+
"2. Build a release once you're satisfied.",
|
|
249
|
+
"3. Other.",
|
|
250
|
+
].join("\n"),
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
assert.equal(isAwaitingUserInput(messages), true);
|
|
254
|
+
});
|
|
255
|
+
|
|
241
256
|
test("isAwaitingUserInput still triggers on text-block question marks when thinking is also present", () => {
|
|
242
257
|
// When thinking + text are both present and the text asks a question, it should still pause.
|
|
243
258
|
const messages = [
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Project/App: gsd-pi
|
|
2
|
+
// File Purpose: Doctor git checks treat validation-pass closeout as terminal without SUMMARY.
|
|
3
|
+
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { runGSDDoctor } from "../doctor.ts";
|
|
12
|
+
import { openDatabase, insertMilestone, insertSlice, insertAssessment, closeDatabase } from "../gsd-db.js";
|
|
13
|
+
import { createWorktree, worktreePath } from "../worktree-manager.ts";
|
|
14
|
+
|
|
15
|
+
function runGit(args: string[], cwd: string): string {
|
|
16
|
+
return execFileSync("git", args, {
|
|
17
|
+
cwd,
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
}).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeRepo(): string {
|
|
24
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-terminal-"));
|
|
25
|
+
runGit(["init", "-b", "main"], base);
|
|
26
|
+
runGit(["config", "user.name", "Test User"], base);
|
|
27
|
+
runGit(["config", "user.email", "test@example.com"], base);
|
|
28
|
+
writeFileSync(join(base, "package.json"), "{\"scripts\":{}}\n", "utf-8");
|
|
29
|
+
runGit(["add", "."], base);
|
|
30
|
+
runGit(["commit", "-m", "chore: init"], base);
|
|
31
|
+
return base;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test.after(() => {
|
|
35
|
+
closeDatabase();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("doctor flags orphaned worktree for DB-complete milestone without SUMMARY", async (t) => {
|
|
39
|
+
const base = makeRepo();
|
|
40
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
41
|
+
|
|
42
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
43
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
44
|
+
insertMilestone({ id: "M008", title: "Done", status: "complete" });
|
|
45
|
+
insertSlice({ id: "S01", milestoneId: "M008", title: "Slice", status: "complete" });
|
|
46
|
+
insertAssessment({
|
|
47
|
+
path: "milestones/M008/M008-VALIDATION.md",
|
|
48
|
+
milestoneId: "M008",
|
|
49
|
+
status: "pass",
|
|
50
|
+
scope: "milestone-validation",
|
|
51
|
+
fullContent: "verdict: pass",
|
|
52
|
+
});
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(base, ".gsd", "PREFERENCES.md"),
|
|
55
|
+
"---\ngit:\n isolation: worktree\n---\n",
|
|
56
|
+
);
|
|
57
|
+
mkdirSync(join(base, ".gsd", "milestones", "M008"), { recursive: true });
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(base, ".gsd", "milestones", "M008", "M008-ROADMAP.md"),
|
|
60
|
+
"# M008 Roadmap\n\n- [x] **S01: Slice** `risk:low` `depends:[]`\n",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
createWorktree(base, "M008", { branch: "milestone/M008" });
|
|
64
|
+
const wtPath = worktreePath(base, "M008");
|
|
65
|
+
assert.ok(existsSync(wtPath), "worktree should exist for the test");
|
|
66
|
+
|
|
67
|
+
const report = await runGSDDoctor(base, { isolationMode: "worktree" });
|
|
68
|
+
|
|
69
|
+
assert.ok(
|
|
70
|
+
report.issues.some((issue) => issue.code === "orphaned_auto_worktree" && issue.unitId === "M008"),
|
|
71
|
+
"doctor should treat DB-complete milestone without SUMMARY as terminal for cleanup",
|
|
72
|
+
);
|
|
73
|
+
});
|