Merge branch 'next' into fix/2028-phase-complete-milestone-end-and-workstream-guard
This commit is contained in:
5
.changeset/2071-effort-sync-installed-runtime.md
Normal file
5
.changeset/2071-effort-sync-installed-runtime.md
Normal file
@@ -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)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
207
bin/install.js
207
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: <value>` 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.
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
resolveInstallTimeEffort(cfg: Record<string, unknown>, agentName: string): string;
|
||||
};
|
||||
|
||||
242
src/install-effort-resolver.cts
Normal file
242
src/install-effort-resolver.cts
Normal file
@@ -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<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,
|
||||
};
|
||||
72
tests/effort-sync-installed-runtime.test.cjs
Normal file
72
tests/effort-sync-installed-runtime.test.cjs
Normal file
@@ -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 <configDir>/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 <temp>` 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user