Files
msd-core/src/core.cts
Tom Boucher 185935379a refactor(#888): extract model+effort resolution into model-resolver.cts (#890)
ADR-857 rollout phase 2f — the FINAL core.cts decomposition. Move the model and
effort resolution cluster (resolveModelInternal, resolveModelPolicy,
resolveTierEntry, _resolveRuntimeTier, resolveModelForTier,
resolveGranularityInternal, assertValidGranularityOverride, resolveEffortInternal,
resolveFastModeInternal, resolveEffortForTier, nextEffort + VALID_GRANULARITIES/
VALID_EFFORTS/EFFORT_SET + interfaces) out of core.cts into a new leaf module
src/model-resolver.cts. core.cts re-exports the 13 public symbols (callers in
init/docs/commands unchanged; export= set byte-identical).

Cycle-free: model-resolver imports only leaves (config-loader for loadConfig,
configuration for defaults, model-profiles + model-catalog for the static
tables). Removed 6 now-unused imports from core (verified zero remaining
references, none re-exported).

This completes the god-module decomposition: core.cts 2271 -> 389 lines (~83%),
now a thin re-export spine over seven clean leaves (io, phase-id, roadmap-parser,
core-utils, phase-locator, config-loader, model-resolver).

New-CLI-module checklist done (.gitignore, eslint, INVENTORY 96->97 + row,
manifest, ARCHITECTURE, CONTEXT.md "Model Resolver Module"). Adds
tests/model-resolver.test.cjs (81 tests: behavioral + shim-identity + adversarial).

Gates: lint, code-review (export set byte-identical; import-removal verified),
security-review, codex adversarial-review (all 0 findings; verbatim move). Mac
4303 pass; clean-build docker 13117 pass, 0 fail.

Closes #888

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:26:40 -04:00

390 lines
15 KiB
TypeScript

/**
* Core — Shared utilities, constants, and internal helpers
*
* ADR-457 build-at-publish: the hand-written bin/lib/core.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execGit } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioModule = require('./io.cjs');
const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelProfiles = require('./model-profiles.cjs');
const { MODEL_PROFILES, VALID_PHASE_TYPES: _VALID_PHASE_TYPES } = modelProfiles;
import { RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, KNOWN_PROVIDERS, MODEL_ALIAS_MAP } from './model-catalog.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import worktreeSafety = require('./worktree-safety.cjs');
const {
resolveWorktreeContext,
parseWorktreePorcelain: parseWorktreePorcelainPolicy,
planWorktreePrune,
executeWorktreePrunePlan,
inspectWorktreeHealth,
} = worktreeSafety;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
// Compatibility shim: new imports should use planning-workspace.cjs directly.
const {
planningDir,
planningRoot,
planningPaths,
withPlanningLock,
getActiveWorkstream,
setActiveWorkstream,
} = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsModule = require('./core-utils.cjs');
const {
toPosixPath,
detectSubRepos,
extractOneLinerFromBody,
pathExistsInternal,
generateSlugInternal,
filterPlanFiles,
filterSummaryFiles,
getPhaseFileStats,
readSubdirectories,
timeAgo,
} = coreUtilsModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorModule = require('./phase-locator.cjs');
const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule;
import { findProjectRoot } from './project-root.cjs';
import { getGlobalConfigDir } from './runtime-homes.cjs';
// ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ─────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderModule = require('./config-loader.cjs');
const {
loadConfig,
isGitIgnored,
CONFIG_DEFAULTS,
_warnUnknownProfileOverrides,
_resetRuntimeWarningCacheForTests,
RUNTIME_OVERRIDE_TIERS,
} = configLoaderModule;
// ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelResolverModule = require('./model-resolver.cjs');
const {
resolveTierEntry,
resolveModelPolicy,
resolveModelInternal,
VALID_GRANULARITIES,
resolveGranularityInternal,
assertValidGranularityOverride,
resolveModelForTier,
VALID_EFFORTS,
EFFORT_SET,
nextEffort,
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,
} = modelResolverModule;
// ─── Path helpers ────────────────────────────────────────────────────────────
// toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
// The destructured bindings above (from coreUtilsModule) make them available to
// core-internal callers; core.cjs re-exports toPosixPath and detectSubRepos for back-compat.
// findProjectRoot is now re-exported from the generated CJS module above.
// loadConfig, isGitIgnored, CONFIG_DEFAULTS, and related helpers moved to
// config-loader.cjs (ADR-857 phase 2e / #885). The destructured bindings above
// (from configLoaderModule) make them available to core-internal callers;
// core.cjs re-exports loadConfig and isGitIgnored for back-compat.
// ─── Common path helpers ──────────────────────────────────────────────────────
/**
* Resolve the main worktree root when running inside a git worktree.
* In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
* Returns the main worktree path, or cwd if not in a worktree.
*/
function resolveWorktreeRoot(cwd: string): string {
const context = resolveWorktreeContext(cwd, {
existsSync: fs.existsSync,
});
return context.effectiveRoot;
}
/**
* Parse `git worktree list --porcelain` output into an array of
* { path, branch } objects. Entries with a detached HEAD (no branch line)
* are skipped because we cannot safely reason about their merge status.
*
* @param porcelain - raw output from git worktree list --porcelain
* @returns {{ path: string, branch: string }[]}
*/
function parseWorktreePorcelain(porcelain: string): Array<{ path: string; branch: string }> {
return parseWorktreePorcelainPolicy(porcelain);
}
/**
* Clear stale worktree metadata references via `git worktree prune`.
*
* Destructive linked-worktree removal is disabled by default for safety.
*
* @param repoRoot - absolute path to the main (or any) worktree of
* the repository; used as `cwd` for git commands.
* @returns list of worktree paths that were removed (always empty)
*/
function pruneOrphanedWorktrees(repoRoot: string): string[] {
try {
const plan = planWorktreePrune(
repoRoot,
{ allowDestructive: false },
{ parseWorktreePorcelain }
);
const pruneResult = executeWorktreePrunePlan(plan) as { timedOut?: boolean } | null;
if (pruneResult && pruneResult.timedOut) {
process.stderr.write(
'[gsd-tools] WARNING: worktree health check degraded' +
' — git worktree prune timed out after 10s.' +
' Orphaned worktree metadata may remain until the next successful run.\n'
);
}
} catch { /* never crash the caller */ }
return [];
}
// ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ───
// ─── Phase utilities (pure helpers re-exported from phase-id.cjs) ─────────────
// escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId,
// phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum,
// extractPhaseToken, phaseTokenMatches
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
// It is consumed exclusively by phase-locator.cjs, which imports it from
// core-utils.cjs directly. It is NOT destructured in core.cts and is NOT
// in core.cjs's public export = block (it was never public).
// searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs
// (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule)
// make them available to core-internal callers; core.cjs re-exports findPhaseInternal,
// getArchivedPhaseDirs, and searchPhaseInDir for back-compat.
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
// getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter
// — all imported via `roadmapParserModule` above; internal callers use the destructured bindings.
// ─── Agent installation validation (#1371) ───────────────────────────────────
/**
* Resolve the agents directory for the given runtime.
*
* Priority:
* 1. GSD_AGENTS_DIR env var (explicit override, any runtime)
* 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/)
* This is correct for both repo runs and real installs (the runtime config dir's
* agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases.
* 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
*
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
*/
function getAgentsDir(runtime?: string): string {
if (process.env['GSD_AGENTS_DIR']) {
return process.env['GSD_AGENTS_DIR'];
}
const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
if (resolved === 'claude') {
return path.join(__dirname, '..', '..', '..', 'agents');
}
return path.join(getGlobalConfigDir(resolved), 'agents');
}
interface AgentsInstalledResult {
agents_installed: boolean;
missing_agents: string[];
installed_agents: string[];
agents_dir: string;
agent_runtime: string;
}
/**
* Check which GSD agents are installed on disk.
*
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
*/
function checkAgentsInstalled(runtime?: string): AgentsInstalledResult {
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
const agentsDir = getAgentsDir(resolvedRuntime);
const expectedAgents = Object.keys(MODEL_PROFILES);
const installed: string[] = [];
const missing: string[] = [];
if (!fs.existsSync(agentsDir)) {
return {
agents_installed: false,
missing_agents: expectedAgents,
installed_agents: [],
agents_dir: agentsDir,
agent_runtime: resolvedRuntime,
};
}
for (const agent of expectedAgents) {
const agentFile = path.join(agentsDir, `${agent}.md`);
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
const agentFileCodex = path.join(agentsDir, `${agent}.toml`);
if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot) || fs.existsSync(agentFileCodex)) {
installed.push(agent);
} else {
missing.push(agent);
}
}
return {
agents_installed: installed.length > 0 && missing.length === 0,
missing_agents: missing,
installed_agents: installed,
agents_dir: agentsDir,
agent_runtime: resolvedRuntime,
};
}
// ─── Model alias resolution ───────────────────────────────────────────────────
// RUNTIME_OVERRIDE_TIERS, _warnedConfigKeys, _warnUnknownProfileOverrides, and
// _resetRuntimeWarningCacheForTests moved to config-loader.cjs (ADR-857 phase 2e / #885).
// The destructured bindings above (from configLoaderModule) make them available to
// core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat.
// resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES,
// resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier,
// VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal,
// resolveEffortForTier — all moved to model-resolver.cjs (ADR-857 phase 2f / #888).
// The destructured bindings above (from modelResolverModule) make them available to
// core-internal callers; core.cjs re-exports all 13 symbols for back-compat.
// ─── Summary body helpers / Misc utilities / Phase file helpers ───────────────
// extractOneLinerFromBody, pathExistsInternal, generateSlugInternal,
// filterPlanFiles, filterSummaryFiles, getPhaseFileStats, readSubdirectories,
// timeAgo — all moved to core-utils.cjs (ADR-857 phase 2c / #877).
// The destructured bindings above (from coreUtilsModule) make them available
// to core-internal callers; core.cjs re-exports the public ones for back-compat.
// ─── Misc utilities (remaining in core) ──────────────────────────────────────
interface GitWorktreeInfo {
inside: boolean;
worktreeRoot: string | null;
}
/**
* Detect whether `cwd` sits inside a git worktree, and if so, return the
* absolute path of the worktree root.
*/
function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo {
try {
const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
if (insideResult.exitCode !== 0) {
return { inside: false, worktreeRoot: null };
}
const insideStdout = String(insideResult.stdout || '').trim();
if (insideStdout !== 'true') {
return { inside: false, worktreeRoot: null };
}
const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
if (rootResult.exitCode !== 0) {
return { inside: true, worktreeRoot: null };
}
const root = String(rootResult.stdout || '').trim();
return { inside: true, worktreeRoot: root || null };
} catch {
return { inside: false, worktreeRoot: null };
}
}
// MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter
// — all re-exported from roadmap-parser.cjs via roadmapParserModule above.
export = {
output,
error,
ERROR_REASON,
setJsonErrorMode,
getJsonErrorMode,
loadConfig,
isGitIgnored,
escapeRegex,
normalizePhaseName,
getMilestoneFromPhaseId,
getPhaseDirFromPhaseId,
phaseMarkdownRegexSource,
phaseMarkdownRegexSourceExact,
comparePhaseNum,
searchPhaseInDir,
extractPhaseToken,
phaseTokenMatches,
findPhaseInternal,
getArchivedPhaseDirs,
getRoadmapPhaseInternal,
resolveModelInternal,
resolveModelForTier,
resolveGranularityInternal,
VALID_GRANULARITIES,
assertValidGranularityOverride,
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,
VALID_EFFORTS,
EFFORT_SET,
nextEffort,
RUNTIME_PROFILE_MAP,
RUNTIMES_WITH_REASONING_EFFORT,
RUNTIMES_WITH_FAST_MODE,
KNOWN_RUNTIMES,
RUNTIME_OVERRIDE_TIERS,
resolveTierEntry,
resolveModelPolicy,
KNOWN_PROVIDERS,
_resetRuntimeWarningCacheForTests,
pathExistsInternal,
gitWorktreeInfoInternal,
generateSlugInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
stripShippedMilestones,
extractCurrentMilestone,
replaceInCurrentMilestone,
toPosixPath,
extractOneLinerFromBody,
resolveWorktreeRoot,
// Deprecated re-exports — prefer direct import from planning-workspace.cjs
withPlanningLock,
findProjectRoot,
detectSubRepos,
reapStaleTempFiles,
GSD_TEMP_DIR,
MODEL_ALIAS_MAP,
CONFIG_DEFAULTS,
planningDir,
planningRoot,
planningPaths,
getActiveWorkstream,
setActiveWorkstream,
filterPlanFiles,
filterSummaryFiles,
getPhaseFileStats,
readSubdirectories,
getAgentsDir,
checkAgentsInstalled,
timeAgo,
pruneOrphanedWorktrees,
inspectWorktreeHealth,
};