/** * install-effort-resolver — install-time effort resolution (#443), extracted from * the package-root `bin/install.js` (#2071). * * `gsd-tools effort sync` (src/commands.cts `cmdEffortSync`) must mirror what the * installer writes — home `~/.gsd/defaults.json` merged with the project's * `.planning/config.json` — which the runtime resolver (`resolveEffortInternal` * via `loadConfig`) does NOT do. It previously reached those two functions via * `require('../../../bin/install.js')`, but the installer never copies the * package-root `bin/install.js` into a runtime home, so `effort sync` crashed with * MODULE_NOT_FOUND in every installed runtime (#2071). Moving the logic here — a * `src/*.cts` module compiled into the shipped `gsd-core/bin/lib/` tree — lets both * the installer AND `effort sync` require it from a location that is always present, * keeping a single source of truth (no duplication / drift). * * Pure with respect to config: `readGsdEffectiveEffortConfig` performs the config * reads; `resolveInstallTimeEffort` is pure given a pre-merged effort object. * * #2875 defect fix: this module sits on the `installRuntimeArtifacts` call * tree (reached both directly from `runtime-artifact-conversion.cts`'s * effort-injection rewrite pass, and transitively via * `install-model-override-resolver.cts`'s `_readGsdConfigFile` reuse) — every * DESTINATION/config fs touch below routes through `installFs()` * (install-fs-adapter.cts), matching `retired-artifact-cleanup.cts` / * `user-artifact-staging.cts`'s existing precedent. `config-defaults.manifest.json` * (`_getGsdEffortCatalog`) stays on raw `node:fs`, deliberately: it is * PACKAGE-SOURCE (ships under `gsd-core/bin/shared/`, resolved from * `__dirname`, never the install destination), the same class of read * `install-fs-adapter.cts`'s module doc documents as "DELIBERATELY NOT * ROUTED" for `findInstallSourceRoot`/`readGsdCommandNames`. */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module import installFsAdapter = require('./install-fs-adapter.cjs'); const { installFs } = installFsAdapter; // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module import modelResolver = require('./model-resolver.cjs'); const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver as { EFFORT_SET: Set }; // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module import modelCatalog = require('./model-catalog.cjs'); const { mergeEffortTierDefaults } = modelCatalog as { mergeEffortTierDefaults: typeof import('./model-catalog.cjs').mergeEffortTierDefaults }; interface EffortConfig { agent_overrides?: Record; routing_tier_defaults?: Record; default?: unknown; [k: string]: unknown; } /** * #2517 — Read a single GSD config file (defaults.json or per-project * config.json) into a plain object, returning null on missing/empty files * and warning to stderr on JSON parse failures so silent corruption can't * mask broken configs (review finding #5). */ function _readGsdConfigFile(absPath: string, label: string): Record | null { if (!installFs().existsSync(absPath)) return null; let raw: string; try { raw = installFs().readFileSync(absPath, 'utf-8'); } catch (err) { process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${(err as Error).message}\n`); return null; } try { return JSON.parse(raw) as Record; } catch (err) { process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${(err as Error).message}\n`); return null; } } interface EffortCatalog { AGENT_DEFAULT_TIERS: Record; // #3007: `value` is `string | null` — declaring it `string` here was a // structural lie that silently defeated TS null-checking for anything // routed through this seam (a rejected/unrenderable effort level renders // null, e.g. 'ultra' or an exhausted catalog clamp). renderEffortForRuntime: (runtime: string, effort: string) => { value: string | null }; EFFORT_MANIFEST_TIER_DEFAULTS: Record; EFFORT_MANIFEST_DEFAULT: string; } // #443 — model-catalog and config-defaults.manifest.json exports needed only // by effort-resolution code paths (resolveInstallTimeEffort / // generateCodexAgentToml / Claude .md effort injection). Loaded lazily the // first time they are needed so that requiring this module in test contexts that // never trigger an install does NOT produce module-load-time side effects (the // manifest read + hard throw) that could alter subprocess exit codes or stderr. let _gsdEffortCatalogCache: EffortCatalog | null = null; function _getGsdEffortCatalog(): EffortCatalog { if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache; // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require('./model-catalog.cjs') as { AGENT_DEFAULT_TIERS: Record; // #3007: `value` is `string | null` — declaring it `string` here was a // structural lie that silently defeated TS null-checking for anything // routed through this seam (a rejected/unrenderable effort level renders // null, e.g. 'ultra' or an exhausted catalog clamp). renderEffortForRuntime: (runtime: string, effort: string) => { value: string | null }; }; // This module lives in gsd-core/bin/lib/, so the shared manifest is one level // up in gsd-core/bin/shared/ (bin/lib → bin → shared). (In bin/install.js this // path was `.., gsd-core, bin, shared` relative to the package-root bin/.) const manifestPath = path.join(__dirname, '..', 'shared', 'config-defaults.manifest.json'); let manifestData: { effort?: { routing_tier_defaults?: Record; default?: string } }; try { manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { effort?: { routing_tier_defaults?: Record; default?: string }; }; } catch (_err) { // Fail loudly — a missing manifest is a broken install, not a soft degradation. throw new Error( `gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${(_err as Error).message}`, ); } const tierDefaults = (manifestData.effort && manifestData.effort.routing_tier_defaults && typeof manifestData.effort.routing_tier_defaults === 'object' && !Array.isArray(manifestData.effort.routing_tier_defaults)) ? manifestData.effort.routing_tier_defaults : { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid const effortDefault = (manifestData.effort && typeof manifestData.effort.default === 'string') ? manifestData.effort.default : 'high'; // guard: unreachable if manifest is valid _gsdEffortCatalogCache = { AGENT_DEFAULT_TIERS, renderEffortForRuntime, EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults, EFFORT_MANIFEST_DEFAULT: effortDefault, }; return _gsdEffortCatalogCache; } /** * #2875 defect fix (Generative Fix Divergence — the exact class this module * exists to remove): the upward walk from a runtime install root looking for * `.planning/config.json`, capped at 8 ancestor levels, was duplicated * verbatim three times — once here, and twice more in * `install-model-override-resolver.cts` (`readGsdEffectiveModelOverrides`, * `readGsdRuntimeProfileResolver`) after that module was extracted FROM this * one specifically to stop duplicating shared install-time config logic. * Single-sourced here so the cap and the walk semantics (depth 0 = targetDir * itself, depth 7 = the last checked ancestor, 8 levels up is never reached) * can only diverge if this function changes. * * @param targetDir Runtime install root to start the walk from. * @returns the first `.planning/config.json` found walking upward from * `targetDir` (inclusive) through up to 8 ancestor levels, or `null`. */ function _findAncestorGsdConfigPath(targetDir: string): string | null { let probeDir = path.resolve(targetDir); for (let depth = 0; depth < 8; depth += 1) { const candidate = path.join(probeDir, '.planning', 'config.json'); if (installFs().existsSync(candidate)) return candidate; const parent = path.dirname(probeDir); if (parent === probeDir) break; probeDir = parent; } return null; } /** * #443 — Read the merged `effort` config block for install-time effort resolution. * * Probes the same config sources as readGsdRuntimeProfileResolver (per-project * `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the * `effort` object instead of the model-profile fields. * * Returns the merged `effort` object or null when neither source defines one. * The caller can pass this to resolveInstallTimeEffort() which is pure and * requires no filesystem access beyond what this helper already performs. * * @param targetDir Runtime install root (walks up to find .planning/). */ function readGsdEffectiveEffortConfig(targetDir: string | null = null): EffortConfig | null { const homeDefaults = _readGsdConfigFile( path.join(os.homedir(), '.gsd', 'defaults.json'), '~/.gsd/defaults.json', ); let projectConfig: Record | null = null; if (targetDir) { const candidate = _findAncestorGsdConfigPath(targetDir); if (candidate) projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); } const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort)) ? (homeDefaults.effort as EffortConfig) : null; const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort)) ? (projectConfig.effort as EffortConfig) : null; if (!homeEffort && !projectEffort) return null; // Per-project wins on conflict within each sub-field. Merge field-by-field so // a project config that only sets agent_overrides still inherits global // routing_tier_defaults and default. // #3531 (10c): routing_tier_defaults is deep-merged per-tier like // agent_overrides — a project block naming only `heavy` must not discard the // home block's `light`/`standard` entries (the top-level spread would // otherwise replace the whole block, the same defect class as the // manifest-replacement this change fixes). return { ...(homeEffort || {}), ...(projectEffort || {}), // Deep-merge agent_overrides (project wins per-key) agent_overrides: { ...((homeEffort && homeEffort.agent_overrides) || {}), ...((projectEffort && projectEffort.agent_overrides) || {}), }, // Deep-merge routing_tier_defaults (project wins per-tier) routing_tier_defaults: { ...((homeEffort && homeEffort.routing_tier_defaults) || {}), ...((projectEffort && projectEffort.routing_tier_defaults) || {}), }, }; } /** * #443 — Resolve install-time effort for a given agent, using the same * precedence chain as resolveEffortInternal() in core.cjs, but operating * on a pre-loaded effortCfg object (no loadConfig side-effects at install). * * Precedence (mirrors resolveEffortInternal): * 1. effortCfg.agent_overrides[agentName] * 2. routing_tier_defaults merged over the manifest tier defaults * (#3531/10c: a config block — present or partial — no longer disables * the built-in tier ladder; invalid config values are dropped by the * merge so the manifest value for the tier surfaces) * 3. effortCfg.default * 4. 'high' (hardcoded fallback) * * @param effortCfg Result of readGsdEffectiveEffortConfig(). * @param agentName e.g. 'gsd-planner' * @returns Universal effort string (low/medium/high/xhigh/max/minimal) */ function resolveInstallTimeEffort(effortCfg: EffortConfig | null, agentName: string): string { // Validates each candidate against the canonical EFFORT_SET (sourced once // from model-resolver.cjs) before accepting it, mirroring resolveEffortInternal // exactly. Invalid values fall through to the next precedence layer; final // fallback 'high'. // Step 1: agent_overrides if (effortCfg) { const ao = effortCfg.agent_overrides; if (ao && typeof ao === 'object' && !Array.isArray(ao)) { const v = ao[agentName]; if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; } } // Step 2: routing_tier_defaults keyed by the agent's catalog tier const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog(); const agentTier = AGENT_DEFAULT_TIERS[agentName]; if (agentTier) { const isValidEffort = (v: unknown): v is string => typeof v === 'string' && GSD_EFFORT_SET.has(v); const merged = mergeEffortTierDefaults( EFFORT_MANIFEST_TIER_DEFAULTS, effortCfg ? effortCfg.routing_tier_defaults : undefined, isValidEffort, ); const v = merged[agentTier]; if (isValidEffort(v)) return v; } // Step 3: effort.default if (effortCfg) { const d = effortCfg.default; if (typeof d === 'string' && GSD_EFFORT_SET.has(d)) return d; } // Step 4: manifest default (sourced from config-defaults.manifest.json effort.default) // If even the manifest default is invalid, fall back to 'high'. if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) { return EFFORT_MANIFEST_DEFAULT; } return 'high'; } export = { readGsdEffectiveEffortConfig, resolveInstallTimeEffort, _getGsdEffortCatalog, _readGsdConfigFile, _findAncestorGsdConfigPath, };