feat: workflow mode system (solo/team) with /gsd mode command (#651)

* feat: add workflow mode system (solo/team) with /gsd mode command

Introduces a `mode` preference that bundles sensible defaults for solo
developers vs team workflows, replacing the need to manually configure
5-8 individual git preferences.

* fix: resolve TS2339 — use string narrowing for ctx.ui.select return type
This commit is contained in:
Jeremy McSpadden 2026-03-16 12:04:51 -05:00 committed by GitHub
parent cdf42fe001
commit 2042a30232
7 changed files with 361 additions and 20 deletions

View file

@ -57,6 +57,29 @@ Use the `/worktree` (or `/wt`) command for manual worktree management:
/worktree remove
```
## Workflow Modes
Instead of configuring each git setting individually, set `mode` to get sensible defaults for your workflow:
```yaml
mode: solo # personal projects — auto-push, squash, simple IDs
mode: team # shared repos — unique IDs, push branches, pre-merge checks
```
| Setting | `solo` | `team` |
|---|---|---|
| `git.auto_push` | `true` | `false` |
| `git.push_branches` | `false` | `true` |
| `git.pre_merge_check` | `false` | `true` |
| `git.merge_strategy` | `"squash"` | `"squash"` |
| `git.isolation` | `"worktree"` | `"worktree"` |
| `git.commit_docs` | `true` | `true` |
| `unique_milestone_ids` | `false` | `true` |
Mode defaults are the lowest priority — any explicit preference overrides them. For example, `mode: solo` with `git.auto_push: false` gives you everything from solo except auto-push.
Existing configs without `mode` work exactly as before — no defaults are injected.
## Git Preferences
Configure git behavior in preferences:

View file

@ -4,19 +4,21 @@ GSD supports multi-user workflows where several developers work on the same repo
## Setup
### 1. Enable Unique Milestone IDs
### 1. Set Team Mode
Prevent ID collisions when multiple developers create milestones:
The simplest way to configure GSD for team use is to set `mode: team` in your project preferences. This enables unique milestone IDs, push branches, and pre-merge checks in one setting:
```yaml
# .gsd/preferences.md (project-level, committed to git)
---
version: 1
unique_milestone_ids: true
mode: team
---
```
This generates milestone IDs like `M001-eh88as` instead of plain `M001`. The random suffix ensures no two developers clash.
This is equivalent to manually setting `unique_milestone_ids: true`, `git.push_branches: true`, `git.pre_merge_check: true`, and other team-appropriate defaults. You can still override individual settings — for example, adding `git.auto_push: true` on top of `mode: team` if your team prefers auto-push.
Alternatively, you can configure each setting individually without using a mode (see [Git Strategy](git-strategy.md) for details).
### 2. Configure `.gitignore`

View file

@ -67,12 +67,12 @@ function projectRoot(): string {
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|run-hook|skill-health|doctor|migrate|remote|steer|knowledge",
description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|migrate|remote|steer|knowledge",
getArgumentCompletions: (prefix: string) => {
const subcommands = [
"help", "next", "auto", "stop", "pause", "status", "visualize", "queue", "quick", "discuss",
"capture", "triage",
"history", "undo", "skip", "export", "cleanup", "prefs",
"history", "undo", "skip", "export", "cleanup", "mode", "prefs",
"config", "hooks", "run-hook", "skill-health", "doctor", "migrate", "remote", "steer", "inspect", "knowledge",
];
const parts = prefix.trim().split(/\s+/);
@ -90,6 +90,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
.map((f) => ({ value: `auto ${f}`, label: f }));
}
if (parts[0] === "mode" && parts.length <= 2) {
const subPrefix = parts[1] ?? "";
return ["global", "project"]
.filter((cmd) => cmd.startsWith(subPrefix))
.map((cmd) => ({ value: `mode ${cmd}`, label: cmd }));
}
if (parts[0] === "prefs" && parts.length <= 2) {
const subPrefix = parts[1] ?? "";
return ["global", "project", "status", "wizard", "setup"]
@ -177,6 +184,15 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "mode" || trimmed.startsWith("mode ")) {
const modeArgs = trimmed.replace(/^mode\s*/, "").trim();
const scope = modeArgs === "project" ? "project" : "global";
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
await ensurePreferencesFile(path, ctx, scope);
await handlePrefsMode(ctx, scope);
return;
}
if (trimmed === "prefs" || trimmed.startsWith("prefs ")) {
await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
return;
@ -401,6 +417,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
" /gsd knowledge <type> <text> Add rule, pattern, or lesson to KNOWLEDGE.md",
"",
"CONFIGURATION",
" /gsd mode Set workflow mode (solo/team) [global|project]",
" /gsd prefs Manage preferences [global|project|status|wizard|setup]",
" /gsd config Set API keys for external tools",
" /gsd hooks Show post-unit hook configuration",
@ -518,6 +535,36 @@ async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<
ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup]", "info");
}
async function handlePrefsMode(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences();
const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {};
await configureMode(ctx, prefs);
// Serialize and save
prefs.version = prefs.version || 1;
const frontmatter = serializePreferencesToFrontmatter(prefs);
let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
if (existsSync(path)) {
const existingContent = readFileSync(path, "utf-8");
const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---"));
if (closingIdx !== -1) {
const afterFrontmatter = existingContent.slice(closingIdx + 4);
if (afterFrontmatter.trim()) {
body = afterFrontmatter;
}
}
}
const content = `---\n${frontmatter}---${body}`;
await saveFile(path, content);
await ctx.waitForIdle();
await ctx.reload();
ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info");
}
async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
const trimmed = args.trim();
const parts = trimmed ? trimmed.split(/\s+/) : [];
@ -686,6 +733,10 @@ async function handleSkillHealth(args: string, ctx: ExtensionCommandContext): Pr
/** Build short summary strings for each preference category. */
function buildCategorySummaries(prefs: Record<string, unknown>): Record<string, string> {
// Mode
const mode = prefs.mode as string | undefined;
const modeSummary = mode ?? "(not set)";
// Models
const models = prefs.models as Record<string, string> | undefined;
let modelsSummary = "(not configured)";
@ -752,6 +803,7 @@ function buildCategorySummaries(prefs: Record<string, unknown>): Record<string,
}
return {
mode: modeSummary,
models: modelsSummary,
timeouts: timeoutsSummary,
git: gitSummary,
@ -1052,6 +1104,37 @@ async function configureNotifications(ctx: ExtensionCommandContext, prefs: Recor
}
}
async function configureMode(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const currentMode = prefs.mode as string | undefined;
const modeChoice = await ctx.ui.select(
`Workflow mode${currentMode ? ` (current: ${currentMode})` : ""}:`,
[
"solo — auto-push, squash, simple IDs (personal projects)",
"team — unique IDs, push branches, pre-merge checks (shared repos)",
"(none) — configure everything manually",
"(keep current)",
],
);
const modeStr = typeof modeChoice === "string" ? modeChoice : "";
if (modeStr && modeStr !== "(keep current)") {
if (modeStr.startsWith("solo")) {
prefs.mode = "solo";
ctx.ui.notify(
"Mode: solo — defaults: auto_push=true, push_branches=false, pre_merge_check=false, merge_strategy=squash, isolation=worktree, commit_docs=true, unique_milestone_ids=false",
"info",
);
} else if (modeStr.startsWith("team")) {
prefs.mode = "team";
ctx.ui.notify(
"Mode: team — defaults: auto_push=false, push_branches=true, pre_merge_check=true, merge_strategy=squash, isolation=worktree, commit_docs=true, unique_milestone_ids=true",
"info",
);
} else {
delete prefs.mode;
}
}
}
async function configureAdvanced(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const currentUnique = prefs.unique_milestone_ids;
const uniqueChoice = await ctx.ui.select(
@ -1078,6 +1161,7 @@ async function handlePrefsWizard(
while (true) {
const summaries = buildCategorySummaries(prefs);
const options = [
`Workflow Mode ${summaries.mode}`,
`Models ${summaries.models}`,
`Timeouts ${summaries.timeouts}`,
`Git ${summaries.git}`,
@ -1092,7 +1176,8 @@ async function handlePrefsWizard(
const choice = typeof raw === "string" ? raw : "";
if (!choice || choice.includes("Save & Exit")) break;
if (choice.startsWith("Models")) await configureModels(ctx, prefs);
if (choice.startsWith("Workflow Mode")) await configureMode(ctx, prefs);
else if (choice.startsWith("Models")) await configureModels(ctx, prefs);
else if (choice.startsWith("Timeouts")) await configureTimeouts(ctx, prefs);
else if (choice.startsWith("Git")) await configureGit(ctx, prefs);
else if (choice.startsWith("Skills")) await configureSkills(ctx, prefs);
@ -1189,7 +1274,7 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
// Ordered keys for consistent output
const orderedKeys = [
"version", "always_use_skills", "prefer_skills", "avoid_skills",
"version", "mode", "always_use_skills", "prefer_skills", "avoid_skills",
"skill_rules", "custom_instructions", "models", "skill_discovery",
"auto_supervisor", "uat_dispatch", "unique_milestone_ids",
"budget_ceiling", "budget_enforcement", "context_pause_threshold",

View file

@ -72,6 +72,20 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
- `version`: schema version. Start at `1`.
- `mode`: workflow mode — `"solo"` or `"team"`. Sets sensible defaults for git and project settings based on your workflow. Mode defaults are the lowest priority layer — any explicit preference overrides them. Omit to configure everything manually.
| Setting | `solo` | `team` |
|---|---|---|
| `git.auto_push` | `true` | `false` |
| `git.push_branches` | `false` | `true` |
| `git.pre_merge_check` | `false` | `true` |
| `git.merge_strategy` | `"squash"` | `"squash"` |
| `git.isolation` | `"worktree"` | `"worktree"` |
| `git.commit_docs` | `true` | `true` |
| `unique_milestone_ids` | `false` | `true` |
Quick setup: `/gsd mode` (global) or `/gsd mode project` (project-level).
- `always_use_skills`: skills GSD should use whenever they are relevant.
- `prefer_skills`: soft defaults GSD should prefer when relevant.
@ -190,6 +204,45 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
---
## Workflow Mode Examples
**Solo developer — auto-push, simple IDs:**
```yaml
---
version: 1
mode: solo
---
```
Equivalent to setting `git.auto_push: true`, `git.push_branches: false`, `git.pre_merge_check: false`, `git.merge_strategy: squash`, `git.isolation: worktree`, `git.commit_docs: true`, `unique_milestone_ids: false`.
**Team — unique IDs, push branches, pre-merge checks:**
```yaml
---
version: 1
mode: team
---
```
Equivalent to setting `git.auto_push: false`, `git.push_branches: true`, `git.pre_merge_check: true`, `git.merge_strategy: squash`, `git.isolation: worktree`, `git.commit_docs: true`, `unique_milestone_ids: true`.
**Mode with overrides — team mode but with auto-push:**
```yaml
---
version: 1
mode: team
git:
auto_push: true
---
```
Gets all team defaults except `auto_push`, which is explicitly overridden to `true`. Any explicit setting always wins over the mode default.
---
## Minimal Example
The cleanest preferences file only specifies what you actually want:

View file

@ -18,9 +18,40 @@ const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.m
const PROJECT_PREFERENCES_PATH_UPPERCASE = join(process.cwd(), ".gsd", "PREFERENCES.md");
const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]);
// ─── Workflow Modes ──────────────────────────────────────────────────────────
export type WorkflowMode = "solo" | "team";
/** Default preference values for each workflow mode. */
const MODE_DEFAULTS: Record<WorkflowMode, Partial<GSDPreferences>> = {
solo: {
git: {
auto_push: true,
push_branches: false,
pre_merge_check: false,
merge_strategy: "squash",
isolation: "worktree",
commit_docs: true,
},
unique_milestone_ids: false,
},
team: {
git: {
auto_push: false,
push_branches: true,
pre_merge_check: true,
merge_strategy: "squash",
isolation: "worktree",
commit_docs: true,
},
unique_milestone_ids: true,
},
};
/** All recognized top-level keys in GSDPreferences. Used to detect typos / stale config. */
const KNOWN_PREFERENCE_KEYS = new Set<string>([
"version",
"mode",
"always_use_skills",
"prefer_skills",
"avoid_skills",
@ -116,6 +147,7 @@ export interface RemoteQuestionsConfig {
export interface GSDPreferences {
version?: number;
mode?: WorkflowMode;
always_use_skills?: string[];
prefer_skills?: string[];
avoid_skills?: string[];
@ -172,25 +204,49 @@ export function loadProjectGSDPreferences(): LoadedGSDPreferences | null {
?? loadPreferencesFile(PROJECT_PREFERENCES_PATH_UPPERCASE, "project");
}
/**
* Apply mode defaults as the lowest-priority layer.
* Mode defaults fill in undefined fields; any explicit user value wins.
*/
export function applyModeDefaults(mode: WorkflowMode, prefs: GSDPreferences): GSDPreferences {
const defaults = MODE_DEFAULTS[mode];
if (!defaults) return prefs;
return mergePreferences(defaults, prefs);
}
export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null {
const globalPreferences = loadGlobalGSDPreferences();
const projectPreferences = loadProjectGSDPreferences();
if (!globalPreferences && !projectPreferences) return null;
if (!globalPreferences) return projectPreferences;
if (!projectPreferences) return globalPreferences;
const mergedWarnings = [
...(globalPreferences.warnings ?? []),
...(projectPreferences.warnings ?? []),
];
let result: LoadedGSDPreferences;
if (!globalPreferences) {
result = projectPreferences!;
} else if (!projectPreferences) {
result = globalPreferences;
} else {
const mergedWarnings = [
...(globalPreferences.warnings ?? []),
...(projectPreferences.warnings ?? []),
];
result = {
path: projectPreferences.path,
scope: "project",
preferences: mergePreferences(globalPreferences.preferences, projectPreferences.preferences),
...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}),
};
}
return {
path: projectPreferences.path,
scope: "project",
preferences: mergePreferences(globalPreferences.preferences, projectPreferences.preferences),
...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}),
};
// Apply mode defaults as the lowest-priority layer
if (result.preferences.mode) {
result = {
...result,
preferences: applyModeDefaults(result.preferences.mode, result.preferences),
};
}
return result;
}
// ─── Skill Reference Resolution ───────────────────────────────────────────────
@ -662,6 +718,7 @@ export function resolveInlineLevel(): InlineLevel {
function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences {
return {
version: override.version ?? base.version,
mode: override.mode ?? base.mode,
always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills),
prefer_skills: mergeStringLists(base.prefer_skills, override.prefer_skills),
avoid_skills: mergeStringLists(base.avoid_skills, override.avoid_skills),
@ -721,6 +778,16 @@ export function validatePreferences(preferences: GSDPreferences): {
}
}
// ─── Workflow Mode ──────────────────────────────────────────────────
if (preferences.mode !== undefined) {
const validModes = new Set<string>(["solo", "team"]);
if (typeof preferences.mode === "string" && validModes.has(preferences.mode)) {
validated.mode = preferences.mode as WorkflowMode;
} else {
errors.push(`invalid mode "${preferences.mode}" — must be one of: solo, team`);
}
}
const validDiscoveryModes = new Set(["auto", "suggest", "off"]);
if (preferences.skill_discovery) {
if (validDiscoveryModes.has(preferences.skill_discovery)) {

View file

@ -1,5 +1,6 @@
---
version: 1
mode:
always_use_skills: []
prefer_skills: []
avoid_skills: []

View file

@ -0,0 +1,110 @@
// GSD Workflow Mode Tests — validates mode defaults, overrides, and validation
import { createTestContext } from "./test-helpers.ts";
import { validatePreferences, applyModeDefaults } from "../preferences.ts";
import type { GSDPreferences } from "../preferences.ts";
const { assertEq, assertTrue, report } = createTestContext();
async function main(): Promise<void> {
console.log("\n=== mode: solo defaults ===");
{
const prefs: GSDPreferences = { mode: "solo" };
const result = applyModeDefaults("solo", prefs);
assertEq(result.git?.auto_push, true, "solo — auto_push defaults to true");
assertEq(result.git?.push_branches, false, "solo — push_branches defaults to false");
assertEq(result.git?.pre_merge_check, false, "solo — pre_merge_check defaults to false");
assertEq(result.git?.merge_strategy, "squash", "solo — merge_strategy defaults to squash");
assertEq(result.git?.isolation, "worktree", "solo — isolation defaults to worktree");
assertEq(result.git?.commit_docs, true, "solo — commit_docs defaults to true");
assertEq(result.unique_milestone_ids, false, "solo — unique_milestone_ids defaults to false");
}
console.log("\n=== mode: team defaults ===");
{
const prefs: GSDPreferences = { mode: "team" };
const result = applyModeDefaults("team", prefs);
assertEq(result.git?.auto_push, false, "team — auto_push defaults to false");
assertEq(result.git?.push_branches, true, "team — push_branches defaults to true");
assertEq(result.git?.pre_merge_check, true, "team — pre_merge_check defaults to true");
assertEq(result.git?.merge_strategy, "squash", "team — merge_strategy defaults to squash");
assertEq(result.git?.isolation, "worktree", "team — isolation defaults to worktree");
assertEq(result.git?.commit_docs, true, "team — commit_docs defaults to true");
assertEq(result.unique_milestone_ids, true, "team — unique_milestone_ids defaults to true");
}
console.log("\n=== explicit override wins over mode default ===");
{
const prefs: GSDPreferences = {
mode: "solo",
git: { auto_push: false },
};
const result = applyModeDefaults("solo", prefs);
assertEq(result.git?.auto_push, false, "solo + explicit auto_push=false — override wins");
assertEq(result.git?.push_branches, false, "solo + override — other defaults still apply");
assertEq(result.git?.merge_strategy, "squash", "solo + override — merge_strategy still defaults");
}
console.log("\n=== no mode set — no defaults injected ===");
{
const prefs: GSDPreferences = { git: { auto_push: true } };
const { preferences } = validatePreferences(prefs);
assertEq(preferences.mode, undefined, "no mode — mode is undefined");
assertEq(preferences.git?.push_branches, undefined, "no mode — push_branches not injected");
assertEq(preferences.unique_milestone_ids, undefined, "no mode — unique_milestone_ids not injected");
}
console.log("\n=== invalid mode value → validation error ===");
{
const { errors } = validatePreferences({ mode: "invalid" as any });
assertTrue(errors.length > 0, "invalid mode — produces error");
assertTrue(errors[0].includes("solo, team"), "invalid mode — error mentions valid values");
}
console.log("\n=== valid mode values pass validation ===");
{
const { errors: soloErrors, preferences: soloPrefs } = validatePreferences({ mode: "solo" });
assertEq(soloErrors.length, 0, "mode: solo — no errors");
assertEq(soloPrefs.mode, "solo", "mode: solo — value preserved");
}
{
const { errors: teamErrors, preferences: teamPrefs } = validatePreferences({ mode: "team" });
assertEq(teamErrors.length, 0, "mode: team — no errors");
assertEq(teamPrefs.mode, "team", "mode: team — value preserved");
}
console.log("\n=== deep merge: mode + explicit git.remote ===");
{
const prefs: GSDPreferences = {
mode: "team",
git: { remote: "upstream" },
};
const result = applyModeDefaults("team", prefs);
assertEq(result.git?.remote, "upstream", "team + git.remote — custom remote preserved");
assertEq(result.git?.auto_push, false, "team + git.remote — team auto_push default applied");
assertEq(result.git?.push_branches, true, "team + git.remote — team push_branches default applied");
}
console.log("\n=== mode + unique_milestone_ids explicit override ===");
{
const prefs: GSDPreferences = {
mode: "team",
unique_milestone_ids: false,
};
const result = applyModeDefaults("team", prefs);
assertEq(result.unique_milestone_ids, false, "team + explicit unique_milestone_ids=false — override wins");
assertEq(result.git?.push_branches, true, "team + override — other team defaults still apply");
}
report();
}
main();