diff --git a/.changeset/2071-effort-sync-installed-runtime.md b/.changeset/2071-effort-sync-installed-runtime.md new file mode 100644 index 000000000..2c9a0b663 --- /dev/null +++ b/.changeset/2071-effort-sync-installed-runtime.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2076 +--- +**`gsd-tools effort sync` no longer crashes in an installed runtime.** In any global install (e.g. `~/.claude/gsd-core/`), `effort sync` threw `Cannot find module '../../../bin/install.js'` — the command reached into the package-root `bin/install.js` for its install-time effort resolvers, but the installer only copies the `gsd-core/` subtree into a runtime home, so that file is never present there. As a result, `effort` config changes (`routing_tier_defaults` / `agent_overrides`) silently never reached installed agents without a full reinstall. The two resolvers (`readGsdEffectiveEffortConfig` + `resolveInstallTimeEffort`, with their helpers) are now extracted into a shipped `gsd-core/bin/lib/install-effort-resolver.cjs` that both `effort sync` and the installer import — a single source of truth that is always present in the installed tree. (#2076) diff --git a/.gitignore b/.gitignore index 7e954bbf4..a76a62d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ build/ /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/host-integration-sdk.cjs /gsd-core/bin/lib/handshake-serialized.cjs +/gsd-core/bin/lib/install-effort-resolver.cjs /gsd-core/bin/lib/install-engine.cjs /gsd-core/bin/lib/embedding-adapter.cjs /gsd-core/bin/lib/adapter-declarative.cjs diff --git a/bin/install.js b/bin/install.js index bd148d748..6cf14cea7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -273,60 +273,20 @@ const { } = require(path.join(_gsdLibDir, 'model-catalog.cjs')); const { resolveTierEntry: gsdResolveTierEntry, - EFFORT_SET: GSD_EFFORT_SET, } = require(path.join(_gsdLibDir, 'model-resolver.cjs')); -// #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 install.js 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 = null; -function _getGsdEffortCatalog() { - if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache; - - const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require(path.join(_gsdLibDir, 'model-catalog.cjs')); - - const manifestPath = path.join( - __dirname, - '..', - 'gsd-core', - 'bin', - 'shared', - 'config-defaults.manifest.json' - ); - let manifestData; - try { - manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - } 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.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; -} +// #2071 — install-time effort resolution (readGsdEffectiveEffortConfig / +// resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile +// helpers) was extracted into the shipped gsd-core/bin/lib/install-effort-resolver.cjs +// so `gsd-tools effort sync` can require it from the installed runtime instead of this +// package-root bin/install.js, which the installer never copies (#2071 crash). The +// installer imports it back here — single source of truth for both surfaces. +const { + readGsdEffectiveEffortConfig, + resolveInstallTimeEffort, + _getGsdEffortCatalog, + _readGsdConfigFile, +} = require(path.join(_gsdLibDir, 'install-effort-resolver.cjs')); const { MINIMAL_SKILL_ALLOWLIST, @@ -1071,126 +1031,6 @@ function readGsdEffectiveModelOverrides(targetDir = null) { return { ...(global || {}), ...(projectOverrides || {}) }; } -/** - * #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 {string|null} targetDir Runtime install root (walks up to find .planning/). - * @returns {object|null} - */ -function readGsdEffectiveEffortConfig(targetDir = null) { - const homeDefaults = _readGsdConfigFile( - path.join(os.homedir(), '.gsd', 'defaults.json'), - '~/.gsd/defaults.json' - ); - - let projectConfig = null; - if (targetDir) { - let probeDir = path.resolve(targetDir); - for (let depth = 0; depth < 8; depth += 1) { - const candidate = path.join(probeDir, '.planning', 'config.json'); - if (fs.existsSync(candidate)) { - projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); - break; - } - const parent = path.dirname(probeDir); - if (parent === probeDir) break; - probeDir = parent; - } - } - - const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort)) - ? homeDefaults.effort - : null; - const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort)) - ? projectConfig.effort - : 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. - return { - ...(homeEffort || {}), - ...(projectEffort || {}), - // Deep-merge agent_overrides (project wins per-key) - agent_overrides: { - ...((homeEffort && homeEffort.agent_overrides) || {}), - ...((projectEffort && projectEffort.agent_overrides) || {}), - }, - }; -} - - -/** - * #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. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present) - * — OR manifest tier defaults when effortCfg is null - * 3. effortCfg.default - * 4. 'high' (hardcoded fallback) - * - * @param {object|null} effortCfg Result of readGsdEffectiveEffortConfig(). - * @param {string} agentName e.g. 'gsd-planner' - * @returns {string} Universal effort string (low/medium/high/xhigh/max/minimal) - */ -function resolveInstallTimeEffort(effortCfg, agentName) { - // Validates each candidate against the canonical EFFORT_SET (sourced once - // from core.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) { - if (effortCfg && effortCfg.routing_tier_defaults && - typeof effortCfg.routing_tier_defaults === 'object' && - !Array.isArray(effortCfg.routing_tier_defaults)) { - const v = effortCfg.routing_tier_defaults[agentTier]; - if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; - } else if (!effortCfg) { - // No effort config — use manifest tier defaults - const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier]; - if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; - } - // effortCfg exists but has no routing_tier_defaults — fall through - } - - // 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'; -} - /** * #443 — Inject `effort: ` into YAML frontmatter of a Claude .md agent * file in a newline-agnostic way (LF and CRLF source files are both handled). @@ -1296,29 +1136,6 @@ const READONLY_AGENT_DISALLOWED_TOOLS = { 'gsd-ui-auditor': 'Edit, MultiEdit', }; -/** - * #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, label) { - if (!fs.existsSync(absPath)) return null; - let raw; - try { - raw = fs.readFileSync(absPath, 'utf-8'); - } catch (err) { - process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`); - return null; - } - try { - return JSON.parse(raw); - } catch (err) { - process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`); - return null; - } -} - /** * #2517 — Build a runtime-aware tier resolver for the install path. * diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d3d251353..4628c7b77 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -356,6 +356,7 @@ "host-integration.cjs", "init-command-router.cjs", "init.cjs", + "install-effort-resolver.cjs", "install-engine.cjs", "install-profiles.cjs", "installer-migration-authoring.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 5dfbe8688..be47f0f48 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -451,6 +451,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` | | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | +| `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) | | `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations | diff --git a/eslint.config.mjs b/eslint.config.mjs index 148104840..a71f80e39 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,6 +62,7 @@ export default tseslint.config( 'gsd-core/bin/lib/host-integration.cjs', 'gsd-core/bin/lib/handshake-serialized.cjs', 'gsd-core/bin/lib/host-integration-sdk.cjs', + 'gsd-core/bin/lib/install-effort-resolver.cjs', 'gsd-core/bin/lib/install-engine.cjs', 'gsd-core/bin/lib/capability-loader.cjs', 'gsd-core/bin/lib/capability-source.cjs', diff --git a/src/commands.cts b/src/commands.cts index ed54ae44e..426923c0d 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -564,9 +564,11 @@ function cmdEffortSync(cwd: string, raw: boolean, opts?: { dryRun?: boolean; con // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string, explicitDir?: string | null): string }; // Use install-time resolvers: they merge ~/.gsd/defaults.json with project config, - // matching the exact logic used when agents were originally installed. + // matching the exact logic used when agents were originally installed. #2071: these + // live in the shipped sibling install-effort-resolver.cjs (extracted from the + // package-root bin/install.js, which the installer never copies into a runtime home). // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method - const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('../../../bin/install.js') as { + const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs') as { readGsdEffectiveEffortConfig(cwd: string): Record; resolveInstallTimeEffort(cfg: Record, agentName: string): string; }; diff --git a/src/install-effort-resolver.cts b/src/install-effort-resolver.cts new file mode 100644 index 000000000..3b9690a8f --- /dev/null +++ b/src/install-effort-resolver.cts @@ -0,0 +1,242 @@ +/** + * 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. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// 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 }; + +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 (!fs.existsSync(absPath)) return null; + let raw: string; + try { + raw = fs.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; + renderEffortForRuntime: (runtime: string, effort: string) => { value: string }; + 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; + renderEffortForRuntime: (runtime: string, effort: string) => { value: string }; + }; + + // 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; +} + +/** + * #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) { + let probeDir = path.resolve(targetDir); + for (let depth = 0; depth < 8; depth += 1) { + const candidate = path.join(probeDir, '.planning', 'config.json'); + if (fs.existsSync(candidate)) { + projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); + break; + } + const parent = path.dirname(probeDir); + if (parent === probeDir) break; + probeDir = parent; + } + } + + 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. + return { + ...(homeEffort || {}), + ...(projectEffort || {}), + // Deep-merge agent_overrides (project wins per-key) + agent_overrides: { + ...((homeEffort && homeEffort.agent_overrides) || {}), + ...((projectEffort && projectEffort.agent_overrides) || {}), + }, + }; +} + +/** + * #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. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present) + * — OR manifest tier defaults when effortCfg is null + * 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) { + if (effortCfg && effortCfg.routing_tier_defaults && + typeof effortCfg.routing_tier_defaults === 'object' && + !Array.isArray(effortCfg.routing_tier_defaults)) { + const v = effortCfg.routing_tier_defaults[agentTier]; + if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; + } else if (!effortCfg) { + // No effort config — use manifest tier defaults + const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier]; + if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; + } + // effortCfg exists but has no routing_tier_defaults — fall through + } + + // 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, +}; diff --git a/tests/effort-sync-installed-runtime.test.cjs b/tests/effort-sync-installed-runtime.test.cjs new file mode 100644 index 000000000..34c0ecbd8 --- /dev/null +++ b/tests/effort-sync-installed-runtime.test.cjs @@ -0,0 +1,72 @@ +'use strict'; + +/** + * #2071 — `gsd-tools effort sync` crashed in an INSTALLED runtime because + * commands.cjs did `require('../../../bin/install.js')`, but the installer only + * copies the `gsd-core/` subtree into a runtime home — the package-root + * `bin/install.js` is never present there, so the require threw MODULE_NOT_FOUND. + * + * This does a real minimal install into a temp home (the same helper the + * golden-parity suite uses) and runs the exact repro from the issue against the + * installed shim: `node /gsd-core/bin/gsd-tools.cjs effort sync`. Pre-fix + * this throws `Cannot find module '../../../bin/install.js'`; post-fix the + * install-time resolvers live in the shipped sibling + * `gsd-core/bin/lib/install-effort-resolver.cjs` and the require resolves. + * + * `--config-dir ` keeps it hermetic (targets the temp install, never the + * developer's real ~/.claude); effort sync defaults to dry-run so nothing is written. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { runMinimalInstall } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); + +describe('#2071: effort sync runs in an installed runtime (no package-root bin/install.js)', () => { + test('effort sync does not crash reaching for the un-shipped bin/install.js', () => { + if (process.platform === 'win32') return; // install layout is POSIX-path-shaped + + const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' }); + try { + // Installed layout invariant: the package-root installer is never copied in. + assert.ok(!fs.existsSync(path.join(root, 'bin', 'install.js')), 'installed home must not contain bin/install.js'); + assert.ok(!fs.existsSync(path.join(configDir, 'bin', 'install.js')), 'no bin/install.js beside gsd-core'); + + // A project effort config gives the sync something to resolve. + fs.mkdirSync(path.join(root, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(root, '.planning', 'config.json'), + JSON.stringify({ effort: { default: 'high' } }), + ); + + const gsdTools = path.join(configDir, 'gsd-core', 'bin', 'gsd-tools.cjs'); + let combined = ''; + try { + combined = execFileSync( + process.execPath, + [gsdTools, 'effort', 'sync', '--config-dir', configDir], + { cwd: root, encoding: 'utf-8', env: { ...process.env, HOME: root } }, + ); + } catch (e) { + combined = `${e.stdout || ''}${e.stderr || ''}${e.message || ''}`; + } + + assert.doesNotMatch( + combined, + /Cannot find module[^\n]*install\.js|'\.\.\/\.\.\/\.\.\/bin\/install\.js'/, + `effort sync must not reach for the un-shipped bin/install.js:\n${combined}`, + ); + assert.doesNotMatch( + combined, + /MODULE_NOT_FOUND/, + `effort sync must not crash on module resolution in an installed runtime:\n${combined}`, + ); + } finally { + cleanup(root); + } + }); +});