diff --git a/commands/gsd/set-profile.md b/commands/gsd/set-profile.md index ab24458d13..6e1eb107ae 100644 --- a/commands/gsd/set-profile.md +++ b/commands/gsd/set-profile.md @@ -1,34 +1,12 @@ --- name: gsd:set-profile description: Switch model profile for GSD agents (quality/balanced/budget) -argument-hint: +argument-hint: +model: haiku allowed-tools: - - Read - - Write - Bash --- - -Switch the model profile used by GSD agents. Controls which Claude model each agent uses, balancing quality vs token spend. +Show the following output to the user verbatim, with no extra commentary: -Routes to the set-profile workflow which handles: -- Argument validation (quality/balanced/budget) -- Config file creation if missing -- Profile update in config.json -- Confirmation with model table display - - - -@~/.claude/get-shit-done/workflows/set-profile.md - - - -**Follow the set-profile workflow** from `@~/.claude/get-shit-done/workflows/set-profile.md`. - -The workflow handles all logic including: -1. Profile argument validation -2. Config file ensuring -3. Config reading and updating -4. Model table generation from MODEL_PROFILES -5. Confirmation display - +!`node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-set-model-profile $ARGUMENTS --raw` diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 48cb9cf563..07c0a5e032 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -377,6 +377,11 @@ async function main() { break; } + case "config-set-model-profile": { + config.cmdConfigSetModelProfile(cwd, args[1], raw); + break; + } + case 'config-get': { config.cmdConfigGet(cwd, args[1], raw); break; diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index e42034946f..90ee735400 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,8 +4,9 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); +const { MODEL_PROFILES } = require('./model-profiles.cjs'); function cmdGenerateSlug(text, raw) { if (!text) { diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 61d8798b79..b653398569 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -5,8 +5,19 @@ const fs = require('fs'); const path = require('path'); const { output, error } = require('./core.cjs'); +const { + VALID_PROFILES, + getAgentToModelMapForProfile, + formatAgentToModelMapAsTable, +} = require('./model-profiles.cjs'); -function cmdConfigEnsureSection(cwd, raw) { +/** + * Ensures the config file exists (creates it if needed). + * + * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in + * the happy path. But note that `error()` will still `exit(1)` out of the process. + */ +function ensureConfigFile(cwd) { const configPath = path.join(cwd, '.planning', 'config.json'); const planningDir = path.join(cwd, '.planning'); @@ -21,9 +32,7 @@ function cmdConfigEnsureSection(cwd, raw) { // Check if config already exists if (fs.existsSync(configPath)) { - const result = { created: false, reason: 'already_exists' }; - output(result, raw, 'exists'); - return; + return { created: false, reason: 'already_exists' }; } // Detect Brave Search API key availability @@ -42,7 +51,9 @@ function cmdConfigEnsureSection(cwd, raw) { 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 {} + try { + fs.writeFileSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2), 'utf-8'); + } catch {} } } } catch (err) { @@ -74,25 +85,36 @@ function cmdConfigEnsureSection(cwd, raw) { try { fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); - const result = { created: true, path: '.planning/config.json' }; - output(result, raw, 'created'); + return { created: true, path: '.planning/config.json' }; } catch (err) { error('Failed to create config.json: ' + err.message); } } -function cmdConfigSet(cwd, keyPath, value, raw) { - const configPath = path.join(cwd, '.planning', 'config.json'); - - if (!keyPath) { - error('Usage: config-set '); +/** + * Command to ensure the config file exists (creates it if needed). + * + * Note that this exits the process (via `output()`) even in the happy path; use + * `ensureConfigFile()` directly if you need to avoid this. + */ +function cmdConfigEnsureSection(cwd, raw) { + const ensureConfigFileResult = ensureConfigFile(cwd); + if (ensureConfigFileResult.created) { + output(ensureConfigFileResult, raw, 'created'); + } else { + output(ensureConfigFileResult, raw, 'exists'); } +} - // Parse value (handle booleans and numbers) - let parsedValue = value; - if (value === 'true') parsedValue = true; - else if (value === 'false') parsedValue = false; - else if (!isNaN(value) && value !== '') parsedValue = Number(value); +/** + * Sets a value in the config file, allowing nested values via dot notation (e.g., + * "workflow.research"). + * + * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in + * the happy path. But note that `error()` will still `exit(1)` out of the process. + */ +function setConfigValue(cwd, keyPath, parsedValue) { + const configPath = path.join(cwd, '.planning', 'config.json'); // Load existing config or start with empty object let config = {}; @@ -114,18 +136,40 @@ function cmdConfigSet(cwd, keyPath, value, raw) { } current = current[key]; } + const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting current[keys[keys.length - 1]] = parsedValue; // Write back try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - const result = { updated: true, key: keyPath, value: parsedValue }; - output(result, raw, `${keyPath}=${parsedValue}`); + return { updated: true, key: keyPath, value: parsedValue, previousValue }; } catch (err) { error('Failed to write config.json: ' + err.message); } } +/** + * Command to set a value in the config file, allowing nested values via dot notation (e.g., + * "workflow.research"). + * + * Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()` + * directly if you need to avoid this. + */ +function cmdConfigSet(cwd, keyPath, value, raw) { + if (!keyPath) { + error('Usage: config-set '); + } + + // Parse value (handle booleans and numbers) + let parsedValue = value; + if (value === 'true') parsedValue = true; + else if (value === 'false') parsedValue = false; + else if (!isNaN(value) && value !== '') parsedValue = Number(value); + + const setConfigValueResult = setConfigValue(cwd, keyPath, parsedValue); + output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`); +} + function cmdConfigGet(cwd, keyPath, raw) { const configPath = path.join(cwd, '.planning', 'config.json'); @@ -162,8 +206,73 @@ function cmdConfigGet(cwd, keyPath, raw) { output(current, raw, String(current)); } +/** + * Command to set the model profile in the config file. + * + * Note that this exits the process (via `output()`) even in the happy path. + */ +function cmdConfigSetModelProfile(cwd, profile, raw) { + if (!profile) { + error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`); + } + + const normalizedProfile = profile.toLowerCase().trim(); + if (!VALID_PROFILES.includes(normalizedProfile)) { + error(`Invalid profile '${profile}'. Valid profiles: ${VALID_PROFILES.join(', ')}`); + } + + // Ensure config exists (create if needed) + ensureConfigFile(cwd); + + // Set the model profile in the config + const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile, raw); + const previousProfile = previousValue || 'balanced'; + + // Build result value / message and return + const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile); + const result = { + updated: true, + profile: normalizedProfile, + previousProfile, + agentToModelMap, + }; + const rawValue = getCmdConfigSetModelProfileResultMessage( + normalizedProfile, + previousProfile, + agentToModelMap + ); + output(result, raw, rawValue); +} + +/** + * Returns the message to display for the result of the `config-set-model-profile` command when + * displaying raw output. + */ +function getCmdConfigSetModelProfileResultMessage( + normalizedProfile, + previousProfile, + agentToModelMap +) { + const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap); + const didChange = previousProfile !== normalizedProfile; + const paragraphs = didChange + ? [ + `✓ Model profile set to: ${normalizedProfile} (was: ${previousProfile})`, + 'Agents will now use:', + agentToModelTable, + 'Next spawned agents will use the new profile.', + ] + : [ + `✓ Model profile is already set to: ${normalizedProfile}`, + 'Agents are using:', + agentToModelTable, + ]; + return paragraphs.join('\n\n'); +} + module.exports = { cmdConfigEnsureSection, cmdConfigSet, cmdConfigGet, + cmdConfigSetModelProfile, }; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 6db9f95406..66e2a52de3 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +const { MODEL_PROFILES } = require('./model-profiles.cjs'); // ─── Path helpers ──────────────────────────────────────────────────────────── @@ -13,23 +14,6 @@ function toPosixPath(p) { return p.split(path.sep).join('/'); } -// ─── Model Profile Table ───────────────────────────────────────────────────── - -const MODEL_PROFILES = { - 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' }, - 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, - 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, - 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, - 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' }, - 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, - 'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, -}; - // ─── Output helpers ─────────────────────────────────────────────────────────── function output(result, raw, rawValue) { @@ -469,7 +453,6 @@ function getMilestonePhaseFilter(cwd) { } module.exports = { - MODEL_PROFILES, output, error, safeReadFile, diff --git a/get-shit-done/bin/lib/model-profiles.cjs b/get-shit-done/bin/lib/model-profiles.cjs new file mode 100644 index 0000000000..29dfbb8b2e --- /dev/null +++ b/get-shit-done/bin/lib/model-profiles.cjs @@ -0,0 +1,65 @@ +/** + * Mapping of GSD agent to model for each profile. + * + * Should be in sync with the profiles table in `get-shit-done/references/model-profiles.md`. But + * possibly worth making this the single source of truth at some point, and removing the markdown + * reference table in favor of programmatically determining the model to use for an agent (which + * would be faster, use fewer tokens, and be less error-prone). + */ +const MODEL_PROFILES = { + 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' }, + 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, + 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, + 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, + 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' }, + 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, + 'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, +}; +const VALID_PROFILES = Object.keys(MODEL_PROFILES['gsd-planner']); + +/** + * Formats the agent-to-model mapping as a human-readable table (in string format). + * + * @param {Object} agentToModelMap - A mapping from agent to model + * @returns {string} A formatted table string + */ +function formatAgentToModelMapAsTable(agentToModelMap) { + const agentWidth = Math.max('Agent'.length, ...Object.keys(agentToModelMap).map((a) => a.length)); + const modelWidth = Math.max( + 'Model'.length, + ...Object.values(agentToModelMap).map((m) => m.length) + ); + const sep = '─'.repeat(agentWidth + 2) + '┼' + '─'.repeat(modelWidth + 2); + const header = ' ' + 'Agent'.padEnd(agentWidth) + ' │ ' + 'Model'.padEnd(modelWidth); + let agentToModelTable = header + '\n' + sep + '\n'; + for (const [agent, model] of Object.entries(agentToModelMap)) { + agentToModelTable += ' ' + agent.padEnd(agentWidth) + ' │ ' + model.padEnd(modelWidth) + '\n'; + } + return agentToModelTable; +} + +/** + * Returns a mapping from agent to model for the given model profile. + * + * @param {string} normalizedProfile - The normalized (lowercase and trimmed) profile name + * @returns {Object} A mapping from agent to model for the given profile + */ +function getAgentToModelMapForProfile(normalizedProfile) { + const agentToModelMap = {}; + for (const [agent, profileToModelMap] of Object.entries(MODEL_PROFILES)) { + agentToModelMap[agent] = profileToModelMap[normalizedProfile]; + } + return agentToModelMap; +} + +module.exports = { + MODEL_PROFILES, + VALID_PROFILES, + formatAgentToModelMapAsTable, + getAgentToModelMapForProfile, +}; diff --git a/get-shit-done/workflows/set-profile.md b/get-shit-done/workflows/set-profile.md deleted file mode 100644 index 04ea06230f..0000000000 --- a/get-shit-done/workflows/set-profile.md +++ /dev/null @@ -1,81 +0,0 @@ - -Switch the model profile used by GSD agents. Controls which Claude model each agent uses, balancing quality vs token spend. - - - -Read all files referenced by the invoking prompt's execution_context before starting. - - - - - -Validate argument: - -``` -if $ARGUMENTS.profile not in ["quality", "balanced", "budget"]: - Error: Invalid profile "$ARGUMENTS.profile" - Valid profiles: quality, balanced, budget - EXIT -``` - - - -Ensure config exists and load current state: - -```bash -node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-ensure-section -INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load) -if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi -``` - -This creates `.planning/config.json` with defaults if missing and loads current config. - - - -Read current config from state load or directly: - -Update `model_profile` field: -```json -{ - "model_profile": "$ARGUMENTS.profile" -} -``` - -Write updated config back to `.planning/config.json`. - - - -Display confirmation with model table for selected profile: - -``` -✓ Model profile set to: $ARGUMENTS.profile - -Agents will now use: - -[Show table from MODEL_PROFILES in gsd-tools.cjs for selected profile] - -Example: -| Agent | Model | -|-------|-------| -| gsd-planner | opus | -| gsd-executor | sonnet | -| gsd-verifier | haiku | -| ... | ... | - -Next spawned agents will use the new profile. -``` - -Map profile names: -- quality: use "quality" column from MODEL_PROFILES -- balanced: use "balanced" column from MODEL_PROFILES -- budget: use "budget" column from MODEL_PROFILES - - - - - -- [ ] Argument validated -- [ ] Config file ensured -- [ ] Config updated with new model_profile -- [ ] Confirmation displayed with model table - diff --git a/tests/core.test.cjs b/tests/core.test.cjs index f5a5ff6168..1d98b79379 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -14,7 +14,6 @@ const os = require('os'); const { loadConfig, resolveModelInternal, - MODEL_PROFILES, escapeRegex, generateSlugInternal, normalizePhaseName,