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); + } + }); +});