From f649543b2068d44e79024377b847a0ace93334be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Mari=C3=B1o?= Date: Wed, 18 Mar 2026 14:58:58 +0100 Subject: [PATCH 1/5] feat: materialize full config on new-project initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `config-new-project` CLI command that writes a complete, fully-materialized `.planning/config.json` with sane defaults instead of the previous partial template (6-7 user-chosen keys only). Unset keys are no longer silently resolved at read time — every key GSD reads is written explicitly at project creation. Previously, missing keys were resolved silently by loadConfig() defaults, making the effective config non-discoverable. Now every key that GSD reads is written explicitly at project creation. - buildNewProjectConfig() — single source of truth for all defaults; merges hardcoded ← ~/.gsd/defaults.json ← user choices - ensureConfigFile() refactored to reuse buildNewProjectConfig({}) instead of duplicating default logic (~40 lines removed) - new-project.md Steps 2a and 5 updated to call config-new-project instead of writing a hardcoded partial JSON template - Test coverage for config.cjs: 78.96% → 93.81% statements, 100% functions; adds config-set-model-profile test suite FIXES: - VALID_CONFIG_KEYS extended with workflow.auto_advance, workflow.node_repair, workflow.node_repair_budget, hooks.context_warnings — these keys had hardcoded defaults but were not settable via config-set --- get-shit-done/bin/gsd-tools.cjs | 7 +- get-shit-done/bin/lib/config.cjs | 193 +++++++++++---- get-shit-done/workflows/new-project.md | 43 +--- tests/config.test.cjs | 309 ++++++++++++++++++++++++- 4 files changed, 468 insertions(+), 84 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 16975e8f2..f0246b741 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -175,7 +175,7 @@ async function main() { const command = args[0]; if (!command) { - error('Usage: gsd-tools [args] [--raw] [--cwd ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, init'); + error('Usage: gsd-tools [args] [--raw] [--cwd ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-new-project, init'); } switch (command) { @@ -401,6 +401,11 @@ async function main() { break; } + case 'config-new-project': { + config.cmdConfigNewProject(cwd, args[1], raw); + break; + } + case 'history-digest': { commands.cmdHistoryDigest(cwd, raw); break; diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 1e0e65491..0625d80dc 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -16,9 +16,11 @@ const VALID_CONFIG_KEYS = new Set([ 'search_gitignored', 'brave_search', 'workflow.research', 'workflow.plan_check', 'workflow.verifier', 'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate', + 'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget', 'workflow._auto_chain_active', 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template', 'planning.commit_docs', 'planning.search_gitignored', + 'hooks.context_warnings', ]); const CONFIG_KEY_SUGGESTIONS = { @@ -34,6 +36,146 @@ function validateKnownConfigKeyPath(keyPath) { } } +/** + * Build a fully-materialized config object for a new project. + * + * Merges (increasing priority): + * 1. Hardcoded defaults — every key that loadConfig() resolves, plus mode/granularity + * 2. User-level defaults from ~/.gsd/defaults.json (if present) + * 3. userChoices — the settings the user explicitly selected during /gsd:new-project + * + * Uses the canonical `git` namespace for branching keys (consistent with VALID_CONFIG_KEYS + * and the settings workflow). loadConfig() handles both flat and nested formats, so this + * is backward-compatible with existing projects that have flat keys. + * + * Returns a plain object — does NOT write any files. + */ +function buildNewProjectConfig(userChoices) { + const choices = userChoices || {}; + const homedir = require('os').homedir(); + + // Detect Brave Search API key availability + const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); + const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); + + // Load user-level defaults from ~/.gsd/defaults.json if available + const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json'); + let userDefaults = {}; + try { + if (fs.existsSync(globalDefaultsPath)) { + userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')); + // Migrate deprecated "depth" key to "granularity" + if ('depth' in userDefaults && !('granularity' in userDefaults)) { + const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; + userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth; + delete userDefaults.depth; + try { + fs.writeFileSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2), 'utf-8'); + } catch {} + } + } + } catch { + // Ignore malformed global defaults + } + + const hardcoded = { + model_profile: 'balanced', + commit_docs: true, + parallelization: true, + search_gitignored: false, + brave_search: hasBraveSearch, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + }, + workflow: { + research: true, + plan_check: true, + verifier: true, + nyquist_validation: true, + auto_advance: false, + node_repair: true, + node_repair_budget: 2, + ui_phase: true, + ui_safety_gate: true, + }, + hooks: { + context_warnings: true, + }, + }; + + // Three-level deep merge: hardcoded <- userDefaults <- choices + return { + ...hardcoded, + ...userDefaults, + ...choices, + git: { + ...hardcoded.git, + ...(userDefaults.git || {}), + ...(choices.git || {}), + }, + workflow: { + ...hardcoded.workflow, + ...(userDefaults.workflow || {}), + ...(choices.workflow || {}), + }, + hooks: { + ...hardcoded.hooks, + ...(userDefaults.hooks || {}), + ...(choices.hooks || {}), + }, + }; +} + +/** + * Command: create a fully-materialized .planning/config.json for a new project. + * + * Accepts user-chosen settings as a JSON string (the keys the user explicitly + * configured during /gsd:new-project). All remaining keys are filled from + * hardcoded defaults and optional ~/.gsd/defaults.json. + * + * Idempotent: if config.json already exists, returns { created: false }. + */ +function cmdConfigNewProject(cwd, choicesJson, raw) { + const configPath = path.join(cwd, '.planning', 'config.json'); + const planningDir = path.join(cwd, '.planning'); + + // Idempotent: don't overwrite existing config + if (fs.existsSync(configPath)) { + output({ created: false, reason: 'already_exists' }, raw, 'exists'); + return; + } + + // Parse user choices + let userChoices = {}; + if (choicesJson && choicesJson.trim() !== '') { + try { + userChoices = JSON.parse(choicesJson); + } catch (err) { + error('Invalid JSON for config-new-project: ' + err.message); + } + } + + // Ensure .planning directory exists + try { + if (!fs.existsSync(planningDir)) { + fs.mkdirSync(planningDir, { recursive: true }); + } + } catch (err) { + error('Failed to create .planning directory: ' + err.message); + } + + const config = buildNewProjectConfig(userChoices); + + try { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + output({ created: true, path: '.planning/config.json' }, raw, 'created'); + } catch (err) { + error('Failed to write config.json: ' + err.message); + } +} + /** * Ensures the config file exists (creates it if needed). * @@ -58,56 +200,10 @@ function ensureConfigFile(cwd) { return { created: false, reason: 'already_exists' }; } - // Detect Brave Search API key availability - const homedir = require('os').homedir(); - const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); - const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); - - // Load user-level defaults from ~/.gsd/defaults.json if available - const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json'); - let userDefaults = {}; - try { - if (fs.existsSync(globalDefaultsPath)) { - userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')); - // Migrate deprecated "depth" key to "granularity" - if ('depth' in userDefaults && !('granularity' in userDefaults)) { - const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; - userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth; - delete userDefaults.depth; - try { - fs.writeFileSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2), 'utf-8'); - } catch {} - } - } - } catch (err) { - // Ignore malformed global defaults, fall back to hardcoded - } - - // Create default config (user-level defaults override hardcoded defaults) - const hardcoded = { - model_profile: 'balanced', - commit_docs: true, - search_gitignored: false, - branching_strategy: 'none', - phase_branch_template: 'gsd/phase-{phase}-{slug}', - milestone_branch_template: 'gsd/{milestone}-{slug}', - workflow: { - research: true, - plan_check: true, - verifier: true, - nyquist_validation: true, - }, - parallelization: true, - brave_search: hasBraveSearch, - }; - const defaults = { - ...hardcoded, - ...userDefaults, - workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) }, - }; + const config = buildNewProjectConfig({}); try { - fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); return { created: true, path: '.planning/config.json' }; } catch (err) { error('Failed to create config.json: ' + err.message); @@ -304,4 +400,5 @@ module.exports = { cmdConfigSet, cmdConfigGet, cmdConfigSetModelProfile, + cmdConfigNewProject, }; diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index 7db0e6c6b..26410155f 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -166,23 +166,11 @@ AskUserQuestion([ ]) ``` -Create `.planning/config.json` with mode set to "yolo": +Create `.planning/config.json` with all settings (CLI fills in remaining defaults automatically): -```json -{ - "mode": "yolo", - "granularity": "[selected]", - "parallelization": true|false, - "commit_docs": true|false, - "model_profile": "quality|balanced|budget|inherit", - "workflow": { - "research": true|false, - "plan_check": true|false, - "verifier": true|false, - "nyquist_validation": depth !== "quick", - "auto_advance": true - } -} +```bash +mkdir -p .planning +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-new-project '{"mode":"yolo","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false,"nyquist_validation":true|false,"auto_advance":true}}' ``` **If commit_docs = No:** Add `.planning/` to `.gitignore`. @@ -467,24 +455,15 @@ questions: [ ] ``` -Create `.planning/config.json` with all settings: +Create `.planning/config.json` with all settings (CLI fills in remaining defaults automatically): -```json -{ - "mode": "yolo|interactive", - "granularity": "coarse|standard|fine", - "parallelization": true|false, - "commit_docs": true|false, - "model_profile": "quality|balanced|budget|inherit", - "workflow": { - "research": true|false, - "plan_check": true|false, - "verifier": true|false, - "nyquist_validation": depth !== "quick" - } -} +```bash +mkdir -p .planning +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-new-project '{"mode":"[yolo|interactive]","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false}}' ``` +**Note:** Run `/gsd:settings` anytime to update model profile, workflow agents, branching strategy, and other preferences. + **If commit_docs = No:** - Set `commit_docs: false` in config.json - Add `.planning/` to `.gitignore` (create if needed) @@ -498,8 +477,6 @@ Create `.planning/config.json` with all settings: node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "chore: add project config" --files .planning/config.json ``` -**Note:** Run `/gsd:settings` anytime to update these preferences. - ## 5.5. Resolve Model Profile Use models from init: `researcher_model`, `synthesizer_model`, `roadmapper_model`. diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 789753153..99dacd402 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -51,7 +51,8 @@ describe('config-ensure-section command', () => { assert.strictEqual(typeof config.model_profile, 'string'); assert.strictEqual(typeof config.commit_docs, 'boolean'); assert.strictEqual(typeof config.parallelization, 'boolean'); - assert.strictEqual(typeof config.branching_strategy, 'string'); + assert.ok(config.git && typeof config.git === 'object', 'git should be an object'); + assert.strictEqual(typeof config.git.branching_strategy, 'string'); assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow should be an object'); assert.strictEqual(typeof config.workflow.research, 'boolean'); assert.strictEqual(typeof config.workflow.plan_check, 'boolean'); @@ -139,7 +140,8 @@ describe('config-ensure-section command', () => { const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'quality', 'model_profile should be overridden'); assert.strictEqual(config.commit_docs, false, 'commit_docs should be overridden'); - assert.strictEqual(typeof config.branching_strategy, 'string', 'branching_strategy should be a string'); + assert.ok(config.git && typeof config.git === 'object', 'git should be an object'); + assert.strictEqual(typeof config.git.branching_strategy, 'string', 'git.branching_strategy should be a string'); } finally { // Restore if (existingDefaults !== null) { @@ -372,3 +374,306 @@ describe('config-get command', () => { assert.strictEqual(result.success, false); }); }); + +// ─── config-new-project ─────────────────────────────────────────────────────── + +describe('config-new-project command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('creates full config with all expected keys', () => { + const choices = JSON.stringify({ + mode: 'interactive', + granularity: 'standard', + parallelization: true, + commit_docs: true, + model_profile: 'balanced', + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, + }); + const result = runGsdTools(['config-new-project', choices], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + + // User choices present + assert.strictEqual(config.mode, 'interactive'); + assert.strictEqual(config.granularity, 'standard'); + assert.strictEqual(config.parallelization, true); + assert.strictEqual(config.commit_docs, true); + assert.strictEqual(config.model_profile, 'balanced'); + + // Defaults materialized — these were silently missing before + assert.strictEqual(typeof config.search_gitignored, 'boolean'); + assert.strictEqual(typeof config.brave_search, 'boolean'); + + // git section present with all three keys + assert.ok(config.git && typeof config.git === 'object', 'git section should exist'); + assert.strictEqual(config.git.branching_strategy, 'none'); + assert.strictEqual(config.git.phase_branch_template, 'gsd/phase-{phase}-{slug}'); + assert.strictEqual(config.git.milestone_branch_template, 'gsd/{milestone}-{slug}'); + + // workflow section present with all keys + assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow section should exist'); + assert.strictEqual(config.workflow.research, true); + assert.strictEqual(config.workflow.plan_check, true); + assert.strictEqual(config.workflow.verifier, true); + assert.strictEqual(config.workflow.nyquist_validation, true); + assert.strictEqual(config.workflow.auto_advance, false); + assert.strictEqual(config.workflow.node_repair, true); + assert.strictEqual(config.workflow.node_repair_budget, 2); + assert.strictEqual(config.workflow.ui_phase, true); + assert.strictEqual(config.workflow.ui_safety_gate, true); + + // hooks section present + assert.ok(config.hooks && typeof config.hooks === 'object', 'hooks section should exist'); + assert.strictEqual(config.hooks.context_warnings, true); + }); + + test('user choices override defaults', () => { + const choices = JSON.stringify({ + mode: 'yolo', + granularity: 'coarse', + parallelization: false, + commit_docs: false, + model_profile: 'quality', + workflow: { research: false, plan_check: false, verifier: true, nyquist_validation: false }, + }); + const result = runGsdTools(['config-new-project', choices], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.mode, 'yolo'); + assert.strictEqual(config.granularity, 'coarse'); + assert.strictEqual(config.parallelization, false); + assert.strictEqual(config.commit_docs, false); + assert.strictEqual(config.model_profile, 'quality'); + assert.strictEqual(config.workflow.research, false); + assert.strictEqual(config.workflow.plan_check, false); + assert.strictEqual(config.workflow.verifier, true); + assert.strictEqual(config.workflow.nyquist_validation, false); + // Defaults still present for non-chosen keys + assert.strictEqual(config.git.branching_strategy, 'none'); + assert.strictEqual(typeof config.search_gitignored, 'boolean'); + }); + + test('works with empty choices — all defaults materialized', () => { + const result = runGsdTools(['config-new-project', '{}'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.model_profile, 'balanced'); + assert.strictEqual(config.commit_docs, true); + assert.strictEqual(config.parallelization, true); + assert.strictEqual(config.search_gitignored, false); + assert.ok(config.git && typeof config.git === 'object'); + assert.strictEqual(config.git.branching_strategy, 'none'); + assert.ok(config.workflow && typeof config.workflow === 'object'); + assert.strictEqual(config.workflow.nyquist_validation, true); + assert.strictEqual(config.workflow.auto_advance, false); + assert.strictEqual(config.workflow.node_repair, true); + assert.strictEqual(config.workflow.node_repair_budget, 2); + assert.strictEqual(config.workflow.ui_phase, true); + assert.strictEqual(config.workflow.ui_safety_gate, true); + assert.ok(config.hooks && typeof config.hooks === 'object'); + assert.strictEqual(config.hooks.context_warnings, true); + }); + + test('is idempotent — returns already_exists if config exists', () => { + const choices = JSON.stringify({ mode: 'yolo', granularity: 'fine' }); + + const first = runGsdTools(['config-new-project', choices], tmpDir); + assert.ok(first.success, `First call failed: ${first.error}`); + const firstOut = JSON.parse(first.output); + assert.strictEqual(firstOut.created, true); + + const second = runGsdTools(['config-new-project', choices], tmpDir); + assert.ok(second.success, `Second call failed: ${second.error}`); + const secondOut = JSON.parse(second.output); + assert.strictEqual(secondOut.created, false); + assert.strictEqual(secondOut.reason, 'already_exists'); + + // Config unchanged + const config = readConfig(tmpDir); + assert.strictEqual(config.mode, 'yolo'); + assert.strictEqual(config.granularity, 'fine'); + }); + + test('auto_advance in workflow choices is preserved', () => { + const choices = JSON.stringify({ + mode: 'yolo', + granularity: 'standard', + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true, auto_advance: true }, + }); + const result = runGsdTools(['config-new-project', choices], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.auto_advance, true); + }); + + test('rejects invalid JSON choices', () => { + const result = runGsdTools(['config-new-project', '{not-json}'], tmpDir); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('Invalid JSON'), `Expected "Invalid JSON" in: ${result.error}`); + }); + + test('output has created:true and path on success', () => { + const choices = JSON.stringify({ mode: 'interactive', granularity: 'standard' }); + const result = runGsdTools(['config-new-project', choices], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.created, true); + assert.strictEqual(out.path, '.planning/config.json'); + }); +}); + +// ─── config-set (additional coverage) ──────────────────────────────────────── + +describe('config-set unknown key (no suggestion)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + runGsdTools('config-ensure-section', tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('rejects a key that has no suggestion', () => { + const result = runGsdTools('config-set totally.unknown.key value', tmpDir); + assert.strictEqual(result.success, false); + assert.ok( + result.error.includes('Unknown config key'), + `Expected "Unknown config key" in error: ${result.error}` + ); + }); +}); + +// ─── config-get (additional coverage) ──────────────────────────────────────── + +describe('config-get edge cases', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('errors when traversing a dot-path through a non-object value', () => { + // model_profile is a string — requesting model_profile.something traverses into a non-object + writeConfig(tmpDir, { model_profile: 'balanced' }); + const result = runGsdTools('config-get model_profile.something', tmpDir); + assert.strictEqual(result.success, false); + assert.ok( + result.error.includes('Key not found'), + `Expected "Key not found" in error: ${result.error}` + ); + }); + + test('errors when config.json contains malformed JSON', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync(configPath, '{not valid json', 'utf-8'); + const result = runGsdTools('config-get model_profile', tmpDir); + assert.strictEqual(result.success, false); + assert.ok( + result.error.includes('Failed to read config.json'), + `Expected "Failed to read config.json" in error: ${result.error}` + ); + }); +}); + +// ─── config-set-model-profile ───────────────────────────────────────────────── + +describe('config-set-model-profile command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + runGsdTools('config-ensure-section', tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('sets a valid profile and updates config', () => { + const result = runGsdTools('config-set-model-profile quality', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.updated, true); + assert.strictEqual(out.profile, 'quality'); + assert.ok(out.agentToModelMap && typeof out.agentToModelMap === 'object'); + + const config = readConfig(tmpDir); + assert.strictEqual(config.model_profile, 'quality'); + }); + + test('reports previous profile in output', () => { + const result = runGsdTools('config-set-model-profile budget', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.previousProfile, 'balanced'); // default was balanced + assert.strictEqual(out.profile, 'budget'); + }); + + test('setting the same profile is a no-op on config but still succeeds', () => { + // Set to quality first, then set to quality again + runGsdTools('config-set-model-profile quality', tmpDir); + const result = runGsdTools('config-set-model-profile quality', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual(out.profile, 'quality'); + assert.strictEqual(out.previousProfile, 'quality'); + }); + + test('is case-insensitive', () => { + const result = runGsdTools('config-set-model-profile BALANCED', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.model_profile, 'balanced'); + }); + + test('rejects invalid profile', () => { + const result = runGsdTools('config-set-model-profile turbo', tmpDir); + assert.strictEqual(result.success, false); + assert.ok( + result.error.includes('Invalid profile'), + `Expected "Invalid profile" in error: ${result.error}` + ); + }); + + test('errors when no profile provided', () => { + const result = runGsdTools('config-set-model-profile', tmpDir); + assert.strictEqual(result.success, false); + }); + + test('creates config if missing before setting profile', () => { + const emptyDir = createTempProject(); + try { + const result = runGsdTools('config-set-model-profile budget', emptyDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(emptyDir); + assert.strictEqual(config.model_profile, 'budget'); + } finally { + cleanup(emptyDir); + } + }); +}); From 63f6424d1b2b683b921f6087ccb03eb05516ff3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Mari=C3=B1o?= Date: Wed, 18 Mar 2026 15:27:46 +0100 Subject: [PATCH 2/5] fix(tests): sandbox HOME in runGsdTools to prevent flaky assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildNewProjectConfig() merges ~/.gsd/defaults.json when present, so tests asserting concrete config values (model_profile, commit_docs, brave_search) would fail on machines with a personal defaults file. - Pass HOME=cwd as env override in runGsdTools — child process resolves os.homedir() to the temp directory, which has no .gsd/ subtree - Update three tests that previously wrote to the real ~/.gsd/ using fragile save/restore logic; they now write to tmpDir/.gsd/ instead, which is cleaned up automatically by afterEach - Remove now-unused `os` import from config.test.cjs --- tests/config.test.cjs | 141 +++++++++++------------------------------- tests/helpers.cjs | 5 ++ 2 files changed, 42 insertions(+), 104 deletions(-) diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 99dacd402..c6fe2590f 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -11,7 +11,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); // ─── helpers ────────────────────────────────────────────────────────────────── @@ -77,122 +76,56 @@ describe('config-ensure-section command', () => { assert.strictEqual(secondOutput.reason, 'already_exists'); }); - // NOTE: This test touches ~/.gsd/ on the real filesystem. It uses save/restore - // try/finally and skips if the file already exists to avoid corrupting user config. test('detects Brave Search from file-based key', () => { - const homedir = os.homedir(); - const gsdDir = path.join(homedir, '.gsd'); - const braveKeyFile = path.join(gsdDir, 'brave_api_key'); + // runGsdTools sandboxes HOME=tmpDir, so brave_api_key is written there — + // no real filesystem side effects, cleanup happens via afterEach. + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'brave_api_key'), 'test-key', 'utf-8'); - // Skip if file already exists (don't mess with user's real config) - if (fs.existsSync(braveKeyFile)) { - return; - } + const result = runGsdTools('config-ensure-section', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); - // Create .gsd dir and brave_api_key file - const gsdDirExisted = fs.existsSync(gsdDir); - try { - if (!gsdDirExisted) { - fs.mkdirSync(gsdDir, { recursive: true }); - } - fs.writeFileSync(braveKeyFile, 'test-key', 'utf-8'); - - const result = runGsdTools('config-ensure-section', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.brave_search, true); - } finally { - // Clean up - try { fs.unlinkSync(braveKeyFile); } catch { /* ignore */ } - if (!gsdDirExisted) { - try { fs.rmdirSync(gsdDir); } catch { /* ignore if not empty */ } - } - } + const config = readConfig(tmpDir); + assert.strictEqual(config.brave_search, true); }); - // NOTE: This test touches ~/.gsd/ on the real filesystem. It uses save/restore - // try/finally and skips if the file already exists to avoid corrupting user config. test('merges user defaults from defaults.json', () => { - const homedir = os.homedir(); - const gsdDir = path.join(homedir, '.gsd'); - const defaultsFile = path.join(gsdDir, 'defaults.json'); + // runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there — + // no real filesystem side effects, cleanup happens via afterEach. + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ + model_profile: 'quality', + commit_docs: false, + }), 'utf-8'); - // Save existing defaults if present - let existingDefaults = null; - const gsdDirExisted = fs.existsSync(gsdDir); - if (fs.existsSync(defaultsFile)) { - existingDefaults = fs.readFileSync(defaultsFile, 'utf-8'); - } + const result = runGsdTools('config-ensure-section', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); - try { - if (!gsdDirExisted) { - fs.mkdirSync(gsdDir, { recursive: true }); - } - fs.writeFileSync(defaultsFile, JSON.stringify({ - model_profile: 'quality', - commit_docs: false, - }), 'utf-8'); - - const result = runGsdTools('config-ensure-section', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.model_profile, 'quality', 'model_profile should be overridden'); - assert.strictEqual(config.commit_docs, false, 'commit_docs should be overridden'); - assert.ok(config.git && typeof config.git === 'object', 'git should be an object'); - assert.strictEqual(typeof config.git.branching_strategy, 'string', 'git.branching_strategy should be a string'); - } finally { - // Restore - if (existingDefaults !== null) { - fs.writeFileSync(defaultsFile, existingDefaults, 'utf-8'); - } else { - try { fs.unlinkSync(defaultsFile); } catch { /* ignore */ } - } - if (!gsdDirExisted) { - try { fs.rmdirSync(gsdDir); } catch { /* ignore */ } - } - } + const config = readConfig(tmpDir); + assert.strictEqual(config.model_profile, 'quality', 'model_profile should be overridden'); + assert.strictEqual(config.commit_docs, false, 'commit_docs should be overridden'); + assert.ok(config.git && typeof config.git === 'object', 'git should be an object'); + assert.strictEqual(typeof config.git.branching_strategy, 'string', 'git.branching_strategy should be a string'); }); - // NOTE: This test touches ~/.gsd/ on the real filesystem. It uses save/restore - // try/finally and skips if the file already exists to avoid corrupting user config. test('merges nested workflow keys from defaults.json preserving unset keys', () => { - const homedir = os.homedir(); - const gsdDir = path.join(homedir, '.gsd'); - const defaultsFile = path.join(gsdDir, 'defaults.json'); + // runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there — + // no real filesystem side effects, cleanup happens via afterEach. + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ + workflow: { research: false }, + }), 'utf-8'); - let existingDefaults = null; - const gsdDirExisted = fs.existsSync(gsdDir); - if (fs.existsSync(defaultsFile)) { - existingDefaults = fs.readFileSync(defaultsFile, 'utf-8'); - } + const result = runGsdTools('config-ensure-section', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); - try { - if (!gsdDirExisted) { - fs.mkdirSync(gsdDir, { recursive: true }); - } - fs.writeFileSync(defaultsFile, JSON.stringify({ - workflow: { research: false }, - }), 'utf-8'); - - const result = runGsdTools('config-ensure-section', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.research, false, 'research should be overridden'); - assert.strictEqual(typeof config.workflow.plan_check, 'boolean', 'plan_check should be a boolean'); - assert.strictEqual(typeof config.workflow.verifier, 'boolean', 'verifier should be a boolean'); - } finally { - if (existingDefaults !== null) { - fs.writeFileSync(defaultsFile, existingDefaults, 'utf-8'); - } else { - try { fs.unlinkSync(defaultsFile); } catch { /* ignore */ } - } - if (!gsdDirExisted) { - try { fs.rmdirSync(gsdDir); } catch { /* ignore */ } - } - } + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.research, false, 'research should be overridden'); + assert.strictEqual(typeof config.workflow.plan_check, 'boolean', 'plan_check should be a boolean'); + assert.strictEqual(typeof config.workflow.verifier, 'boolean', 'verifier should be a boolean'); }); }); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 4dddcf461..06abd6cc3 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -18,17 +18,22 @@ const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools function runGsdTools(args, cwd = process.cwd()) { try { let result; + // Override HOME so buildNewProjectConfig() doesn't pick up ~/.gsd/defaults.json + // from the developer's machine, which would cause flaky value assertions. + const env = { ...process.env, HOME: cwd }; if (Array.isArray(args)) { result = execFileSync(process.execPath, [TOOLS_PATH, ...args], { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + env, }); } else { result = execSync(`node "${TOOLS_PATH}" ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + env, }); } return { success: true, output: result.trim() }; From 43fc1b11d45b568473079738d4c4a48bc2846ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Mari=C3=B1o?= Date: Wed, 18 Mar 2026 15:29:40 +0100 Subject: [PATCH 3/5] fix(workflow): restore nyquist_validation derivation in Step 5 config Before this PR, Step 5 derived nyquist_validation from depth !== "quick" (now granularity !== "coarse"). The new config-new-project call omitted it, silently defaulting to true even when the user selected "Coarse" granularity. Adds nyquist_validation back to the Step 5 JSON payload with an explicit inline rule: false when granularity=coarse, true otherwise. --- get-shit-done/workflows/new-project.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index 26410155f..893de103e 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -459,7 +459,7 @@ Create `.planning/config.json` with all settings (CLI fills in remaining default ```bash mkdir -p .planning -node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-new-project '{"mode":"[yolo|interactive]","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false}}' +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-new-project '{"mode":"[yolo|interactive]","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false,"nyquist_validation":[false if granularity=coarse, true otherwise]}}' ``` **Note:** Run `/gsd:settings` anytime to update model profile, workflow agents, branching strategy, and other preferences. From a1207d5473b8e6bc1e2a9235d883a4c68db8ed43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Mari=C3=B1o?= Date: Wed, 18 Mar 2026 23:10:56 +0100 Subject: [PATCH 4/5] fix(tests): make HOME sandboxing opt-in to avoid breaking git-dependent tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global HOME override in runGsdTools broke tests in verify-health.test.cjs on Ubuntu CI: git operations fail when HOME points to a tmpDir that lacks the runner's .gitconfig. - runGsdTools now accepts an optional third `env` parameter (default: {}) merged on top of process.env — no behavior change for callers that omit it - Pass { HOME: tmpDir } only in the 6 tests that need ~/.gsd/ isolation: brave_api_key detection, defaults.json merging (x2), and config-new-project tests that assert concrete default values (x3) --- tests/config.test.cjs | 12 ++++++------ tests/helpers.cjs | 13 +++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/config.test.cjs b/tests/config.test.cjs index c6fe2590f..65b62aa4f 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -83,7 +83,7 @@ describe('config-ensure-section command', () => { fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'brave_api_key'), 'test-key', 'utf-8'); - const result = runGsdTools('config-ensure-section', tmpDir); + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -100,7 +100,7 @@ describe('config-ensure-section command', () => { commit_docs: false, }), 'utf-8'); - const result = runGsdTools('config-ensure-section', tmpDir); + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -119,7 +119,7 @@ describe('config-ensure-section command', () => { workflow: { research: false }, }), 'utf-8'); - const result = runGsdTools('config-ensure-section', tmpDir); + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -330,7 +330,7 @@ describe('config-new-project command', () => { model_profile: 'balanced', workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, }); - const result = runGsdTools(['config-new-project', choices], tmpDir); + const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -378,7 +378,7 @@ describe('config-new-project command', () => { model_profile: 'quality', workflow: { research: false, plan_check: false, verifier: true, nyquist_validation: false }, }); - const result = runGsdTools(['config-new-project', choices], tmpDir); + const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -397,7 +397,7 @@ describe('config-new-project command', () => { }); test('works with empty choices — all defaults materialized', () => { - const result = runGsdTools(['config-new-project', '{}'], tmpDir); + const result = runGsdTools(['config-new-project', '{}'], tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 06abd6cc3..8a3cdcb3e 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -14,26 +14,27 @@ const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools * @param {string|string[]} args - Command string (shell-interpreted) or array * of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs). * @param {string} cwd - Working directory. + * @param {object} [env] - Optional env overrides merged on top of process.env. + * Pass { HOME: cwd } to sandbox ~/.gsd/ lookups in tests that assert concrete + * config values that could be overridden by a developer's defaults.json. */ -function runGsdTools(args, cwd = process.cwd()) { +function runGsdTools(args, cwd = process.cwd(), env = {}) { try { let result; - // Override HOME so buildNewProjectConfig() doesn't pick up ~/.gsd/defaults.json - // from the developer's machine, which would cause flaky value assertions. - const env = { ...process.env, HOME: cwd }; + const childEnv = { ...process.env, ...env }; if (Array.isArray(args)) { result = execFileSync(process.execPath, [TOOLS_PATH, ...args], { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - env, + env: childEnv, }); } else { result = execSync(`node "${TOOLS_PATH}" ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - env, + env: childEnv, }); } return { success: true, output: result.trim() }; From a1852fef33f305e0f0b0fadb7020192f39e5c46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Mari=C3=B1o?= Date: Fri, 20 Mar 2026 14:56:28 +0100 Subject: [PATCH 5/5] fix(tests): add USERPROFILE override for Windows HOME sandboxing On Windows, os.homedir() reads USERPROFILE instead of HOME. The 6 tests using { HOME: tmpDir } to sandbox ~/.gsd/ lookups failed on windows-latest because the child process still resolved homedir to the real user profile. Pass USERPROFILE alongside HOME in all sandboxed test calls. --- tests/config.test.cjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 0ec5659ec..3a981ba7b 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -83,7 +83,7 @@ describe('config-ensure-section command', () => { fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'brave_api_key'), 'test-key', 'utf-8'); - const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -100,7 +100,7 @@ describe('config-ensure-section command', () => { commit_docs: false, }), 'utf-8'); - const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -119,7 +119,7 @@ describe('config-ensure-section command', () => { workflow: { research: false }, }), 'utf-8'); - const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -340,7 +340,7 @@ describe('config-new-project command', () => { model_profile: 'balanced', workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, }); - const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir }); + const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -388,7 +388,7 @@ describe('config-new-project command', () => { model_profile: 'quality', workflow: { research: false, plan_check: false, verifier: true, nyquist_validation: false }, }); - const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir }); + const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); @@ -407,7 +407,7 @@ describe('config-new-project command', () => { }); test('works with empty choices — all defaults materialized', () => { - const result = runGsdTools(['config-new-project', '{}'], tmpDir, { HOME: tmpDir }); + const result = runGsdTools(['config-new-project', '{}'], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir);