diff --git a/docs/superpowers/plans/2026-03-18-materialize-new-project-config.md b/docs/superpowers/plans/2026-03-18-materialize-new-project-config.md new file mode 100644 index 000000000..44aaf28f6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-materialize-new-project-config.md @@ -0,0 +1,700 @@ +# Materialize new-project config on initialization + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When `/gsd:new-project` creates `.planning/config.json`, the file contains all effective defaults — not just the 6 user-chosen keys — so developers can see every setting without reading source code. + +**Architecture:** Add a single JS function `buildNewProjectConfig(cwd, userChoices)` in `config.cjs` as the one source of truth for a new project's full config. Expose it as a CLI command `config-new-project`. Update the `new-project.md` workflow to call this command instead of writing a partial JSON inline. + +**Tech Stack:** Node.js/CommonJS, existing gsd-tools CLI, `node:test` for tests. + +--- + +## Background: what exists today + +`new-project.md` Step 5 writes this partial config (the AI fills the template): + +```json +{ + "mode": "...", "granularity": "...", "parallelization": "...", + "commit_docs": "...", "model_profile": "...", + "workflow": { "research", "plan_check", "verifier", "nyquist_validation" } +} +``` + +Missing keys silently resolved by `loadConfig()` at runtime: + +- `search_gitignored: false` +- `brave_search: false` (or env-detected `true`) +- `git.branching_strategy: "none"` +- `git.phase_branch_template: "gsd/phase-{phase}-{slug}"` +- `git.milestone_branch_template: "gsd/{milestone}-{slug}"` + +Full config that should exist from the start: + +```json +{ + "mode": "yolo|interactive", + "granularity": "coarse|standard|fine", + "model_profile": "balanced", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "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 + } +} +``` + +--- + +## File map + +| File | Action | Purpose | +|------|--------|---------| +| `get-shit-done/bin/lib/config.cjs` | Modify | Add `buildNewProjectConfig()` + `cmdConfigNewProject()` | +| `get-shit-done/bin/gsd-tools.cjs` | Modify | Register `config-new-project` case + update usage string | +| `get-shit-done/workflows/new-project.md` | Modify | Steps 2a + 5: replace inline JSON write with CLI call | +| `tests/config.test.cjs` | Modify | Add `config-new-project` test suite | + +--- + +## Task 1: Add `buildNewProjectConfig` and `cmdConfigNewProject` to config.cjs + +**Files:** + +- Modify: `get-shit-done/bin/lib/config.cjs` + +- [ ] **Step 1.1: Write the failing tests first** + +Add to `tests/config.test.cjs` (after the `config-get` suite, before `module.exports`): + +```js +// ─── config-new-project ────────────────────────────────────────────────────── + +describe('config-new-project command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('creates full config with all expected top-level and nested 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 + 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 four 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); + }); + + 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); + }); + + test('is idempotent — returns already_exists if config exists', () => { + // First call: create + 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); + + // Second call: idempotent + 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 JSON has created:true 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'); + }); +}); +``` + +- [ ] **Step 1.2: Run failing tests to confirm they fail** + +```bash +cd /Users/diego/Dev/get-shit-done +node --test tests/config.test.cjs 2>&1 | grep -E "config-new-project|FAIL|Error" +``` + +Expected: All `config-new-project` tests fail with "config-new-project is not a valid command" or similar. + +- [ ] **Step 1.3: Implement `buildNewProjectConfig` and `cmdConfigNewProject` in config.cjs** + +In `get-shit-done/bin/lib/config.cjs`, add the following after the `validateKnownConfigKeyPath` function (around line 35) and before `ensureConfigFile`: + +```js +/** + * Build a fully-materialized config for a new project. + * + * Merges (in order of increasing priority): + * 1. Hardcoded defaults + * 2. User-level defaults from ~/.gsd/defaults.json (if present) + * 3. userChoices (the settings the user explicitly selected during new-project) + * + * Returns a plain object — does NOT write any files. + */ +function buildNewProjectConfig(cwd, 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, + }, + }; + + // Three-level merge: hardcoded <- userDefaults <- choices + return { + ...hardcoded, + ...userDefaults, + ...choices, + git: { + ...hardcoded.git, + ...(userDefaults.git || {}), + ...(choices.git || {}), + }, + workflow: { + ...hardcoded.workflow, + ...(userDefaults.workflow || {}), + ...(choices.workflow || {}), + }, + }; +} + +/** + * 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(cwd, 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); + } +} +``` + +Also add `cmdConfigNewProject` to the `module.exports` at the bottom of `config.cjs`. + +- [ ] **Step 1.4: Run tests to verify they pass** + +```bash +cd /Users/diego/Dev/get-shit-done +node --test tests/config.test.cjs 2>&1 | tail -20 +``` + +Expected: All `config-new-project` tests pass. Existing tests still pass. + +- [ ] **Step 1.5: Commit** + +```bash +cd /Users/diego/Dev/get-shit-done +git add get-shit-done/bin/lib/config.cjs tests/config.test.cjs +git commit -m "feat: add config-new-project command for full config materialization" +``` + +--- + +## Task 2: Register `config-new-project` in gsd-tools.cjs + +**Files:** + +- Modify: `get-shit-done/bin/gsd-tools.cjs` + +- [ ] **Step 2.1: Add the case to the switch in gsd-tools.cjs** + +After the `config-get` case (around line 401), add: + +```js + case 'config-new-project': { + config.cmdConfigNewProject(cwd, args[1], raw); + break; + } +``` + +Also update the usage string on line 178 to include `config-new-project`: + +Current: `...config-ensure-section, init` +New: `...config-ensure-section, config-new-project, init` + +- [ ] **Step 2.2: Smoke-test the CLI registration** + +```bash +cd /Users/diego/Dev/get-shit-done +node get-shit-done/bin/gsd-tools.cjs config-new-project '{"mode":"interactive","granularity":"standard"}' --cwd /tmp/gsd-smoke-$(date +%s) +``` + +Expected: outputs `{"created":true,"path":".planning/config.json"}` (or similar). + +Clean up: `rm -rf /tmp/gsd-smoke-*` + +- [ ] **Step 2.3: Run full test suite** + +```bash +cd /Users/diego/Dev/get-shit-done +node --test tests/config.test.cjs 2>&1 | tail -10 +``` + +Expected: All pass. + +- [ ] **Step 2.4: Commit** + +```bash +cd /Users/diego/Dev/get-shit-done +git add get-shit-done/bin/gsd-tools.cjs +git commit -m "feat: register config-new-project in gsd-tools CLI router" +``` + +--- + +## Task 3: Update new-project.md workflow to use config-new-project + +**Files:** + +- Modify: `get-shit-done/workflows/new-project.md` + +This is the core change. Two places need updating: + +- **Step 2a** (auto mode config creation, around line 168–195) +- **Step 5** (interactive mode config creation, around line 470–498) + +- [ ] **Step 3.1: Update Step 2a (auto mode)** + +Find the block in Step 2a that creates config.json: + +```markdown +Create `.planning/config.json` with mode set to "yolo": + +```json +{ + "mode": "yolo", + "granularity": "[selected]", + ... +} +``` + +``` + +Replace the inline JSON write instruction with: + +```markdown +Create `.planning/config.json` using the CLI (fills in all defaults automatically): + +```bash +mkdir -p .planning +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-new-project "$(cat <<'CHOICES' +{ + "mode": "yolo", + "granularity": "[selected: coarse|standard|fine]", + "parallelization": [true|false], + "commit_docs": [true|false], + "model_profile": "[selected: quality|balanced|budget|inherit]", + "workflow": { + "research": [true|false], + "plan_check": [true|false], + "verifier": [true|false], + "nyquist_validation": [true|false], + "auto_advance": true + } +} +CHOICES +)" +``` + +The command merges your selections with all runtime defaults (`search_gitignored`, `brave_search`, `git` section), producing a fully-materialized config. + +``` + +- [ ] **Step 3.2: Update Step 5 (interactive mode)** + +Find the block in Step 5 that creates config.json: + +```markdown +Create `.planning/config.json` with all settings: + +```json +{ + "mode": "yolo|interactive", + ... +} +``` + +``` + +Replace with: + +```markdown +Create `.planning/config.json` using the CLI (fills in all defaults automatically): + +```bash +mkdir -p .planning +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-new-project "$(cat <<'CHOICES' +{ + "mode": "[selected: yolo|interactive]", + "granularity": "[selected: coarse|standard|fine]", + "parallelization": [true|false], + "commit_docs": [true|false], + "model_profile": "[selected: quality|balanced|budget|inherit]", + "workflow": { + "research": [true|false], + "plan_check": [true|false], + "verifier": [true|false], + "nyquist_validation": [true|false] + } +} +CHOICES +)" +``` + +The command merges your selections with all runtime defaults (`search_gitignored`, `brave_search`, `git` section), producing a fully-materialized config. + +``` + +- [ ] **Step 3.3: Verify the workflow file reads correctly** + +```bash +cd /Users/diego/Dev/get-shit-done +grep -n "config-new-project\|config\.json\|CHOICES" get-shit-done/workflows/new-project.md +``` + +Expected: 2 occurrences of `config-new-project` (one per step), no more inline JSON templates for config creation. + +- [ ] **Step 3.4: Commit** + +```bash +cd /Users/diego/Dev/get-shit-done +git add get-shit-done/workflows/new-project.md +git commit -m "feat: use config-new-project in new-project workflow for full config materialization" +``` + +--- + +## Task 4: Validation + +- [ ] **Step 4.1: Run the full test suite** + +```bash +cd /Users/diego/Dev/get-shit-done +node --test tests/ 2>&1 | tail -30 +``` + +Expected: All tests pass (no regressions). + +- [ ] **Step 4.2: Manual end-to-end validation** + +Simulate what `new-project.md` does for a new project: + +```bash +# Create a fresh project dir +TMP=$(mktemp -d) +cd "$TMP" + +# Step 1 simulation: what init new-project returns +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs init new-project --cwd "$TMP" + +# Step 5 simulation: create full config +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs config-new-project '{ + "mode": "interactive", + "granularity": "standard", + "parallelization": true, + "commit_docs": true, + "model_profile": "balanced", + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": true + } +}' --cwd "$TMP" + +# Verify the file has all 12 expected keys +echo "=== Generated config.json ===" +cat "$TMP/.planning/config.json" + +# Clean up +rm -rf "$TMP" +``` + +Expected output: a config.json with `mode`, `granularity`, `model_profile`, `commit_docs`, `parallelization`, `search_gitignored`, `brave_search`, `git` (3 sub-keys), `workflow` (4 sub-keys) — 12 top-level keys total (or 10 if counting `git` and `workflow` as single keys). + +- [ ] **Step 4.3: Verify idempotency** + +```bash +TMP=$(mktemp -d) +CHOICES='{"mode":"yolo","granularity":"coarse"}' + +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs config-new-project "$CHOICES" --cwd "$TMP" +FIRST=$(cat "$TMP/.planning/config.json") + +# Second call should be no-op +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs config-new-project "$CHOICES" --cwd "$TMP" +SECOND=$(cat "$TMP/.planning/config.json") + +[ "$FIRST" = "$SECOND" ] && echo "IDEMPOTENT: OK" || echo "IDEMPOTENT: FAIL" +rm -rf "$TMP" +``` + +Expected: `IDEMPOTENT: OK` + +- [ ] **Step 4.4: Verify loadConfig still reads the new format correctly** + +```bash +TMP=$(mktemp -d) +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs config-new-project '{ + "mode":"yolo","granularity":"standard","parallelization":true,"commit_docs":true, + "model_profile":"balanced", + "workflow":{"research":true,"plan_check":false,"verifier":true,"nyquist_validation":true} +}' --cwd "$TMP" + +# loadConfig should correctly read plan_check (nested as workflow.plan_check) +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs config-get workflow.plan_check --cwd "$TMP" +# Expected: false + +node /Users/diego/Dev/get-shit-done/get-shit-done/bin/gsd-tools.cjs config-get git.branching_strategy --cwd "$TMP" +# Expected: "none" + +rm -rf "$TMP" +``` + +- [ ] **Step 4.5: Final full test suite + commit** + +```bash +cd /Users/diego/Dev/get-shit-done +node --test tests/ 2>&1 | grep -E "pass|fail|error" | tail -5 +``` + +Expected: All pass, 0 failures. + +--- + +## Appendix: PR description for upstream + +``` +feat: materialize all config defaults at new-project initialization + +**Problem:** +`/gsd:new-project` creates `.planning/config.json` with only the 6 keys +the user explicitly chose during onboarding. Five additional keys +(`search_gitignored`, `brave_search`, `git.branching_strategy`, +`git.phase_branch_template`, `git.milestone_branch_template`) are resolved +silently by `loadConfig()` at runtime but never written to disk. + +This creates two problems: +1. **Discoverability**: users can't see or understand `git.branching_strategy` + without reading source code — it doesn't appear in their config. +2. **Implicit expansion**: the first time `/gsd:settings` or `config-set` + writes to the config, those keys still aren't added. The config only + reflects a fraction of the effective configuration. + +**Solution:** +Add `config-new-project` CLI command to `gsd-tools.cjs`. The command: +- Accepts user-chosen values as JSON +- Merges them with all runtime defaults (including env-detected `brave_search`) +- Writes the fully-materialized config in one shot + +Update `new-project.md` workflow (Steps 2a and 5) to call this command +instead of writing a hardcoded partial JSON template. Defaults now live in +exactly one place: `buildNewProjectConfig()` in `config.cjs`. + +**Why this is conservative:** +- No changes to `loadConfig()`, `ensureConfigFile()`, or any read path +- No new config keys introduced +- No semantic changes — same values the system was already resolving silently +- Fully backward-compatible: `loadConfig()` continues to handle both the old + partial format (existing projects) and the new full format +- Idempotent: calling `config-new-project` twice is safe +- No new user-facing flags + +**Why this improves discoverability:** +A developer opening `.planning/config.json` for the first time can now see +`git.branching_strategy: "none"` and immediately understand that branching +is available and configurable, without reading the GSD source. +``` diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 4ee194ec1..f2b455397 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -188,7 +188,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'); } // Multi-repo guard: resolve project root for commands that read/write .planning/. @@ -449,6 +449,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 f8e16308b..eef0afd97 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -16,10 +16,12 @@ const VALID_CONFIG_KEYS = new Set([ 'search_gitignored', 'brave_search', 'firecrawl', 'exa_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.text_mode', 'workflow._auto_chain_active', 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'planning.commit_docs', 'planning.search_gitignored', + 'hooks.context_warnings', ]); const CONFIG_KEY_SUGGESTIONS = { @@ -35,6 +37,154 @@ 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 API key availability + const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); + const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); + const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key'); + const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || fs.existsSync(firecrawlKeyFile)); + const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key'); + const hasExaSearch = !!(process.env.EXA_API_KEY || fs.existsSync(exaKeyFile)); + + // 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 { /* intentionally empty */ } + } + } + } catch { + // Ignore malformed global defaults + } + + const hardcoded = { + model_profile: 'balanced', + commit_docs: true, + parallelization: true, + search_gitignored: false, + brave_search: hasBraveSearch, + firecrawl: hasFirecrawl, + exa_search: hasExaSearch, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + 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, + text_mode: false, + }, + 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). * @@ -59,67 +209,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)); - - // Detect Firecrawl API key availability - const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key'); - const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || fs.existsSync(firecrawlKeyFile)); - - // Detect Exa API key availability - const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key'); - const hasExaSearch = !!(process.env.EXA_API_KEY || fs.existsSync(exaKeyFile)); - - // 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 { /* intentionally empty */ } - } - } - } 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}', - quick_branch_template: null, - workflow: { - research: true, - plan_check: true, - verifier: true, - nyquist_validation: true, - }, - parallelization: true, - brave_search: hasBraveSearch, - firecrawl: hasFirecrawl, - exa_search: hasExaSearch, - }; - 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); @@ -316,4 +409,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 5b5d6ad5a..3d93f6cc9 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -7,11 +7,13 @@ Read all files referenced by the invoking prompt's execution_context before star + ## Auto Mode Detection Check if `--auto` flag is present in $ARGUMENTS. **If auto mode:** + - Skip brownfield mapping offer (assume greenfield) - Skip deep questioning (extract context from provided document) - Config: YOLO mode is implicit (skip that question), but ask granularity/git/agents FIRST (Step 2a) @@ -23,6 +25,7 @@ Check if `--auto` flag is present in $ARGUMENTS. **Document requirement:** Auto mode requires an idea document — either: + - File reference: `/gsd:new-project --auto @prd.md` - Pasted/written text in the prompt @@ -37,6 +40,7 @@ Usage: The document should describe what you want to build. ``` + @@ -55,6 +59,7 @@ Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `co **If `project_exists` is true:** Error — project already initialized. Use `/gsd:progress`. **If `has_git` is false:** Initialize git: + ```bash git init ``` @@ -66,6 +71,7 @@ git init **If `needs_codebase_map` is true** (from init — existing code detected but no codebase map): Use AskUserQuestion: + - header: "Codebase" - question: "I detected existing code in this directory. Would you like to map the codebase first?" - options: @@ -73,9 +79,11 @@ Use AskUserQuestion: - "Skip mapping" — Proceed with project initialization **If "Map codebase first":** + ``` Run `/gsd:map-codebase` first, then return to `/gsd:new-project` ``` + Exit command. **If "Skip mapping" OR `needs_codebase_map` is false:** Continue to Step 3. @@ -166,23 +174,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`. @@ -223,6 +219,7 @@ Ask inline (freeform, NOT AskUserQuestion): Wait for their response. This gives you the context needed to ask intelligent follow-up questions. **Research-before-questions mode:** Check if `research_questions` is enabled in `.planning/config.json` (or the config from init context). When enabled, before asking follow-up questions about a topic area: + 1. Do a brief web search for best practices related to what the user described 2. Mention key findings naturally as you ask questions (e.g., "Most projects like this use X — is that what you're thinking, or something different?") 3. This makes questions more informed without changing the conversational flow @@ -234,6 +231,7 @@ When disabled (default), ask questions directly as before. Based on what they said, ask follow-up questions that dig into their response. Use AskUserQuestion with options that probe what they mentioned — interpretations, clarifications, concrete examples. Keep following threads. Each answer opens new threads to explore. Ask about: + - What excited them - What problem sparked this - What they mean by vague terms @@ -241,6 +239,7 @@ Keep following threads. Each answer opens new threads to explore. Ask about: - What's already decided Consult `questioning.md` for techniques: + - Challenge vagueness - Make abstract concrete - Surface assumptions @@ -495,29 +494,22 @@ 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,"nyquist_validation":[false if granularity=coarse, true otherwise]}}' ``` +**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) **If commit_docs = Yes:** + - No additional gitignore entries needed **Commit config.json:** @@ -526,8 +518,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.1. Sub-Repo Detection **Detect multi-repo workspace:** @@ -543,6 +533,7 @@ find . -maxdepth 1 -type d -not -name ".*" -not -name "node_modules" -exec test Strip the `./` prefix to get directory names (e.g., `./backend` → `backend`). Use AskUserQuestion: + - header: "Multi-Repo Workspace" - question: "I detected separate git repos in this workspace. Which directories contain code that GSD should commit to?" - multiSelect: true @@ -550,6 +541,7 @@ Use AskUserQuestion: - "[directory name]" — Separate git repo **If user selects one or more directories:** + - Set `planning.sub_repos` in config.json to the selected directory names array (e.g., `["backend", "frontend"]`) - Auto-set `planning.commit_docs` to `false` (planning docs stay local in multi-repo workspaces) - Add `.planning/` to `.gitignore` if not already present @@ -567,6 +559,7 @@ Use models from init: `researcher_model`, `synthesizer_model`, `roadmapper_model **If auto mode:** Default to "Research first" without asking. Use AskUserQuestion: + - header: "Research" - question: "Research the domain ecosystem before defining requirements?" - options: @@ -576,6 +569,7 @@ Use AskUserQuestion: **If "Research first":** Display stage banner: + ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ GSD ► RESEARCHING @@ -585,6 +579,7 @@ Researching [domain] ecosystem... ``` Create research directory: + ```bash mkdir -p .planning/research ``` @@ -592,10 +587,12 @@ mkdir -p .planning/research **Determine milestone context:** Check if this is greenfield or subsequent milestone: + - If no "Validated" requirements in PROJECT.md → Greenfield (building from scratch) - If "Validated" requirements exist → Subsequent milestone (adding to existing app) Display spawning indicator: + ``` ◆ Spawning 4 researchers in parallel... → Stack research @@ -784,6 +781,7 @@ Commit after writing. ``` Display research complete banner and key findings: + ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ GSD ► RESEARCH COMPLETE ✓ @@ -803,6 +801,7 @@ Files: `.planning/research/` ## 7. Define Requirements Display stage banner: + ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ GSD ► DEFINING REQUIREMENTS @@ -812,6 +811,7 @@ Display stage banner: **Load context:** Read PROJECT.md and extract: + - Core value (the ONE thing that must work) - Stated constraints (budget, timeline, tech limitations) - Any explicit scope boundaries @@ -819,6 +819,7 @@ Read PROJECT.md and extract: **If research exists:** Read research/FEATURES.md and extract feature categories. **If auto mode:** + - Auto-include all table stakes features (users expect these) - Include features explicitly mentioned in provided document - Auto-defer differentiators not mentioned in document @@ -857,6 +858,7 @@ Here are the features for [domain]: Ask: "What are the main things users need to be able to do?" For each capability mentioned: + - Ask clarifying questions to make it specific - Probe for related capabilities - Group into categories @@ -875,6 +877,7 @@ For each category, use AskUserQuestion: - "None for v1" — Defer entire category Track responses: + - Selected features → v1 requirements - Unselected table stakes → v2 (users expect these) - Unselected differentiators → out of scope @@ -882,6 +885,7 @@ Track responses: **Identify gaps:** Use AskUserQuestion: + - header: "Additions" - question: "Any requirements research missed? (Features specific to your vision)" - options: @@ -895,6 +899,7 @@ Cross-check requirements against Core Value from PROJECT.md. If gaps detected, s **Generate REQUIREMENTS.md:** Create `.planning/REQUIREMENTS.md` with: + - v1 Requirements grouped by category (checkboxes, REQ-IDs) - v2 Requirements (deferred) - Out of Scope (explicit exclusions with reasoning) @@ -905,12 +910,14 @@ Create `.planning/REQUIREMENTS.md` with: **Requirement quality criteria:** Good requirements are: + - **Specific and testable:** "User can reset password via email link" (not "Handle password reset") - **User-centric:** "User can X" (not "System does Y") - **Atomic:** One capability per requirement (not "User can login and manage profile") - **Independent:** Minimal dependencies on other requirements Reject vague requirements. Push for specificity: + - "Handle authentication" → "User can log in with email/password and stay logged in across sessions" - "Support sharing" → "User can share post via link that opens in recipient's browser" @@ -948,6 +955,7 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: define v1 req ## 8. Create Roadmap Display stage banner: + ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ GSD ► CREATING ROADMAP @@ -988,6 +996,7 @@ Write files first, then return. This ensures artifacts persist even if context i **Handle roadmapper return:** **If `## ROADMAP BLOCKED`:** + - Present blocker information - Work with user to resolve - Re-spawn when resolved @@ -1037,6 +1046,7 @@ Success criteria: **CRITICAL: Ask for approval before committing (interactive mode only):** Use AskUserQuestion: + - header: "Roadmap" - question: "Does this roadmap structure work for you?" - options: @@ -1047,8 +1057,10 @@ Use AskUserQuestion: **If "Approve":** Continue to commit. **If "Adjust phases":** + - Get user's adjustment notes - Re-spawn roadmapper with revision context: + ``` Task(prompt=" @@ -1064,6 +1076,7 @@ Use AskUserQuestion: ", subagent_type="gsd-roadmapper", model="{roadmapper_model}", description="Revise roadmap") ``` + - Present revised roadmap - Loop until user approves diff --git a/package-lock.json b/package-lock.json index 2e1af42c1..c08d252d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "esbuild": "^0.24.0" }, "engines": { - "node": ">=16.7.0" + "node": ">=20.0.0" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 33fb9d44a..3a981ba7b 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 ────────────────────────────────────────────────────────────────── @@ -51,7 +50,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'); @@ -76,121 +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, { HOME: tmpDir, USERPROFILE: 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, { HOME: tmpDir, USERPROFILE: 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.strictEqual(typeof config.branching_strategy, 'string', '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, { HOME: tmpDir, USERPROFILE: 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'); }); }); @@ -382,3 +317,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, { HOME: tmpDir, USERPROFILE: 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, { HOME: tmpDir, USERPROFILE: 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, { HOME: tmpDir, USERPROFILE: 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); + } + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 4dddcf461..8a3cdcb3e 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -14,21 +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; + 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: childEnv, }); } else { result = execSync(`node "${TOOLS_PATH}" ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv, }); } return { success: true, output: result.trim() };