diff --git a/docs/git-strategy.md b/docs/git-strategy.md index 14c1241be..e9db91582 100644 --- a/docs/git-strategy.md +++ b/docs/git-strategy.md @@ -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: diff --git a/docs/working-in-teams.md b/docs/working-in-teams.md index febea592c..71956d5ff 100644 --- a/docs/working-in-teams.md +++ b/docs/working-in-teams.md @@ -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` diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 02f7053d1..bcd1c1869 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 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 { + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); + const prefs: Record = 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 { 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): Record { + // Mode + const mode = prefs.mode as string | undefined; + const modeSummary = mode ?? "(not set)"; + // Models const models = prefs.models as Record | undefined; let modelsSummary = "(not configured)"; @@ -752,6 +803,7 @@ function buildCategorySummaries(prefs: Record): Record): Promise { + 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): Promise { 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): 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", diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 96c802e1c..20e5455c8 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -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: diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 86dfea6e4..c129b7f60 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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> = { + 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([ "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(["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)) { diff --git a/src/resources/extensions/gsd/templates/preferences.md b/src/resources/extensions/gsd/templates/preferences.md index d5ac04656..6f0d041e5 100644 --- a/src/resources/extensions/gsd/templates/preferences.md +++ b/src/resources/extensions/gsd/templates/preferences.md @@ -1,5 +1,6 @@ --- version: 1 +mode: always_use_skills: [] prefer_skills: [] avoid_skills: [] diff --git a/src/resources/extensions/gsd/tests/preferences-mode.test.ts b/src/resources/extensions/gsd/tests/preferences-mode.test.ts new file mode 100644 index 000000000..3a60716ba --- /dev/null +++ b/src/resources/extensions/gsd/tests/preferences-mode.test.ts @@ -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 { + 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();