fix: align v1→v2 commands with upstream types, remove engine-dependent slice mutations

- Fix ProjectTotals field names (totalCost→cost, totalDuration→duration, etc.)
- Fix UnitMetrics field names (status→finishedAt, unitId→id)
- Fix ModelAggregate field name (count→units)
- Fix ActiveRef usage (no phase/slices fields)
- Remove commands-slice-mutation.ts (depends on single-writer engine)
- Remove slice mutation routes from catalog, do command, and workflow handler
- Update backlog promote to work without engine slice commands
This commit is contained in:
Jeremy McSpadden 2026-03-23 15:12:55 -05:00 committed by Jeremy
parent 0516a611e3
commit d8a472d4b4
10 changed files with 36 additions and 399 deletions

View file

@ -14,12 +14,22 @@ import { deriveState } from "./state.js";
import { gsdRoot } from "./paths.js";
import { loadPrompt } from "./prompt-loader.js";
function findLastCompletedSlice(state: { activeMilestone?: { slices?: Array<{ id: string; status: string }> } }): string | null {
const slices = state.activeMilestone?.slices ?? [];
for (let i = slices.length - 1; i >= 0; i--) {
if (slices[i].status === "done" || slices[i].status === "completed") {
return slices[i].id;
function findLastCompletedSlice(basePath: string, milestoneId: string): string | null {
// Scan disk for slices that have a SUMMARY.md (indicating completion)
const slicesDir = join(gsdRoot(basePath), "milestones", milestoneId, "slices");
if (!existsSync(slicesDir)) return null;
try {
const entries = readdirSync(slicesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^S\d+$/.test(e.name))
.sort((a, b) => b.name.localeCompare(a.name)); // reverse order — latest first
for (const entry of entries) {
const summaryPath = join(slicesDir, entry.name, `${entry.name}-SUMMARY.md`);
if (existsSync(summaryPath)) return entry.name;
}
} catch {
// non-fatal
}
return null;
}
@ -92,7 +102,7 @@ export async function handleAddTests(
const milestoneId = state.activeMilestone.id;
// Determine target
const targetId = args.trim() || findLastCompletedSlice(state);
const targetId = args.trim() || findLastCompletedSlice(basePath, milestoneId);
if (!targetId) {
ctx.ui.notify(
"No completed slices found. Specify a slice ID: /gsd add-tests S03",

View file

@ -129,14 +129,12 @@ async function promoteBacklogItem(
return;
}
// Promote via add-slice
const { handleAddSlice } = await import("./commands-slice-mutation.js");
await handleAddSlice(item.title, ctx, pi);
// Mark as done
// Promote — currently requires single-writer engine (not yet available)
// Mark as promoted in backlog for now; slice creation will be available with the engine.
item.done = true;
item.note = `promoted ${new Date().toISOString().slice(0, 10)}`;
writeBacklog(basePath, items);
ctx.ui.notify(`Promoted ${itemId}: "${item.title}" — add it to the roadmap manually or wait for engine slice commands.`, "info");
}
async function removeBacklogItem(basePath: string, itemId: string, ctx: ExtensionCommandContext): Promise<void> {

View file

@ -23,8 +23,6 @@ const ROUTES: Route[] = [
{ keywords: ["export", "report", "share results"], command: "export" },
{ keywords: ["ship", "pull request", "create pr", "open pr", "merge"], command: "ship" },
{ keywords: ["discuss", "talk about", "architecture", "design"], command: "discuss" },
{ keywords: ["add slice", "new slice", "add scope", "expand scope"], command: "add-slice" },
{ keywords: ["remove slice", "delete slice", "drop slice"], command: "remove-slice" },
{ keywords: ["undo", "revert", "rollback", "take back"], command: "undo" },
{ keywords: ["skip", "skip task", "skip this"], command: "skip" },
{ keywords: ["queue", "reorder", "milestone order", "order milestones"], command: "queue" },

View file

@ -22,20 +22,21 @@ function formatSessionReport(units: UnitMetrics[]): string {
const lines: string[] = [];
lines.push("╭─ Session Report ──────────────────────────────────────╮");
if (totals.totalDuration > 0) {
lines.push(`│ Duration: ${formatDuration(totals.totalDuration).padEnd(40)}`);
if (totals.duration > 0) {
lines.push(`│ Duration: ${formatDuration(totals.duration).padEnd(40)}`);
}
lines.push(`│ Units: ${String(units.length).padEnd(40)}`);
lines.push(`│ Cost: ${formatCost(totals.totalCost).padEnd(40)}`);
lines.push(`│ Tokens: ${`${formatTokenCount(totals.totalInput)} in / ${formatTokenCount(totals.totalOutput)} out`.padEnd(40)}`);
lines.push(`│ Cost: ${formatCost(totals.cost).padEnd(40)}`);
lines.push(`│ Tokens: ${`${formatTokenCount(totals.tokens.input)} in / ${formatTokenCount(totals.tokens.output)} out`.padEnd(40)}`);
lines.push("│ │");
// Work completed
if (units.length > 0) {
lines.push("│ Work Completed: │");
for (const unit of units) {
const status = unit.status === "completed" ? "✓" : unit.status === "skipped" ? "⊘" : "•";
const label = ` ${status} ${unit.unitId ?? "unknown"}`;
const finished = unit.finishedAt > 0;
const status = finished ? "✓" : "•";
const label = ` ${status} ${unit.id ?? "unknown"}`;
lines.push(`${label.padEnd(53)}`);
}
lines.push("│ │");
@ -45,7 +46,7 @@ function formatSessionReport(units: UnitMetrics[]): string {
if (byModel.length > 0) {
lines.push("│ Model Usage: │");
for (const m of byModel) {
const label = ` ${m.model}: ${m.count} units (${formatCost(m.cost)})`;
const label = ` ${m.model}: ${m.units} units (${formatCost(m.cost)})`;
lines.push(`${label.padEnd(53)}`);
}
}

View file

@ -85,13 +85,13 @@ function generatePRContent(basePath: string, milestoneId: string, milestoneTitle
const byModel = aggregateByModel(units);
sections.push("## Metrics\n");
sections.push(`- **Units executed:** ${units.length}`);
sections.push(`- **Total cost:** ${formatCost(totals.totalCost)}`);
sections.push(`- **Tokens:** ${formatTokenCount(totals.totalInput)} input / ${formatTokenCount(totals.totalOutput)} output`);
if (totals.totalDuration > 0) {
sections.push(`- **Duration:** ${formatDuration(totals.totalDuration)}`);
sections.push(`- **Total cost:** ${formatCost(totals.cost)}`);
sections.push(`- **Tokens:** ${formatTokenCount(totals.tokens.input)} input / ${formatTokenCount(totals.tokens.output)} output`);
if (totals.duration > 0) {
sections.push(`- **Duration:** ${formatDuration(totals.duration)}`);
}
if (byModel.length > 0) {
sections.push(`- **Models:** ${byModel.map((m) => `${m.model} (${m.count} units)`).join(", ")}`);
sections.push(`- **Models:** ${byModel.map((m) => `${m.model} (${m.units} units)`).join(", ")}`);
}
sections.push("");
}
@ -134,10 +134,10 @@ export async function handleShip(
const milestoneId = state.activeMilestone.id;
const milestoneTitle = state.activeMilestone.title ?? "";
// 2. Check for incomplete work
if (state.activeMilestone.phase !== "complete" && !force) {
// 2. Check for incomplete work (use GSD phase as proxy — no phase field on ActiveRef)
if (state.phase !== "complete" && !force) {
ctx.ui.notify(
`Milestone ${milestoneId} is not complete (phase: ${state.activeMilestone.phase}). Use --force to ship anyway.`,
`Milestone ${milestoneId} may not be complete (phase: ${state.phase}). Use --force to ship anyway.`,
"warning",
);
return;

View file

@ -1,226 +0,0 @@
/**
* GSD Commands /gsd add-slice, /gsd insert-slice, /gsd remove-slice
*
* Thin CLI wrappers around the engine's updateRoadmap() command.
* All mutations go through the single-writer WorkflowEngine.
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { deriveState } from "./state.js";
import { _getAdapter, isDbAvailable } from "./gsd-db.js";
import { updateRoadmap } from "./workflow-commands.js";
import { renderAllProjections } from "./workflow-projections.js";
function parseFlag(args: string, flag: string): string | undefined {
const regex = new RegExp(`${flag}\\s+(\\S+)`);
const match = args.match(regex);
return match?.[1];
}
function stripFlags(args: string): string {
return args
.replace(/--\w+\s+\S+/g, "")
.replace(/--\w+/g, "")
.trim();
}
function generateNextSliceId(existingIds: string[]): string {
let maxNum = 0;
for (const id of existingIds) {
const match = id.match(/^S(\d+)$/);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
return `S${String(maxNum + 1).padStart(2, "0")}`;
}
export async function handleAddSlice(
args: string,
ctx: ExtensionCommandContext,
_pi: ExtensionAPI,
): Promise<void> {
const basePath = process.cwd();
const state = await deriveState(basePath);
if (!state.activeMilestone) {
ctx.ui.notify("No active milestone. Create one with /gsd new-milestone first.", "warning");
return;
}
const id = parseFlag(args, "--id");
const risk = parseFlag(args, "--risk") ?? "medium";
const dependsStr = parseFlag(args, "--depends");
const depends = dependsStr ? dependsStr.split(",").map((d) => d.trim()) : [];
const title = stripFlags(args).replace(/^['"]|['"]$/g, "");
if (!title) {
ctx.ui.notify(
"Usage: /gsd add-slice [--id S99] [--risk high] [--depends S01,S02] <title>",
"warning",
);
return;
}
const milestoneId = state.activeMilestone.id;
// Determine slice ID
const existingSliceIds = (state.activeMilestone.slices ?? []).map((s: { id: string }) => s.id);
const sliceId = id ?? generateNextSliceId(existingSliceIds);
if (!isDbAvailable()) {
ctx.ui.notify("Engine database not available. Run /gsd init first.", "warning");
return;
}
try {
const db = _getAdapter();
const result = updateRoadmap(db, {
milestoneId,
addSlices: [{ id: sliceId, title, risk, depends, demo: "" }],
});
renderAllProjections(db, basePath, milestoneId);
ctx.ui.notify(
`Added ${sliceId}: "${title}" (${result.totalSlices} total slices)`,
"success",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`Failed to add slice: ${msg}`, "error");
}
}
export async function handleInsertSlice(
args: string,
ctx: ExtensionCommandContext,
_pi: ExtensionAPI,
): Promise<void> {
const basePath = process.cwd();
const state = await deriveState(basePath);
if (!state.activeMilestone) {
ctx.ui.notify("No active milestone. Create one with /gsd new-milestone first.", "warning");
return;
}
const parts = args.trim().split(/\s+/);
const afterId = parts[0];
const title = parts.slice(1).join(" ").replace(/^['"]|['"]$/g, "");
if (!afterId || !title) {
ctx.ui.notify(
'Usage: /gsd insert-slice <after-slice-id> <title>\nExample: /gsd insert-slice S03 "Auth middleware"',
"warning",
);
return;
}
const milestoneId = state.activeMilestone.id;
const existingSliceIds = (state.activeMilestone.slices ?? []).map((s: { id: string }) => s.id);
if (!existingSliceIds.includes(afterId)) {
ctx.ui.notify(
`Slice ${afterId} not found. Available: ${existingSliceIds.join(", ")}`,
"warning",
);
return;
}
const sliceId = generateNextSliceId(existingSliceIds);
if (!isDbAvailable()) {
ctx.ui.notify("Engine database not available. Run /gsd init first.", "warning");
return;
}
try {
const db = _getAdapter();
// Add the new slice
updateRoadmap(db, {
milestoneId,
addSlices: [{ id: sliceId, title, risk: "medium", depends: [], demo: "" }],
});
// Reorder: insert after the specified slice
const reorder = [...existingSliceIds];
const insertIdx = reorder.indexOf(afterId);
reorder.splice(insertIdx + 1, 0, sliceId);
const result = updateRoadmap(db, {
milestoneId,
reorderSliceIds: reorder,
});
renderAllProjections(db, basePath, milestoneId);
ctx.ui.notify(
`Inserted ${sliceId}: "${title}" after ${afterId} (${result.totalSlices} total slices)`,
"success",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`Failed to insert slice: ${msg}`, "error");
}
}
export async function handleRemoveSlice(
args: string,
ctx: ExtensionCommandContext,
_pi: ExtensionAPI,
): Promise<void> {
const basePath = process.cwd();
const state = await deriveState(basePath);
if (!state.activeMilestone) {
ctx.ui.notify("No active milestone.", "warning");
return;
}
const force = args.includes("--force");
const sliceId = args.replace(/--force/g, "").trim();
if (!sliceId) {
ctx.ui.notify("Usage: /gsd remove-slice <slice-id> [--force]", "warning");
return;
}
const milestoneId = state.activeMilestone.id;
if (!isDbAvailable()) {
ctx.ui.notify("Engine database not available. Run /gsd init first.", "warning");
return;
}
try {
const db = _getAdapter();
// If force, delete tasks first
if (force) {
db.prepare("DELETE FROM tasks WHERE milestone_id = ? AND slice_id = ?").run(milestoneId, sliceId);
}
const result = updateRoadmap(db, {
milestoneId,
removeSliceIds: [sliceId],
});
if (result.removed === 0) {
ctx.ui.notify(
`Could not remove ${sliceId} — only pending slices can be removed. Use --force to remove slices with tasks.`,
"warning",
);
return;
}
renderAllProjections(db, basePath, milestoneId);
ctx.ui.notify(
`Removed ${sliceId} (${result.totalSlices} slices remaining)`,
"success",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`Failed to remove slice: ${msg}`, "error");
}
}

View file

@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
export const GSD_COMMAND_DESCRIPTION =
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|add-slice|insert-slice|remove-slice|do|session-report|backlog|pr-branch|add-tests";
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests";
export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "help", desc: "Categorized command reference with descriptions" },
@ -75,9 +75,6 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" },
{ cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache (.gsd/CODEBASE.md)" },
{ cmd: "ship", desc: "Create PR from milestone artifacts and open for review" },
{ cmd: "add-slice", desc: "Append a new slice to the active milestone's roadmap" },
{ cmd: "insert-slice", desc: "Insert a slice after a specific position in the roadmap" },
{ cmd: "remove-slice", desc: "Remove a pending slice from the roadmap" },
{ cmd: "do", desc: "Route freeform text to the right GSD command" },
{ cmd: "session-report", desc: "Session cost, tokens, and work summary" },
{ cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" },
@ -259,14 +256,6 @@ const NESTED_COMPLETIONS: CompletionMap = {
{ cmd: "--base", desc: "Override target branch (default: main)" },
{ cmd: "--force", desc: "Ship even with pending tasks" },
],
"add-slice": [
{ cmd: "--id", desc: "Explicit slice ID (default: auto-generated)" },
{ cmd: "--risk", desc: "Risk level: low, medium, high (default: medium)" },
{ cmd: "--depends", desc: "Comma-separated dependency slice IDs" },
],
"remove-slice": [
{ cmd: "--force", desc: "Remove even if slice has tasks (deletes them)" },
],
"session-report": [
{ cmd: "--json", desc: "Machine-readable JSON output" },
{ cmd: "--save", desc: "Save report to .gsd/reports/" },

View file

@ -227,22 +227,6 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma
await handleDo(trimmed.replace(/^do\s*/, "").trim(), ctx, pi);
return true;
}
// ── Slice mutation commands ──
if (trimmed === "add-slice" || trimmed.startsWith("add-slice ")) {
const { handleAddSlice } = await import("../../commands-slice-mutation.js");
await handleAddSlice(trimmed.replace(/^add-slice\s*/, "").trim(), ctx, pi);
return true;
}
if (trimmed === "insert-slice" || trimmed.startsWith("insert-slice ")) {
const { handleInsertSlice } = await import("../../commands-slice-mutation.js");
await handleInsertSlice(trimmed.replace(/^insert-slice\s*/, "").trim(), ctx, pi);
return true;
}
if (trimmed === "remove-slice" || trimmed.startsWith("remove-slice ")) {
const { handleRemoveSlice } = await import("../../commands-slice-mutation.js");
await handleRemoveSlice(trimmed.replace(/^remove-slice\s*/, "").trim(), ctx, pi);
return true;
}
// ── Backlog management ──
if (trimmed === "backlog" || trimmed.startsWith("backlog ")) {
const { handleBacklog } = await import("../../commands-backlog.js");

View file

@ -31,7 +31,6 @@ const ROUTES: Route[] = [
{ keywords: ["clean up", "cleanup", "remove old", "prune", "tidy"], command: "cleanup" },
{ keywords: ["ship", "pull request", "create pr", "open pr", "merge"], command: "ship" },
{ keywords: ["discuss", "talk about", "architecture", "design"], command: "discuss" },
{ keywords: ["add slice", "new slice", "add scope", "expand scope"], command: "add-slice" },
{ keywords: ["undo", "revert", "rollback", "take back"], command: "undo" },
{ keywords: ["skip", "skip task", "skip this"], command: "skip" },
{ keywords: ["visualize", "viz", "graph", "chart", "show graph"], command: "visualize" },
@ -126,9 +125,3 @@ test("/gsd do: routes 'session report' to session-report", () => {
assert.ok(match);
assert.equal(match.command, "session-report");
});
test("/gsd do: routes 'add new slice' to add-slice", () => {
const match = matchRoute("add new slice for authentication");
assert.ok(match);
assert.equal(match.command, "add-slice");
});

View file

@ -1,110 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
// Test the argument parsing logic used by slice mutation commands.
// Full integration tests require DB + engine runtime, so we test
// the parsing and ID generation utilities directly.
// ─── Utilities from commands-slice-mutation.ts ──────────────────────────
function parseFlag(args: string, flag: string): string | undefined {
const regex = new RegExp(`${flag}\\s+(\\S+)`);
const match = args.match(regex);
return match?.[1];
}
function stripFlags(args: string): string {
return args
.replace(/--\w+\s+\S+/g, "")
.replace(/--\w+/g, "")
.trim();
}
function generateNextSliceId(existingIds: string[]): string {
let maxNum = 0;
for (const id of existingIds) {
const match = id.match(/^S(\d+)$/);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
return `S${String(maxNum + 1).padStart(2, "0")}`;
}
// ─── Tests ──────────────────────────────────────────────────────────────
test("add-slice: parse --id flag", () => {
assert.equal(parseFlag("--id S99 My title", "--id"), "S99");
assert.equal(parseFlag("My title --id S05", "--id"), "S05");
assert.equal(parseFlag("My title", "--id"), undefined);
});
test("add-slice: parse --risk flag", () => {
assert.equal(parseFlag("--risk high My title", "--risk"), "high");
assert.equal(parseFlag("My title", "--risk"), undefined);
});
test("add-slice: parse --depends flag", () => {
assert.equal(parseFlag("--depends S01,S02 My title", "--depends"), "S01,S02");
const deps = parseFlag("--depends S01,S02 My title", "--depends")?.split(",");
assert.deepEqual(deps, ["S01", "S02"]);
});
test("add-slice: strip flags leaves title", () => {
assert.equal(stripFlags("--id S99 --risk high My new slice"), "My new slice");
assert.equal(stripFlags("Simple title"), "Simple title");
assert.equal(stripFlags("--depends S01,S02 --risk low Auth middleware"), "Auth middleware");
});
test("add-slice: empty after stripping flags", () => {
assert.equal(stripFlags("--id S99 --risk high"), "");
});
test("add-slice: generate next slice ID from empty", () => {
assert.equal(generateNextSliceId([]), "S01");
});
test("add-slice: generate next slice ID increments", () => {
assert.equal(generateNextSliceId(["S01", "S02", "S03"]), "S04");
});
test("add-slice: generate next slice ID handles gaps", () => {
assert.equal(generateNextSliceId(["S01", "S05", "S03"]), "S06");
});
test("add-slice: generate next slice ID pads to 2 digits", () => {
assert.equal(generateNextSliceId(["S09"]), "S10");
assert.equal(generateNextSliceId(["S01"]), "S02");
});
test("remove-slice: parse --force flag", () => {
const args1 = "S05 --force";
const args2 = "S05";
assert.ok(args1.includes("--force"));
assert.ok(!args2.includes("--force"));
assert.equal(args1.replace(/--force/g, "").trim(), "S05");
assert.equal(args2.replace(/--force/g, "").trim(), "S05");
});
test("insert-slice: parse after-id and title", () => {
const args = "S03 Auth middleware";
const parts = args.trim().split(/\s+/);
const afterId = parts[0];
const title = parts.slice(1).join(" ");
assert.equal(afterId, "S03");
assert.equal(title, "Auth middleware");
});
test("insert-slice: quoted title", () => {
const args = 'S03 "Auth middleware with OAuth"';
const parts = args.trim().split(/\s+/);
const afterId = parts[0];
const title = parts.slice(1).join(" ").replace(/^['"]|['"]$/g, "");
assert.equal(afterId, "S03");
assert.equal(title, "Auth middleware with OAuth");
});