Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/user-docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| `/gsd discuss` | Discuss architecture and decisions (stop auto-mode first with `/gsd stop`) |
| `/gsd status` | Open the status dashboard |
| `/gsd widget` | Cycle dashboard widget: full / small / min / off |
| `/gsd planner [MID]` | Open Planner to review or customize a planned milestone before implementation |
| `/gsd notifications` | View, filter, and clear persistent notification history |
| `/gsd queue` | Queue and reorder future milestones (`pending`, `queued`, and legacy `planned`; safe during auto mode) |
| `/gsd capture` | Fire-and-forget thought capture (works during auto mode) |
Expand Down
1 change: 1 addition & 0 deletions docs/zh-CN/user-docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
| `/gsd discuss` | 讨论架构和决策(需先用 `/gsd stop` 停止自动模式) |
| `/gsd status` | 进度仪表板 |
| `/gsd widget` | 循环切换仪表板组件:full / small / min / off |
| `/gsd planner [MID]` | 打开 Planner,在实施前审查或自定义已规划的 milestone |
| `/gsd queue` | 给未来 milestones 排队和重排(自动模式中也安全) |
| `/gsd capture` | 随手记录一个想法,不打断当前流程(自动模式中可用) |
| `/gsd triage` | 手动触发待处理 captures 的 triage |
Expand Down
5 changes: 5 additions & 0 deletions src/cli-web-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface CliFlags {
web?: boolean
/** Optional project path for web mode: `gsd --web <path>` or `gsd web start <path>` */
webPath?: string
/** Internal initial browser route for web mode launchers. */
webInitialPath?: string
/** Custom host to bind web server to: `--host 0.0.0.0` */
webHost?: string
/** Custom port for web server: `--port 8080` */
Expand Down Expand Up @@ -71,6 +73,8 @@ export function parseCliArgs(argv: string[]): CliFlags {
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
flags.webPath = args[++i]
}
} else if (arg === '--web-initial-path' && i + 1 < args.length) {
flags.webInitialPath = args[++i]
} else if (arg === '--host' && i + 1 < args.length) {
flags.webHost = args[++i]
} else if (arg === '--port' && i + 1 < args.length) {
Expand Down Expand Up @@ -299,6 +303,7 @@ export async function runWebCliBranch(
host: flags.webHost,
port: flags.webPort,
allowedOrigins: flags.webAllowedOrigins,
...(flags.webInitialPath ? { initialPath: flags.webInitialPath } : {}),
}
if (flags.webNoAuth !== undefined) {
launchOptions.noAuth = flags.webNoAuth
Expand Down
7 changes: 6 additions & 1 deletion src/resources/extensions/gsd/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;

export const GSD_COMMAND_DESCRIPTION =
"GSD — Git Ship Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|brief|report|queue|quick|discuss|capture|triage|dispatch|verdict|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|closeout|rebuild|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|debug|logs|forensics|changelog|migrate|remote|steer|knowledge|memory|new-milestone|new-project|parallel|cmux|park|unpark|init|setup|onboarding|inspect|extensions|update|upgrade|fast|mcp|rethink|workflow|codebase|notifications|ship|do|usage|context|session-report|backlog|pr-branch|add-tests|scan|language|worktree|eval-review";
"GSD — Git Ship Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|planner|brief|report|queue|quick|discuss|capture|triage|dispatch|verdict|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|closeout|rebuild|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|debug|logs|forensics|changelog|migrate|remote|steer|knowledge|memory|new-milestone|new-project|parallel|cmux|park|unpark|init|setup|onboarding|inspect|extensions|update|upgrade|fast|mcp|rethink|workflow|codebase|notifications|ship|do|usage|context|session-report|backlog|pr-branch|add-tests|scan|language|worktree|eval-review";

export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "help", desc: "Categorized command reference with descriptions" },
Expand All @@ -28,6 +28,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "status", desc: "Open the status dashboard" },
{ cmd: "widget", desc: "Cycle widget: full → small → min → off" },
{ cmd: "visualize", desc: "Open 10-tab workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)" },
{ cmd: "planner", desc: "Open Planner to review or customize the current plan before implementation" },
{ cmd: "brief", desc: "Generate a visual HTML brief: diagram, plan, diff review, recap, table, or slides" },
{ cmd: "queue", desc: "Queue and reorder future milestones" },
{ cmd: "quick", desc: "Execute a quick task without full planning overhead" },
Expand Down Expand Up @@ -331,6 +332,10 @@ const NESTED_COMPLETIONS: CompletionMap = {
{ cmd: "--text", desc: "Plain-text breakdown" },
{ cmd: "--json", desc: "Machine-readable JSON output" },
],
planner: [
{ cmd: "--dry-run", desc: "Preview the built-in Planner route without opening it" },
{ cmd: "M001", desc: "Open Planner for a specific milestone ID" },
],
backlog: [
{ cmd: "add", desc: "Add item to backlog" },
{ cmd: "promote", desc: "Promote backlog item to active slice" },
Expand Down
110 changes: 110 additions & 0 deletions src/resources/extensions/gsd/commands/handlers/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ import { getVisualBriefOutputDir } from "../../../visual-brief/artifact-policy.j
import { buildVisualBriefPrompt, parseVisualBriefArgs, VISUAL_BRIEF_USAGE } from "../../../visual-brief/prompts.js";
import { GSD_CORE_IMPLEMENTED_CATALOG } from "../../commands-gsd-core.js";
import { GSD_CORE_ALIAS_CATALOG } from "../gsd-core-aliases.js";
import {
buildGsdPlannerLaunchPlan,
formatGsdPlannerLaunchTarget,
formatPlannerLaunchUnavailable,
LEGACY_GSD_PLANNER_COMMAND,
launchGsdPlanner,
markPlannerHandoffOffered,
} from "../../planner-handoff.js";

export function showHelp(ctx: ExtensionCommandContext, args = ""): void {
const summaryLines = [
Expand All @@ -33,6 +41,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void {
` /gsd status Status dashboard (${formattedShortcutPair("dashboard")})`,
` /gsd parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`,
` /gsd notifications Notification history (${formattedShortcutPair("notifications")})`,
" /gsd planner Open Planner to review the current plan",
" /gsd visualize Workflow visualizer",
" /gsd report Generate all HTML reports and open browser",
" /gsd brief <mode> Visual HTML brief (diagram, plan, diff, recap, table, slides)",
Expand Down Expand Up @@ -81,6 +90,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void {
" /gsd stop Stop auto-mode gracefully",
" /gsd pause Pause auto-mode (preserves state, /gsd auto to resume)",
" /gsd discuss Start guided milestone/slice discussion",
" /gsd planner Open Planner to customize a planned milestone before implementation",
" /gsd new-milestone Create milestone from headless context (used by gsd headless)",
" /gsd new-project Bootstrap a new project (use --deep for staged project-level discovery)",
" /gsd quick Execute a quick task without full planning overhead",
Expand All @@ -92,6 +102,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void {
"VISIBILITY",
` /gsd status Status dashboard (${formattedShortcutPair("dashboard")})`,
` /gsd parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`,
" /gsd planner Open Planner for plan review/customization [Mxxx] [--dry-run]",
" /gsd widget Cycle status widget [full|small|min|off]",
" /gsd visualize Workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
" /gsd brief <mode> Generate a visual HTML brief [diagram|plan|diff|recap|table|slides] [topic] [--slides]",
Expand Down Expand Up @@ -506,6 +517,101 @@ async function handleModel(
ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}${pinNote}`, "info");
}

const PLANNER_MILESTONE_RE = /^M\d+(?:-[a-z0-9]{6})?$/i;

function normalizePlannerMilestone(value: string | undefined): string | null {
const trimmed = value?.trim();
return trimmed && PLANNER_MILESTONE_RE.test(trimmed) ? trimmed.toUpperCase() : null;
}

function plannerArgValue(parts: string[], idx: number): string | undefined {
const value = parts[idx + 1];
return value && !value.startsWith("--") ? value : undefined;
}

function parsePlannerArgs(args: string): { dryRun: boolean; milestoneId: string | null } {
const parts = args.trim().split(/\s+/).filter(Boolean);
let dryRun = false;
let milestoneId: string | null = null;

for (let idx = 0; idx < parts.length; idx++) {
const part = parts[idx];
if (!part) continue;
if (idx === 0 && part === LEGACY_GSD_PLANNER_COMMAND) {
continue;
}
if (part === "--dry-run") {
dryRun = true;
continue;
}
if (part === "--project") {
if (plannerArgValue(parts, idx)) idx++;
continue;
}
if (part.startsWith("--project=")) {
continue;
}
if (part === "--milestone") {
const value = plannerArgValue(parts, idx);
const parsedMilestoneId = normalizePlannerMilestone(value);
if (!milestoneId && parsedMilestoneId) milestoneId = parsedMilestoneId;
if (value) idx++;
continue;
}
if (part.startsWith("--milestone=")) {
const parsedMilestoneId = normalizePlannerMilestone(part.slice("--milestone=".length));
if (!milestoneId && parsedMilestoneId) milestoneId = parsedMilestoneId;
continue;
}
const parsedMilestoneId = normalizePlannerMilestone(part);
if (!milestoneId && parsedMilestoneId) {
milestoneId = parsedMilestoneId;
continue;
}
}

return { dryRun, milestoneId };
}

async function handlePlanner(args: string, ctx: ExtensionCommandContext): Promise<void> {
const basePath = projectRoot();
const parsed = parsePlannerArgs(args);
let milestoneId = parsed.milestoneId;
if (!milestoneId) {
const state = await deriveState(basePath);
milestoneId = state.activeMilestone?.id ?? null;
}
const plan = buildGsdPlannerLaunchPlan({
basePath,
milestoneId,
});

if (parsed.dryRun) {
ctx.ui.notify(formatGsdPlannerLaunchTarget(plan), "info");
return;
}

const result = await launchGsdPlanner({
basePath,
milestoneId,
});

if (result.status === "failed") {
ctx.ui.notify(formatPlannerLaunchUnavailable(result.plan, result.error), "warning");
return;
}

if (milestoneId) {
markPlannerHandoffOffered(basePath, milestoneId, "command");
}
ctx.ui.notify(
milestoneId
? `Opened Planner for ${milestoneId}. Run /gsd auto when you are ready to continue.`
: "Opened Planner. Run /gsd auto when you are ready to continue.",
"info",
);
}

export async function handleCoreCommand(
trimmed: string,
ctx: ExtensionCommandContext,
Expand Down Expand Up @@ -609,6 +715,10 @@ export async function handleCoreCommand(
await handleOnboarding(trimmed.replace(/^onboarding\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "planner" || trimmed.startsWith("planner ")) {
await handlePlanner(trimmed.replace(/^planner\s*/, "").trim(), ctx);
return true;
}
return false;
}

Expand Down
74 changes: 54 additions & 20 deletions src/resources/extensions/gsd/planner-handoff.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
// Project/App: gsd-pi
// File Purpose: Optional gsd-planner handoff after milestone planning.
// File Purpose: Optional built-in planner handoff after milestone planning.

import { spawn as spawnChild, type ChildProcess, type SpawnOptions } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";

import { gsdRoot } from "./paths.js";

export const PLANNER_HANDOFF_RULE_NAME = "planning review handoff -> gsd-planner";
export const GSD_PLANNER_COMMAND = "gsd-planner";
export const PLANNER_HANDOFF_RULE_NAME = "planning review handoff -> /gsd planner";
export const GSD_PLANNER_VIEW = "planner";
export const LEGACY_GSD_PLANNER_COMMAND = "gsd-planner";
export const GSD_WEB_INITIAL_PATH_FLAG = "--web-initial-path";

export interface GsdPlannerSpawnPlan {
export interface GsdLauncherSpec {
command: string;
baseArgs: string[];
}

export interface GsdPlannerLaunchPlan {
command: string;
args: string[];
cwd: string;
initialPath: string;
milestoneId: string | null;
}

export interface GsdPlannerLaunchInput {
basePath: string;
milestoneId?: string | null;
extraArgs?: string[];
}

export type GsdPlannerLaunchResult =
| { status: "launched"; plan: GsdPlannerSpawnPlan }
| { status: "failed"; plan: GsdPlannerSpawnPlan; error: Error };
| { status: "launched"; plan: GsdPlannerLaunchPlan }
| { status: "failed"; plan: GsdPlannerLaunchPlan; error: Error };

type SpawnLike = (
command: string,
Expand All @@ -33,6 +41,7 @@ type SpawnLike = (
) => ChildProcess;

export interface GsdPlannerLaunchDeps {
launcher?: GsdLauncherSpec;
spawn?: SpawnLike;
}

Expand Down Expand Up @@ -69,31 +78,55 @@ export function markPlannerHandoffOffered(
);
}

export function buildGsdPlannerSpawnPlan(input: GsdPlannerLaunchInput): GsdPlannerSpawnPlan {
const args = ["--project", input.basePath];
export function buildGsdPlannerInitialPath(milestoneId?: string | null): string {
const params = new URLSearchParams({ view: GSD_PLANNER_VIEW });
const normalizedMilestoneId = milestoneId?.trim();
if (normalizedMilestoneId) params.set("milestone", normalizedMilestoneId);
return `/?${params.toString()}`;
}

function resolveCurrentGsdLauncher(): GsdLauncherSpec {
const entrypoint = process.argv[1];
if (entrypoint) {
return {
command: process.execPath,
baseArgs: [entrypoint],
};
}
return {
command: "gsd",
baseArgs: [],
};
}

export function buildGsdPlannerLaunchPlan(
input: GsdPlannerLaunchInput,
launcher: GsdLauncherSpec = resolveCurrentGsdLauncher(),
): GsdPlannerLaunchPlan {
const milestoneId = input.milestoneId?.trim();
if (milestoneId) args.push("--milestone", milestoneId);
args.push(...(input.extraArgs ?? []));
const initialPath = buildGsdPlannerInitialPath(milestoneId);
return {
command: GSD_PLANNER_COMMAND,
args,
command: launcher.command,
args: [...launcher.baseArgs, "--web", input.basePath, GSD_WEB_INITIAL_PATH_FLAG, initialPath],
cwd: input.basePath,
initialPath,
milestoneId: milestoneId || null,
};
}

function quoteArg(arg: string): string {
return /^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : JSON.stringify(arg);
}

export function formatGsdPlannerCommand(plan: GsdPlannerSpawnPlan): string {
return [plan.command, ...plan.args].map(quoteArg).join(" ");
export function formatGsdPlannerLaunchTarget(plan: GsdPlannerLaunchPlan): string {
return `GSD Planner route: ${plan.initialPath}`;
}

export async function launchGsdPlanner(
input: GsdPlannerLaunchInput,
deps: GsdPlannerLaunchDeps = {},
): Promise<GsdPlannerLaunchResult> {
const plan = buildGsdPlannerSpawnPlan(input);
const plan = buildGsdPlannerLaunchPlan(input, deps.launcher);
const spawn = deps.spawn ?? spawnChild;

let child: ChildProcess;
Expand Down Expand Up @@ -137,13 +170,14 @@ export async function launchGsdPlanner(
export function formatPlannerHandoffPauseReason(milestoneId: string): string {
return [
`Milestone ${milestoneId} is planned. Review or customize the plan before implementation if needed.`,
`Run /gsd planner to launch ${GSD_PLANNER_COMMAND}, or run /gsd auto to continue without planner changes.`,
"Run /gsd planner to open the built-in Planner, or run /gsd auto to continue without planner changes.",
].join(" ");
}

export function formatPlannerLaunchUnavailable(plan: GsdPlannerSpawnPlan, error: Error): string {
export function formatPlannerLaunchUnavailable(plan: GsdPlannerLaunchPlan, error: Error): string {
return [
`Could not launch ${GSD_PLANNER_COMMAND}: ${error.message}`,
`Install ${GSD_PLANNER_COMMAND} or run it manually: ${formatGsdPlannerCommand(plan)}`,
`Could not launch GSD Planner: ${error.message}`,
`Open the built-in web app manually: ${["gsd", "--web", plan.cwd, GSD_WEB_INITIAL_PATH_FLAG, plan.initialPath].map(quoteArg).join(" ")}`,
"Continue without planner edits: /gsd auto",
].join("\n");
}
Loading
Loading