Files
msd-core/src/install-effort-resolver.cts
Tom Boucher 015c3a7fda fix(#2071): extract install-time effort resolvers so effort sync stops requiring the un-shipped bin/install.js
`gsd-tools effort sync` crashed in every installed runtime (e.g. ~/.claude/gsd-core/)
with `Cannot find module '../../../bin/install.js'`: cmdEffortSync (src/commands.cts)
required 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 — bin/install.js is never
present there. So `effort` config changes silently never reached installed agents without
a full reinstall (exactly the gap #488 was meant to close). 4th instance of the recurring
"runtime code under gsd-core/ requires a file outside the shipped subtree via ../../../"
anti-pattern (#1223/#1920/#1383 were the prior three, all already mitigated).

Fix (ADR-457 direction — extract, single source): move readGsdEffectiveEffortConfig +
resolveInstallTimeEffort (with their _getGsdEffortCatalog + _readGsdConfigFile helpers)
out of the hand-authored bin/install.js into a new src/install-effort-resolver.cts that
compiles into the shipped gsd-core/bin/lib/install-effort-resolver.cjs. commands.cts now
requires it as a sibling (`./install-effort-resolver.cjs`) — always present in the
installed tree — instead of `../../../bin/install.js`. bin/install.js imports the same four
symbols back from the new module (it still calls them + re-exports them), so there is one
source of truth and no duplication/drift. The lazy manifest read is repointed from the
package-root layout (`.., gsd-core, bin, shared`) to the bin/lib layout (`.., shared`).

Scope note: this is one of four instances of the anti-pattern; the other three are already
shipped/guarded. A build-time guard rejecting new cross-boundary requires whose target isn't
in the installer copy manifest (to prevent instance #5) is recommended on the issue but kept
out of this fix.

Tests: tests/effort-sync-installed-runtime.test.cjs does a real minimal install into a temp
home (the golden-parity helper) and runs the issue's exact repro
(`gsd-tools effort sync --config-dir <temp>`), asserting no MODULE_NOT_FOUND for
bin/install.js. Fail-first verified: against pristine next the same test throws
`Cannot find module '../../../bin/install.js'` at cmdEffortSync; post-fix it syncs cleanly.

New module registered in .gitignore (ADR-457), eslint ignores, docs/INVENTORY.md +
INVENTORY-MANIFEST.json. bin/install.js is not shipped and the new module is under bin/lib
(excluded from golden parity), so no golden fixtures change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:21:45 -04:00

243 lines
10 KiB
TypeScript

/**
* 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<string> };
interface EffortConfig {
agent_overrides?: Record<string, unknown>;
routing_tier_defaults?: Record<string, unknown>;
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<string, unknown> | 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<string, unknown>;
} 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<string, string>;
renderEffortForRuntime: (runtime: string, effort: string) => { value: string };
EFFORT_MANIFEST_TIER_DEFAULTS: Record<string, string>;
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<string, string>;
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<string, string>; default?: string } };
try {
manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as {
effort?: { routing_tier_defaults?: Record<string, string>; 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<string, unknown> | 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,
};