* test(01-01): add failing protected-branch warning coverage - pin configured, absent, and malformed branch-list behavior - require opposite CLI and execute warning outcomes * feat(01-01): warn on configured protected branches - resolve the base branch union configured protected branch names - expose exact boolean CLI comparison output for workflow callers - keep execute-phase warning advisory and within its byte budget * test(01-01): add failing protected branch config coverage - cover valid list persistence and null unset - reject hostile shapes while preserving the prior value * feat(01-01): validate protected branch configuration - register git.protected_branches as a canonical config key - require a non-empty array of non-blank branch names * test(01-02): add failing ship protected-branch controls - Execute both workflow warning blocks with exact predicate arguments - Require true and false results to produce opposite warning outcomes - Preserve the none-strategy feature-branch offer contract * feat(01-02): warn at ship on protected branches - Reuse the typed protected-branch predicate in ship preflight - Keep raw base resolution for PR targeting and advisory branch creation - Prove execute and ship warning blocks with opposite-result controls * test(01-02): add failing protected-branch docs parity - Require the canonical schema key in both English config references - Pin the non-empty string-array type and absent default - Require synchronized multi-branch examples and advisory semantics * feat(01-02): publish protected branch configuration contract - Document the optional non-empty string-array field in both references - Explain resolved-base union and absent-field compatibility - Keep execute and ship warnings advisory under branching_strategy none * fix(01): CR-01 honor active workstream branch policy * fix(01): WR-01 assert protected config path selection * docs: add changeset fragment for #3648 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CteVPJt4BkPmroMPGajYx * fix(#3648): resolve base_branch precedence inversion and round-1 findings Blocker 1/2: production config resolution was flat-first, so a project that migrated to git.base_branch but still carried a stale flat base_branch got the old value back. Add base_branch to normalizeLegacyKeys (mirrors the existing branching_strategy/sub_repos pattern: canonical nested wins) and route readEffectiveGitConfig's test seam through the same normalization so it can't silently diverge from production again. Adds a regression test with both keys set that fails without the fix. Blocker 3/4/5: restore the handle_branching case-selector prose and "none" contract sentence that #3389's tests anchor on, and revert the unrelated prose/comment compaction in the same step — both were drive-by edits outside #3552's scope. Also addresses review majors/minors: delete readConfigBaseBranch and readConfigProtectedBranches (dead in production, only self-tested); --is-protected now fails closed (reports protected) instead of silently answering false when the base branch can't be verified; trim configured protected-branch names; fix HOME-without-USERPROFILE vacuous isolation on Windows; correct the drift-ack's byte accounting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S44stkuQbhD3jTCtKzte5N * test(#3648): add failing legacy-key hoist safety coverage Round-2 review found normalizeLegacyKeys block 5 records a normalization carrying the DISCARDED flat value on the canonical-wins branch. Probing that turned up a second, unreported defect in the same helper shape: blocks 1, 2 and 5 all spread result['git'] / result['planning'] with no object guard, so a config whose section key holds a string is spread into index keys — {"git":"main","base_branch":"release"} -> {"git":{"0":"m","1":"a","2":"i","3":"n","base_branch":"release"}} The resolved value is accidentally still correct, so nothing fails and no diagnostic fires. But normalizations.length > 0 sets configDirty, and config-loader then serializes that shape back into the user's config.json — a read that silently corrupts config. The deleted #3057 W3 suite covered {"git":"main","base_branch":"release"} explicitly; this is the input it would have caught. Covers both defects across blocks 1 and 5, with object/array/null negative controls that must stay green in both phases, and a fast-check property over arbitrary `git` values. * test(#3648): pin fail-closed handling of malformed protected_branches Replaces the test that pinned the fail-OPEN behaviour. The old assertion — ['develop', 42] yields isProtected === false for 'develop' — locked in the exact failure #3552 exists to close: config-set validation is bypassable by a direct edit of .planning/config.json, so a user who believes 'develop' is protected got a silent false and no warning. It was also inconsistent with the fail-CLOSED direction twelve lines away, where an unverified base reports protected and writes a diagnostic. A protection predicate must not have two opposite failure directions depending on which input is bad (#3648 review Blocker 3). New coverage: a bad element drops only itself, a non-array contributes no names, an empty list is well-formed rather than malformed, and --is-protected surfaces the rejection. Both negative controls — a clean list reports nothing rejected and writes no diagnostic — must stay green in either phase, so the reject channel cannot fire unconditionally. * fix(#3648): drop only invalid protected_branches and report them Partition git.protected_branches instead of discarding the whole list on one bad element, and carry the rejections out through ProtectedBranchStatus so --is-protected can name them on stderr. Valid names keep protecting; the user finds out the rest were ignored. A non-array value still contributes no names — a bare string is not a list of branch names — but is now reported rather than swallowed. An empty array stays silent: declaring no extra protected branches is a valid choice, not a misconfiguration. writeDiagnostic is hoisted out of the unverified-base branch since both arms now use it. * test(#3648): prove the predicate diagnostic survives both call sites The workflow bash stub now emits a stderr diagnostic the way the real command does, which is what makes a swallowed `2>/dev/null` visible to a test — previously the stub was silent on stderr, so discarding it changed no observable behaviour and the call sites could drop the explanation undetected. Adds the Minor 2 binding check as well: ship must expose the predicate result as IS_PROTECTED rather than only echoing a warning, asserted by running the extracted bash and reading the bound value, not by grepping the workflow source. Both tests carry opposite-outcome controls — an empty diagnostic must leave the text absent, and a false predicate must bind false. * fix(#3648): surface the predicate diagnostic and bind ship's result Drop `2>/dev/null` from the --is-protected call at both call sites. The fail-closed explanation and the new rejected-entry warning both go to stderr, so discarding it left the user with a bare "protected branch" warning on a branch that is not protected and no way to tell a real match from a degraded-git guess. `git branch --show-current` keeps its own redirect — that one is genuine noise. ship.md binds IS_PROTECTED and its prose now branches on the variable, so the following steps have evaluable state instead of having to infer it from warning text in tool output. execute-phase.md byte accounting refreshed: 92326 -> 92645, net growth 319 bytes (was 331 before the redirect came out). Baseline re-verified against the current rebase base by blob id; the ceiling check passes with 755 bytes of margin. * test(#3648): restore negative space for the readFile config seam The #3057 W3 suite was deleted with readConfigBaseBranch, but every arm it pinned survives verbatim in readEffectiveGitConfig's readFile branch — the JSON.parse catch, the non-object guard, the git-section object guard, .trim() and blank-string rejection — and the four surviving readFile injections were positive-path only. protected_branches was never driven through this seam at all. Restores nine cases against the seam, including protected_branches partitioning, plus a control proving loadConfig still wins when both seams are supplied. Records honestly what the suite pins. Mutating the built lib shows .trim() is KILLED, while the non-object guard and the blank-string rejection SURVIVE — both are unreachable through this entry point for the same reasons the deleted suite documented against its own equivalents: a JSON-parsed non-object carries no relevant own-property either way, and a blank value is rejected a second time downstream by the resolver's truthiness check. They stay as defence-in-depth and are labelled known-unkillable rather than left looking like coverage this suite does not provide. * test(#3648): distinguish detached HEAD from a missing branch argument `args[1] ?? ''` collapsed two different situations into one: a detached HEAD, where `git branch --show-current` legitimately prints nothing, and the flag being called with no argument at all. Both answered false, so the right outcome arrived by an unintentional path and a caller bug was indistinguishable from normal operation. Asserts the detached case stays silent and the missing-argument case reports, with a control that the two diagnostics differ. * fix(#3648): report a missing --is-protected branch argument Answer false either way, but say so when the flag arrives with no argument. A detached HEAD passes an explicit empty string and stays silent, since that is a normal state rather than a misconfiguration. * docs(#3648): state exact-name matching and per-entry rejection isProtected is exact string equality, so a git-flow project must enumerate every release/* and hotfix/* by name. #3552 only asked for an integration-branch field, so the implementation satisfies the letter of the issue while leaving its git-flow motivation partly unserved — say so where users will meet it rather than leaving them to discover it. Also documents the Blocker 3 behaviour change: an invalid entry is ignored with a warning naming it and the remaining names still apply. Both statements land in docs/CONFIGURATION.md and gsd-core/references/planning-config.md, and the config-field-docs parity test asserts each in both so the two cannot drift. * refactor(#3648): extract isValidProtectedBranches for cross-surface pinning The `git.protected_branches` check inside `cmdConfigSet` and the resolver's per-entry filter in `git-base-branch.cts` are deliberately different shapes — all-or-nothing on write, per-entry on read, so a hand-edited config.json cannot fail the guard open. Nothing structural keeps their two definitions of "usable branch name" in step. Lifting the write-side check into a named, exported predicate lets a property test ask both surfaces about the same value and assert they agree, which is the fast-check gap the round-2 review flagged. No behaviour change: the predicate is the same expression, called from the same place. * fix(#3648): stop --is-protected rewriting the config it is asking about `gsd_run query git.base-branch --is-protected` runs on every execute-phase and every ship. It resolved config through `loadConfig`, whose normalize-then-write path rewrites `.planning/config.json` whenever any legacy key normalizes — so a boolean question was silently editing the user's checked-in config. This PR had widened the trigger by adding a fifth normalization block (top-level `base_branch` -> `git.base_branch`), making it fire for exactly the projects the feature targets. `loadConfigResolved` gains `options.persist` (opt-OUT, default true): resolution is unchanged, only the two write-back side effects are suppressed. The predicate passes `persist: false`; the ~30 other callers are untouched, so a legacy config is still migrated by ordinary use. Asserted on BYTES rather than parsed shape, because the rewrite reorders keys and reflows whitespace even when the values are equivalent. Three tests, each with its own control: the end-to-end CLI leaves the file byte-identical while still answering `true` from the legacy key (proving the config WAS read); an ordinary persisting load of the same fixture DOES change the bytes (proving the fixture is live rather than inert); and `persist:false` vs default over one directory returns deep-equal config while differing on the write. Reverting the one-line `persist: false` fails the first of those and only that one. Also from the review: - `readEffectiveGitConfig`'s comment claimed the readFile branch routed "through the same precedence authority production uses". It does not, and cannot — it reproduces two of production's steps over a single file. The comment now names what the seam covers and what it does NOT (root/workstream deep merge, builtin and global defaults, federated merge), and the seam now applies production's flat-then-nested lookup so it stops disagreeing about a surviving flat key. - The missing-argument diagnostic promised "answering false", which the fail-closed guard on the same call can contradict by printing `true`. It now states what it did with the argument and leaves the answer to stdout. * test(#3648): re-pin block 5 on #3760's refusal contract #3767 landed on next while this PR was in review and fixed the non-object config-section defect properly: a present-but-non-object section now BLOCKS its own migration — value preserved, no Normalization pushed, refusal reported via `skipped[]` — rather than being rebuilt from a plain-object view. That supersedes this branch's round-2 `hoistLegacyKey`, which prevented the character-key spread but still dropped the section value silently, and which the round-3 review correctly called out as destruction in place of corruption. The rebase drops that commit and routes block 5 through the upstream helper. This file's tests asserted the superseded design, so they are rewritten to pin block 5 — `base_branch` -> `git.base_branch`, which did not exist when #3760's suite was written — against the contract that now governs it: ordinary hoist into an absent/null/object section, canonical-nested-wins, and refusal for each of string/number/boolean/array sections with the exact `skipped` entry. Two controls keep it from passing vacuously: the refusal must be scoped to block 5 (an unrelated block still normalizes in the same call), and a property over arbitrary `git` values asserts hoist and refusal are exhaustive AND mutually exclusive per key, that a refusal leaves both the section and the legacy key untouched, and that a hoist manufactures no index key the input did not carry. * docs(#3648): correct the Git Query and Config Loader module contracts CONTEXT.md's Git Query Module still described base-branch tier 1 as a direct `.planning/config.json` read. Since this PR it is the EFFECTIVE configuration resolved by the Config Loader — a materially different authority, carrying the root/workstream deep merge, flat-then-nested lookup and builtin/federated defaults. The `--is-protected` predicate, `git.protected_branches`, and the two invariants that distinguish the predicate from the plain query (fails closed on an unverified base; must not write) were undocumented entirely. The Config Loader entry now states that loading is not side-effect-free by default and documents `options.persist`. docs/INVENTORY.md's `git-base-branch.cjs` row carried the same stale ladder and no mention of the predicate. `node scripts/gen-inventory-manifest.cjs --write` was run and produced no diff: the manifest indexes roster NAMES, not row prose, so a description edit cannot move it. Also closes the global-defaults minor: `git.protected_branches` is inert in `~/.gsd/defaults.json`, but so is every other `git.*` key — no branch-policy key appears in `_globalBaseCfg` or `GLOBAL_DEFAULTS_RESOLUTION_KEYS`. That is section-wide and predates this PR, so the fix is to state the scope where users meet it rather than to quietly extend the resolution set for two new keys. * fix(#3648): close four defects found by the round-4 external review Two external reviewers (codex, antigravity/Gemini 3.1 Pro) were run adversarially against this branch. Four findings reproduced against source; each is fixed with a failing-first test and a control, and each fix was verified by reverting it and watching exactly the intended test fail. 1. `persist:false` was DROPPED by the workstream fallback (codex). Blocker 1 was only half closed. `loadConfigResolved` re-enters itself with a bare `{ workstream: null }` when a workstream has no config.json of its own, and that literal discarded every other option — so the recursive pass ran at the DEFAULT persistence and rewrote the ROOT config. Reproduced: with GSD_WORKSTREAM=alpha and a legacy flat `base_branch`, `--is-protected` rewrote `.planning/config.json` despite `persist:false`. Both recursions now forward `options` and override only `workstream`; the explicit override still wins the hasOwnProperty check, so spreading cannot let `workstreamContext` reintroduce a workstream. 2. Both workflow call sites failed OPEN, and aborted under `set -e` (both reviewers, independently). `IS_PROTECTED=$(gsd_run ...)` yields an empty string when the query fails, so `[ "$X" = true ]` was simply false: no warning, no trace — a silent hole in the guard whose only job is to warn. The bare assignment also aborted the step under `set -e`. Both sites now degrade VISIBLY: `|| IS_PROTECTED=""`, then an explicit empty-string arm that says the check did not run. Deliberately not fail-closed — claiming "protected" on no evidence would warn on every branch whenever gsd-tools is unavailable. 3. `isValidProtectedBranches` and the resolver disagreed on a sparse array (antigravity). `.every()` skips holes; the resolver's `for...of` yields `undefined` for them, so `["main", , "develop"]` was accepted by config-set and rejected by the resolver. The cross-surface property passed only because `fc.array` cannot generate a hole. The predicate now indexes, and the generator punches holes so that axis is actually falsifiable. JSON cannot express a hole, so this is unreachable in production — but two definitions of one predicate must not contradict each other. 4. A top-level `protected_branches` silently outranked `git.protected_branches` (antigravity). Routing the key through `get(key, {section, field})` gave it flat-then-nested precedence, which is back-compat for keys `normalizeLegacyKeys` migrates. `protected_branches` is new in #3552 and has no legacy form, so that invented an undocumented alias. It now resolves nested-only through a new `getNested`, in production and in the test seam. `base_branch` keeps flat-then-nested — it HAS a legacy spelling that #3760's refusal path can leave behind — and a control pins that distinction. Also narrows a CONTEXT.md claim this round introduced. The predicate fails closed only when a git query TIMED OUT or could not be spawned (#3057 B4's `verified`); a git command that runs and exits non-zero counts as a clean negative, so a cwd that is not a repository answers `false`, not `true`. Verified pre-existing on next @738f42f4, so the documentation was over-claiming rather than the code regressing — but an over-broad contract is exactly what the module docs must not carry. Both workflow byte figures re-derived after the call-site change: execute-phase.md 92356 -> 92865 (+509), ship.md 36784 -> 37227 (+443). * test(#3648): pin git config read parity * docs(#3648): document git query contracts * fix(#3648): expose protected branch default * test(#3648): snapshot planning tree for read-only query * test(#3648): pin planning snapshot stray-write detection * fix(#3648): resolve merge conflict from #3078's ack-fragment sweep next swept the fully-spent 2818/3003 ack fragments this branch had appended to (#3078,a84f7563). Rebased onto upstream/next and took the deletions on both, then moved the #3552 append into a new fragment of its own. Rebasing onto the current base also left execute-phase.md only 34 bytes under the frozen ADR-857 Phase 6 margin ceiling (93400 bytes) — intervening next PRs consumed the rest while this PR was in review. Extracted the "none" arm's protected-branch-warning bash block into gsd-core/workflows/execute-phase/steps/protected-branch.md (content unchanged, matching the existing steps/ extraction pattern used elsewhere in this file) so the inline growth is a one-line pointer instead of the full block. 93366 -> 93385 bytes (+19), 15 bytes inside the ceiling. * fix(#3648): drop stale ack entry for the new step file The extracted execute-phase/steps/protected-branch.md needed no acknowledgment of its own — the differential-attribution check flagged the entry as stale once the build ran, so removed it and kept the two growth entries (execute-phase.md, ship.md) that actually needed one. * fix(#3648): follow the step-file reference in the bash-extraction test helper extractProtectedBranchWarningBash() read the "none" arm's bash block directly out of execute-phase.md. That block now lives in execute-phase/steps/protected-branch.md (byte-ceiling extraction); the helper follows the step-file reference and extracts from there when no inline block is found, so the three execute-phase tests that execute this bash for real keep exercising the actual behavior. * fix(#3648): regenerate INVENTORY-MANIFEST.json and satisfy the CRLF-fragile lint rule - gen-inventory-manifest.cjs --write to pick up the new execute-phase/steps/protected-branch.md entry (already covered by docs/INVENTORY.md's generic workflow_steps wildcard row, so no INVENTORY.md edit is needed). - Reworked the step-file-reference lookup in extractProtectedBranchWarningBash() to avoid a bare-\n regex split on file content (local/no-crlf-fragile-split), using the same line-array scan the function already uses elsewhere. * fix(#3648): regenerate golden install-tree fixtures for the new step file npm run gen:install-tree, adding gsd-core/workflows/execute-phase/ steps/protected-branch.md to all 19 runtime install-tree fixtures. CI's tests/golden-install-tree.test.cjs caught this on push — I'd verified the differential-attribution and INVENTORY-MANIFEST checks but missed this separate golden-fixture check for the new file. * fix(#3648): add the canonical gsd_run preamble to the new step file CI's runtime-launcher-parity suite requires exactly one canonical resolver preamble in every workflow .md that calls gsd_run. The inline "none"-arm block never needed one (execute-phase.md already carried a preamble elsewhere in the same file), but the extracted execute-phase/steps/protected-branch.md is now its own file with no preamble of its own. Ran node scripts/sync-runtime-launcher.cjs to insert it (execute-phase.md itself is untouched — still 93385 bytes, inside the ADR-857 ceiling). That preamble defines its own gsd_run(), which shadows the mock tests/git-base-branch.test.cjs injects for the three #3648 tests that execute this bash for real — without stripping it, those tests reached the real gsd-tools.cjs on the machine running them instead of the test's fixture. Preamble correctness is already covered by tests/runtime-launcher-parity.test.cjs, so extractProtectedBranchWarningBash() now strips the preamble line before handing the block to the harness; it only needs to exercise the #3552 warning logic. * fix(#3552): address PR 3648 review feedback on protected branch warnings - Fix execute-phase handle_branching branching_strategy=none instruction to "Read and execute execute-phase/steps/protected-branch.md" - Use io.error(..., ERROR_REASON.USAGE) for cmdGitBaseBranch usage errors - Align git.protected_branches schema default to (none) without fallback [] - Relocate CONTEXT.md forward-referencing sentence into module body - Sanitize control and ANSI characters in renderRejected diagnostics - Clean up out-of-scope whitespace hunks in gsd-tools.cjs Emitted-Drift-Ack-Growth: execute-phase.md — #3552: execute-phase handle_branching adds a pointer to execute-phase/steps/protected-branch.md for branching_strategy=none so the protected-branch check executes while keeping execute-phase.md within the ADR-857 Phase 6 margin ceiling (93400 bytes). 93392 bytes, 8 bytes inside the ceiling. Emitted-Drift-Ack-Growth: ship.md — #3552: ship preflight step 3 now asks the same typed git.base-branch --is-protected predicate as execute-phase, binding IS_PROTECTED and warning without refusing execution or blocking the branching_strategy=none feature-branch offer; it degrades visibly (rather than silently reading an empty result as "not protected") when the query itself fails to run. 36841 bytes, well inside the XL cap (98304, tests/workflow-size-budget.test.cjs). --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1154 lines
58 KiB
TypeScript
1154 lines
58 KiB
TypeScript
/**
|
|
* Config Loader — Project configuration loading
|
|
*
|
|
* ADR-857 rollout phase 2e: extracted from core.cts (issue #885).
|
|
* Owns project configuration loading: reads `.planning/config.json`,
|
|
* merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`),
|
|
* normalizes legacy keys, applies the active-workstream overlay, validates
|
|
* against the config schema, and warns on unknown keys/profile overrides.
|
|
* Behaviour is preserved byte-for-behaviour from the prior location; only
|
|
* the module boundary moved. The core.cjs re-export spine was retired in
|
|
* epic #1267; callers import loadConfig from config-loader.cjs directly.
|
|
*
|
|
* Dependencies (leaf modules only):
|
|
* - node:fs / node:os / node:path (stdlib)
|
|
* - ./configuration.cjs (normalizeLegacyKeys, isConfigSection, CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
|
|
* - ./unusable-input.cjs (warnUnusableInput, UNUSABLE_REASON — #3760)
|
|
* - ./config-schema.cjs (VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS)
|
|
* - ./planning-workspace.cjs (planningDir, planningRoot)
|
|
* - ./shell-command-projection.cjs (execGit, platformWriteSync, platformReadSync)
|
|
* - ./core-utils.cjs (detectSubRepos)
|
|
* - ./model-catalog.cjs (KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES)
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
const { planningDir, planningRoot } = planningWorkspace;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import coreUtilsModule = require('./core-utils.cjs');
|
|
const { detectSubRepos } = coreUtilsModule;
|
|
// ─── Configuration Module (generated CJS mirror) ────────────────────────────
|
|
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys, isConfigSection } from './configuration.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import configSchema = require('./config-schema.cjs');
|
|
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
|
|
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES } from './model-catalog.cjs';
|
|
// #3760: the ADR-1411 out-of-band diagnostic seam. loadConfig returns `.config`
|
|
// alone, so an in-band `skipped` record would be unreachable to nearly every
|
|
// caller — "a reason no caller reads is an unreachable field" (ADR-1411).
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import unusableInputModule = require('./unusable-input.cjs');
|
|
const { UNUSABLE_REASON: _UNUSABLE_REASON, warnUnusableInput: _warnUnusableInput } = unusableInputModule;
|
|
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import federatedConfigModule = require('./federated-config.cjs');
|
|
const { mergeFederatedConfig } = federatedConfigModule;
|
|
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
|
|
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
|
|
// This is the FROZEN first-party registry — used as the test-seam default and the
|
|
// fallback. Overlay (installed third-party) config-key federation is cwd-dependent
|
|
// and composed PER loadConfig CALL by _federatedConfigSchema(cwd) below (ADR-1244 D2),
|
|
// never eagerly at module load.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
|
|
|
|
// Module-level registry reference. Defaults to the real generated registry.
|
|
// Overridable for tests via _setFederatedRegistryForTests.
|
|
let _capabilityRegistry: { configSchema?: Record<string, unknown> } = _capabilityRegistryReal;
|
|
|
|
/** Test-only seam: inject a synthetic registry. Call _resetFederatedRegistryForTests() to restore. */
|
|
function _setFederatedRegistryForTests(reg: { configSchema?: Record<string, unknown> }): void {
|
|
_capabilityRegistry = reg;
|
|
}
|
|
|
|
/** Test-only seam: restore the real generated registry. */
|
|
function _resetFederatedRegistryForTests(): void {
|
|
_capabilityRegistry = _capabilityRegistryReal;
|
|
}
|
|
|
|
// ─── File & Config utilities ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Canonical config defaults — flat-key projection for CJS consumers.
|
|
*
|
|
* Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested
|
|
* manifest loaded by configuration.generated.cjs). The flat shape is
|
|
* preserved here so legacy consumers (config.cjs, verify.cjs, tests that
|
|
* regex-parse this source) continue to work without changes. The key names
|
|
* and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept.
|
|
*
|
|
* Mapping notes:
|
|
* - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this)
|
|
* - git.* → flat git keys (branching_strategy, templates)
|
|
* - workflow.* → flat names (research, verifier, …)
|
|
* - planning.sub_repos → sub_repos
|
|
* - planning.pr_strict → pr_strict
|
|
* - planning.commit_docs / search_gitignored → top-level flat keys
|
|
*/
|
|
|
|
// CANONICAL_CONFIG_DEFAULTS is typed as Record<string, unknown> from configuration.cjs;
|
|
// we use a typed accessor to avoid repeated casts.
|
|
function _getConfigDefault(key: string): unknown {
|
|
return (CANONICAL_CONFIG_DEFAULTS)[key];
|
|
}
|
|
function _getNestedConfigDefault(section: string, field: string): unknown {
|
|
const sec = (CANONICAL_CONFIG_DEFAULTS)[section];
|
|
if (sec && typeof sec === 'object' && !Array.isArray(sec)) {
|
|
return (sec as Record<string, unknown>)[field];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Shared flat-then-nested config lookup; exported for parity tests. */
|
|
function _getConfigValue(
|
|
parsed: Record<string, unknown>,
|
|
key: string,
|
|
nested?: { section: string; field: string },
|
|
): unknown {
|
|
if (parsed[key] !== undefined) return parsed[key];
|
|
if (nested && parsed[nested.section] && typeof parsed[nested.section] === 'object' && parsed[nested.section] !== null) {
|
|
return (parsed[nested.section] as Record<string, unknown>)[nested.field];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Shared nested-only config lookup; exported for parity tests. */
|
|
function _getConfigNested(parsed: Record<string, unknown>, section: string, field: string): unknown {
|
|
const sec = parsed[section];
|
|
if (sec !== null && typeof sec === 'object' && !Array.isArray(sec)) {
|
|
return (sec as Record<string, unknown>)[field];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const CONFIG_DEFAULTS = {
|
|
model_profile: _getConfigDefault('model_profile'),
|
|
commit_docs: _getConfigDefault('commit_docs'),
|
|
search_gitignored: _getConfigDefault('search_gitignored'),
|
|
branching_strategy: _getNestedConfigDefault('git', 'branching_strategy'),
|
|
phase_branch_template: _getNestedConfigDefault('git', 'phase_branch_template'),
|
|
milestone_branch_template: _getNestedConfigDefault('git', 'milestone_branch_template'),
|
|
quick_branch_template: _getNestedConfigDefault('git', 'quick_branch_template'),
|
|
research: _getNestedConfigDefault('workflow', 'research'),
|
|
plan_checker: _getNestedConfigDefault('workflow', 'plan_check'), // flat CJS name maps to workflow.plan_check
|
|
verifier: _getNestedConfigDefault('workflow', 'verifier'),
|
|
nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'),
|
|
ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'),
|
|
api_coverage_gate: _getNestedConfigDefault('workflow', 'api_coverage_gate'),
|
|
parallelization: _getConfigDefault('parallelization'),
|
|
brave_search: _getConfigDefault('brave_search'),
|
|
firecrawl: _getConfigDefault('firecrawl'),
|
|
exa_search: _getConfigDefault('exa_search'),
|
|
text_mode: _getNestedConfigDefault('workflow', 'text_mode'),
|
|
sub_repos: _getNestedConfigDefault('planning', 'sub_repos'),
|
|
pr_strict: _getNestedConfigDefault('planning', 'pr_strict'),
|
|
resolve_model_ids: _getConfigDefault('resolve_model_ids'),
|
|
context_window: _getConfigDefault('context_window'),
|
|
phase_naming: _getConfigDefault('phase_naming'),
|
|
project_code: _getConfigDefault('project_code'),
|
|
subagent_timeout: _getNestedConfigDefault('workflow', 'subagent_timeout'),
|
|
security_enforcement: _getNestedConfigDefault('workflow', 'security_enforcement'),
|
|
security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'),
|
|
security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'),
|
|
post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'),
|
|
research_before_questions: _getNestedConfigDefault('workflow', 'research_before_questions'), // #3894
|
|
smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'),
|
|
inline_plan_threshold: _getNestedConfigDefault('workflow', 'inline_plan_threshold'), // #3801
|
|
max_prompt_tokens: _getNestedConfigDefault('review', 'max_prompt_tokens'),
|
|
};
|
|
|
|
/**
|
|
* Deep-merge two plain config objects. `overlay` wins on key conflict.
|
|
* Explicit `null` in overlay overrides base (null means "unset this key").
|
|
* Arrays are replaced, not merged. Non-object primitives use overlay value.
|
|
*
|
|
* Note: `undefined` in overlay is treated as "no value provided" and falls
|
|
* back to base (preserves inheritance). Explicit `null` overrides base.
|
|
*/
|
|
function _deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown> | null | undefined): Record<string, unknown> | null | undefined {
|
|
if (overlay === null || overlay === undefined) return overlay;
|
|
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
|
|
const result: Record<string, unknown> = { ...base };
|
|
for (const key of Object.keys(overlay)) {
|
|
// Prototype-pollution guard — mirrors the four sibling guards in this file
|
|
// (lines ~315/319/331/341/549). Without it a workstream/root config.json with
|
|
// {"__proto__": {...}} pollutes this merged object's prototype chain and can
|
|
// spoof unset config flags. (Per-object pollution, not global Object.prototype.)
|
|
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
|
|
result[key] = _deepMergeConfig((base[key] ?? {}) as Record<string, unknown>, overlay[key] as Record<string, unknown>);
|
|
} else {
|
|
result[key] = overlay[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Module-level deduplication for unknown-key warnings (#3523).
|
|
// A single `init phase-op N` call invokes loadConfig more than once; this Set
|
|
// prevents the same warning from being echoed on each invocation.
|
|
const _warnedUnknownConfigKeys = new Set<string>();
|
|
|
|
// Normalization result shape from configuration.cjs
|
|
interface NormalizationEntry {
|
|
requiresFilesystem?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// Typed parsed config shape used internally
|
|
interface ParsedConfig {
|
|
[key: string]: unknown;
|
|
planning?: Record<string, unknown>;
|
|
}
|
|
|
|
// ─── Git utilities ────────────────────────────────────────────────────────────
|
|
|
|
const _gitIgnoredCache = new Map<string, boolean>();
|
|
|
|
function isGitIgnored(cwd: string, targetPath: string): boolean {
|
|
// #2206: strip trailing slashes — `git check-ignore` has a quirk where a
|
|
// CRLF .gitignore with blank lines falsely reports a trailing-slash path
|
|
// (e.g. `.planning/`) as ignored. Normalizing here protects every call site.
|
|
const normalized = targetPath.replace(/\/+$/, '');
|
|
const key = cwd + '::' + normalized;
|
|
if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key)!;
|
|
// --no-index checks .gitignore rules regardless of whether the file is tracked.
|
|
const result = execGit(['check-ignore', '-q', '--no-index', '--', normalized], { cwd });
|
|
const ignored = result.exitCode === 0;
|
|
_gitIgnoredCache.set(key, ignored);
|
|
return ignored;
|
|
}
|
|
|
|
// ─── Model alias resolution ───────────────────────────────────────────────────
|
|
|
|
// Catalog-derived (model-catalog.cts) so this vocabulary can never drift from
|
|
// VALID_TIERS in verify.cts — see #2070 "Generative Fix Divergence". Excludes
|
|
// 'inherit' (unlike VALID_TIERS): runtime overrides always resolve to a
|
|
// concrete tier, never the adaptive sentinel.
|
|
const RUNTIME_OVERRIDE_TIERS = ADAPTIVE_TIER_VALUES;
|
|
const _warnedConfigKeys = new Set<string>();
|
|
|
|
function _warnUnknownProfileOverrides(parsed: Record<string, unknown>, configLabel: string): void {
|
|
if (!parsed || typeof parsed !== 'object') return;
|
|
|
|
const runtime = parsed['runtime'];
|
|
if (runtime && typeof runtime === 'string' && !(KNOWN_RUNTIMES).has(runtime)) {
|
|
const key = `${configLabel}::runtime::${runtime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — config key "runtime" has unknown value "${runtime}". ` +
|
|
`Known runtimes: ${[...(KNOWN_RUNTIMES)].sort().join(', ')}. ` +
|
|
`Resolution will fall back to safe defaults. (#2517)\n`
|
|
);
|
|
} catch { /* stderr might be closed in some test harnesses */ }
|
|
}
|
|
}
|
|
|
|
const overrides = parsed['model_profile_overrides'];
|
|
if (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) {
|
|
for (const [overrideRuntime, tierMap] of Object.entries(overrides as Record<string, unknown>)) {
|
|
if (!(KNOWN_RUNTIMES).has(overrideRuntime)) {
|
|
const key = `${configLabel}::override-runtime::${overrideRuntime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
|
|
`unknown runtime "${overrideRuntime}". Known runtimes: ` +
|
|
`${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#2517)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
if (!tierMap || typeof tierMap !== 'object') continue;
|
|
for (const tierName of Object.keys(tierMap)) {
|
|
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
|
|
const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
|
|
`uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const policy = parsed['model_policy'];
|
|
if (policy && typeof policy === 'object' && !Array.isArray(policy)) {
|
|
const policyObj = policy as Record<string, unknown>;
|
|
const provider = policyObj['provider'];
|
|
const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']);
|
|
if (provider && typeof provider === 'string' &&
|
|
!(KNOWN_PROVIDERS).has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) {
|
|
const pkey = `${configLabel}::model_policy::provider::${provider}`;
|
|
if (!_warnedConfigKeys.has(pkey)) {
|
|
_warnedConfigKeys.add(pkey);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.provider has unknown value "${provider}". ` +
|
|
`Known providers: ${[...(KNOWN_PROVIDERS)].sort().join(', ')}. ` +
|
|
`For manual model IDs use provider="custom". (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
const rtOverrides = policyObj['runtime_tiers'];
|
|
if (rtOverrides && typeof rtOverrides === 'object' && !Array.isArray(rtOverrides)) {
|
|
for (const [pruntime, tierMap] of Object.entries(rtOverrides as Record<string, unknown>)) {
|
|
if (!(KNOWN_RUNTIMES).has(pruntime)) {
|
|
const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` +
|
|
`unknown runtime "${pruntime}". Known runtimes: ` +
|
|
`${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
if (!tierMap || typeof tierMap !== 'object') continue;
|
|
for (const tierName of Object.keys(tierMap)) {
|
|
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
|
|
const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` +
|
|
`uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Internal helper exposed for tests so per-process warning state can be reset
|
|
// between cases that intentionally exercise the warning path repeatedly.
|
|
// Clears BOTH dedup sets: _warnedConfigKeys (runtime/model-policy/tier warnings)
|
|
// and _warnedUnknownConfigKeys (unknown top-level keys). Omitting the latter made
|
|
// this a silent no-op for the suite that exists to test it — the leaked state
|
|
// suppressed any later case reusing a key, and the existing cases only passed
|
|
// because each picked a key name no other case reused (#2674).
|
|
function _resetRuntimeWarningCacheForTests(): void {
|
|
_warnedConfigKeys.clear();
|
|
_warnedUnknownConfigKeys.clear();
|
|
_warnedUnusableConfig.clear();
|
|
_warnedShadowedGlobalKeys.clear();
|
|
}
|
|
|
|
// ─── #3532 (10b): shadowed global-defaults diagnostic ────────────────────────
|
|
|
|
// The keys Branch D's `_globalBaseCfg` demonstrably honors from
|
|
// ~/.gsd/defaults.json when no project config exists. Under a project
|
|
// .planning/config.json (Branch A — every real project) the global file is
|
|
// never opened, so each of these set globally is silently inert for resolution.
|
|
// `effort` is in Branch D's honored set but is EXCLUDED from the shadow warning:
|
|
// the install-time effort sync (readGsdEffectiveEffortConfig) DOES merge the
|
|
// global file, so warning on it would be false for the channel users actually
|
|
// control via `effort sync`. Keep this list in lockstep with `_globalBaseCfg`
|
|
// below — the per-key canary in tests/config-loader.test.cjs fails first on
|
|
// drift in either direction.
|
|
const GLOBAL_DEFAULTS_RESOLUTION_KEYS = [
|
|
'model_profile', 'commit_docs', 'research', 'plan_checker', 'verifier',
|
|
'nyquist_validation', 'post_planning_gaps', 'research_before_questions', 'parallelization', 'text_mode',
|
|
'resolve_model_ids', 'context_window', 'subagent_timeout', 'model_overrides',
|
|
'models', 'granularity', 'granularities', 'planning', 'dynamic_routing',
|
|
'effort', 'fast_mode', 'agent_skills', 'response_language', 'runtime',
|
|
'model_profile_overrides', 'model_policy',
|
|
];
|
|
|
|
// Module-level dedup keyed on the SORTED shadowed-key set: a later call with
|
|
// the same shadowed set stays quiet, while a config that grows a new shadowed
|
|
// key re-arms the warning. Stronger than _warnedUnknownConfigKeys (which keys
|
|
// on insertion order) — same discipline, order-independent key.
|
|
const _warnedShadowedGlobalKeys = new Set<string>();
|
|
|
|
function _warnShadowedGlobalDefaults(globalDefaults: Record<string, unknown>, globalPath: string): void {
|
|
const shadowed = GLOBAL_DEFAULTS_RESOLUTION_KEYS.filter(k =>
|
|
k !== 'effort' && Object.prototype.hasOwnProperty.call(globalDefaults, k));
|
|
// Branch D also honors the nested alias workflow.post_planning_gaps (the
|
|
// `?? globalDefaults['workflow']?.['post_planning_gaps']` fallback in
|
|
// _globalBaseCfg) — a global file using only the nested form is equally
|
|
// shadowed, so it reports under its dotted name.
|
|
// #3894: research_before_questions gets the same nested-alias reporting.
|
|
const nestedAliasKeys = ['post_planning_gaps', 'research_before_questions'];
|
|
const wf = globalDefaults['workflow'];
|
|
if (wf && typeof wf === 'object' && !Array.isArray(wf)) {
|
|
for (const k of nestedAliasKeys) {
|
|
if (!shadowed.includes(k) && Object.prototype.hasOwnProperty.call(wf, k)) {
|
|
shadowed.push(`workflow.${k}`);
|
|
}
|
|
}
|
|
}
|
|
if (shadowed.length === 0) return;
|
|
const dedupKey = shadowed.slice().sort().join(',');
|
|
if (_warnedShadowedGlobalKeys.has(dedupKey)) return;
|
|
_warnedShadowedGlobalKeys.add(dedupKey);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd-tools: warning: ${globalPath} sets ${shadowed.join(', ')} but a project config ` +
|
|
`takes precedence here — those global keys are ignored for model resolution. (#3532)\n`,
|
|
);
|
|
} catch { /* stderr might be closed in some test harnesses */ }
|
|
}
|
|
|
|
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
|
|
|
|
/**
|
|
* Apply federated key values into a mutable config object.
|
|
* Handles N-level dotted keys (e.g. "a.b.c" → obj.a.b.c).
|
|
* Only adds keys that are not already present (does not clobber).
|
|
* Inline prototype-pollution guards at every segment.
|
|
*/
|
|
function _applyFederatedValues(
|
|
obj: Record<string, unknown>,
|
|
values: Record<string, unknown>,
|
|
validKeys: string[],
|
|
): void {
|
|
for (const dottedKey of validKeys) {
|
|
// S2: inline literal guard on full key
|
|
if (dottedKey === '__proto__' || dottedKey === 'constructor' || dottedKey === 'prototype') continue;
|
|
const parts = dottedKey.split('.');
|
|
if (parts.length === 1) {
|
|
const topKey = parts[0];
|
|
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
|
|
if (!Object.prototype.hasOwnProperty.call(obj, topKey)) {
|
|
obj[topKey] = values[dottedKey];
|
|
}
|
|
}
|
|
} else {
|
|
// N-level nested key: traverse/create intermediate objects
|
|
let cur: Record<string, unknown> = obj;
|
|
let ok = true;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const seg = parts[i];
|
|
// S2: inline literal guard on each segment
|
|
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { ok = false; break; }
|
|
if (!Object.prototype.hasOwnProperty.call(cur, seg) || cur[seg] === null) {
|
|
cur[seg] = {};
|
|
}
|
|
if (typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) { ok = false; break; }
|
|
cur = cur[seg] as Record<string, unknown>;
|
|
}
|
|
if (!ok) continue;
|
|
const leafKey = parts[parts.length - 1];
|
|
// S2: inline literal guard on leaf
|
|
if (leafKey === '__proto__' || leafKey === 'constructor' || leafKey === 'prototype') continue;
|
|
if (!Object.prototype.hasOwnProperty.call(cur, leafKey)) {
|
|
cur[leafKey] = values[dottedKey];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FIX 2: Apply the federated overlay to a base config object.
|
|
* When validKeys is empty (current registry — all keys are central),
|
|
* returns the baseConfig UNCHANGED (true no-op, preserves reference identity).
|
|
* When validKeys is non-empty, applies values into a shallow clone to avoid
|
|
* mutating shared CONFIG_DEFAULTS/module constants.
|
|
*/
|
|
// Resolve the federated capability config-schema for a project (ADR-1244 D2).
|
|
// A test override (via _setFederatedRegistryForTests) wins; otherwise, when a
|
|
// project cwd is available, compose the installed overlay for THAT project —
|
|
// LAZILY (never at module load, so a bare require never scans the filesystem and
|
|
// the result is never cached for the wrong cwd) — falling back to the frozen
|
|
// first-party schema when there is no cwd or the loader is unavailable.
|
|
function _federatedConfigSchema(cwd?: string): Record<string, unknown> | undefined {
|
|
if (_capabilityRegistry !== _capabilityRegistryReal) {
|
|
return _capabilityRegistry.configSchema; // explicit test override
|
|
}
|
|
if (typeof cwd === 'string' && cwd) {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
|
|
// #1459 IC-04: thread the consent home explicitly so a consented project cap's federated config
|
|
// key resolves at the SAME user-owned home that gated its activation (never the wrong home).
|
|
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
|
|
if (schema && typeof schema === 'object') return schema;
|
|
} catch { /* fall back to first-party */ }
|
|
}
|
|
return _capabilityRegistryReal.configSchema;
|
|
}
|
|
|
|
function _applyFederatedOverlay(
|
|
baseConfig: Record<string, unknown>,
|
|
userConfig: Record<string, unknown>,
|
|
cwd?: string,
|
|
): Record<string, unknown> {
|
|
const _fedRegistrySchema = _federatedConfigSchema(cwd);
|
|
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
|
|
const _fedOverlay = mergeFederatedConfig({
|
|
configSchema: _fedRegistrySchema,
|
|
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
|
userConfig,
|
|
});
|
|
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
|
|
if (_fedOverlay.validKeys.length === 0) return baseConfig;
|
|
// Clone shallowly to avoid mutating shared constants, then apply nested values
|
|
const cloned: Record<string, unknown> = { ...baseConfig };
|
|
_applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
|
|
return cloned;
|
|
}
|
|
|
|
// ─── Resolution Provenance (ADR-1411, #1415) ─────────────────────────────────
|
|
|
|
/** Source of a resolved config: which layer actually supplied the config. */
|
|
type ConfigSource = 'workstream' | 'root' | 'builtin-defaults' | 'global-defaults';
|
|
|
|
/**
|
|
* Result of loadConfigResolved — wraps the config object with provenance metadata.
|
|
* - source: which layer supplied the config
|
|
* - degraded: true when the resolution did not deliver the configuration it
|
|
* should have — either a workstream was requested but its
|
|
* config.json was absent (fell back to root), or a file on the
|
|
* resolution path exists but is unusable (#1880). `reason` says which.
|
|
*/
|
|
/**
|
|
* Machine-readable outcome of a config resolution (#1880, ADR-1411 amendment
|
|
* "corrupt is not absent"). `Resolution<T>`'s four documented values all
|
|
* describe a resolution *miss*; the two `config_un*` values below are the
|
|
* unusable-input class that amendment introduced, and they are what makes a
|
|
* corrupt file distinguishable from an absent one.
|
|
*
|
|
* Frozen enum rather than bare strings so tests assert on the typed surface
|
|
* instead of diagnostic prose (CONTRIBUTING.md — Prohibited: Raw Text Matching
|
|
* on Test Outputs).
|
|
*/
|
|
const CONFIG_REASON = Object.freeze({
|
|
/** A config file was found, parsed, and supplied at least one setting. */
|
|
RESOLVED: 'resolved',
|
|
/** No config file exists at the resolved path. Genuine absence — NOT degraded. */
|
|
NOT_CONFIGURED: 'not_configured',
|
|
/** A config file exists and parsed, but carried no settings (`{}`). */
|
|
CONFIGURED_EMPTY: 'configured_empty',
|
|
/** A workstream was requested but had no config; fell back to root. */
|
|
WORKSTREAM_FALLBACK: 'workstream_fallback',
|
|
/** The file exists but is not valid JSON — settings were NOT applied. */
|
|
CONFIG_UNPARSEABLE: 'config_unparseable',
|
|
/** The file exists but could not be read (EACCES/EIO/…) — NOT applied. */
|
|
CONFIG_UNREADABLE: 'config_unreadable',
|
|
} as const);
|
|
|
|
type ConfigReason = (typeof CONFIG_REASON)[keyof typeof CONFIG_REASON];
|
|
|
|
/** A config file that exists but cannot be used. Absence is NOT a fault. */
|
|
interface ConfigFault {
|
|
reason: typeof CONFIG_REASON.CONFIG_UNPARSEABLE | typeof CONFIG_REASON.CONFIG_UNREADABLE;
|
|
/** Resolved path of the offending file — half of the diagnostic dedup key. */
|
|
path: string;
|
|
/** errno for an unreadable file; '' for a parse failure. The other half. */
|
|
code: string;
|
|
}
|
|
|
|
interface ConfigResolution {
|
|
config: Record<string, unknown>;
|
|
source: ConfigSource;
|
|
degraded: boolean;
|
|
/**
|
|
* Why this resolution produced what it did. `degraded` alone cannot separate
|
|
* "no config here" from "your config is corrupt and was discarded" — both
|
|
* previously returned identical objects (#1880).
|
|
*/
|
|
reason: ConfigReason;
|
|
}
|
|
|
|
/**
|
|
* Read + JSON-parse a config file, keeping *absent* distinguishable from
|
|
* *unusable*. `platformReadSync` returns null on ENOENT and re-throws every
|
|
* other errno, which is the seam that makes this separable at all.
|
|
*/
|
|
function _readConfigFile(filePath: string):
|
|
| { kind: 'ok'; data: Record<string, unknown> }
|
|
| { kind: 'absent' }
|
|
| { kind: 'fault'; fault: ConfigFault } {
|
|
let raw: string | null;
|
|
try {
|
|
raw = platformReadSync(filePath);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code ?? 'EUNKNOWN';
|
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNREADABLE, path: filePath, code } };
|
|
}
|
|
if (raw === null) return { kind: 'absent' };
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
|
|
}
|
|
// Shape, not just parseability (ADR-227). `0`, `"x"`, `[]` and `null` are all
|
|
// valid JSON but are not a config object. Accepting them let a PRESENT file
|
|
// parse "ok", then throw downstream, and be reported not_configured by the
|
|
// outer catch — a corrupt file indistinguishable from an absent one, which is
|
|
// the exact defect this change closes. Caught by the fast-check property.
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
|
|
}
|
|
return { kind: 'ok', data: parsed as Record<string, unknown> };
|
|
}
|
|
|
|
/**
|
|
* Dedup set for the unusable-config diagnostic. Keyed on resolved path + errno
|
|
* per the ADR-1411 amendment — never on message text, which would couple the
|
|
* guard to wording, and never on the errno alone, which would suppress a
|
|
* genuine second failure in a different file.
|
|
*/
|
|
const _warnedUnusableConfig = new Set<string>();
|
|
|
|
/**
|
|
* The wiring clause (ADR-1411 amendment). `reason` lives on `ConfigResolution`,
|
|
* but `loadConfig` — the wrapper roughly fifty call sites use — returns
|
|
* `.config` alone and would never surface it. Without this diagnostic the field
|
|
* is unreachable to almost every consumer, and the user whose config was
|
|
* silently discarded still gets no signal. That was the whole defect in #1880.
|
|
*/
|
|
function _warnUnusableConfig(fault: ConfigFault): void {
|
|
// The NUL separators are load-bearing: without them `path`+`reason`+`code` is bare
|
|
// concatenation and two distinct faults can key alike. They are written as escapes rather
|
|
// than literal 0x00 bytes because a literal NUL makes the whole file binary to file(1) and
|
|
// grep(1), which silently skipped it — RULESET.AUDIT.search-source-not-generated tells
|
|
// agents to search this exact source to confirm an invariant exists, and it was returning
|
|
// nothing. Same runtime string, still greppable.
|
|
const key = `${fault.path}\u0000${fault.reason}\u0000${fault.code}`;
|
|
if (_warnedUnusableConfig.has(key)) return;
|
|
_warnedUnusableConfig.add(key);
|
|
const what = fault.reason === CONFIG_REASON.CONFIG_UNPARSEABLE
|
|
? 'is not valid JSON'
|
|
: `could not be read (${fault.code})`;
|
|
process.stderr.write(
|
|
`gsd-tools: warning: ${fault.path} ${what} — its settings were NOT applied; using defaults instead\n`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
|
|
*
|
|
* Identical to loadConfig in every observable way except it returns
|
|
* { config, source, degraded } instead of just the config object.
|
|
* loadConfig now delegates to this function (byte-identical back-compat).
|
|
*
|
|
* Branch → source/degraded/reason mapping:
|
|
* A1: ws set + ws config.json found → source:'workstream', degraded:false, reason:'resolved'|'configured_empty'
|
|
* A2: ws null + config.json found → source:'root', degraded:false, reason:'resolved'|'configured_empty'
|
|
* B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true, reason:'workstream_fallback'
|
|
* C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false, reason:'not_configured'
|
|
* D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false, reason:'not_configured'
|
|
* E: catch + no .planning/ + no global → source:'builtin-defaults', degraded:false, reason:'not_configured'
|
|
*
|
|
* ORTHOGONAL to all of the above (#1880, ADR-1411 "corrupt is not absent"): if
|
|
* any config file on the resolution path exists but is UNUSABLE — invalid JSON,
|
|
* or an errno such as EACCES — every branch instead returns degraded:true with
|
|
* reason:'config_unparseable'|'config_unreadable', and a deduplicated stderr
|
|
* diagnostic names the file. Before this, a trailing comma in config.json was
|
|
* byte-identical to the file not existing: builtin defaults, degraded:false,
|
|
* and the user's entire configuration silently discarded.
|
|
*
|
|
* `options.persist: false` (#3648) suppresses the two normalize-then-write-back
|
|
* side effects below. Resolution is otherwise identical — same precedence, same
|
|
* returned object — the migrated shape simply stays in memory. Callers that only
|
|
* ASK the config something (a predicate, a status readout) pass it so that a read
|
|
* cannot dirty the working tree; the ~30 callers that omit it keep persisting, so
|
|
* a legacy config is still migrated exactly once by ordinary use.
|
|
*/
|
|
function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}): ConfigResolution {
|
|
// Opt-OUT, not opt-in: omitting the option must preserve the historical
|
|
// write-back for every existing caller.
|
|
const persist = options['persist'] !== false;
|
|
// NOTE: loadConfigResolved resolves from cwd AS-IS (no walk-up).
|
|
// Callers that need ancestor-anchoring (e.g. cmdAgentSkills) must do so
|
|
// themselves via findProjectRoot() before calling this function.
|
|
// This preserves back-compat for the ~30 other loadConfig callers (#1415).
|
|
|
|
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
|
? options['workstream']
|
|
: (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
|
|
? (options['workstreamContext'] as Record<string, unknown>)['ws']
|
|
: (process.env['GSD_WORKSTREAM'] || null);
|
|
const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
|
|
// wsRequested: true when caller explicitly requested a non-empty workstream.
|
|
// Used for source labeling (Fix 4) and early absent-dir intercept (Fix 2).
|
|
const wsRequested = ws != null && ws !== '';
|
|
|
|
let cachedSubRepos: string[] | undefined;
|
|
const getDetectedSubRepos = (): string[] => {
|
|
if (cachedSubRepos === undefined) cachedSubRepos = detectSubRepos(cwd);
|
|
return cachedSubRepos.slice();
|
|
};
|
|
// Faults are captured, not thrown: the existing control flow (one broad catch
|
|
// that falls back to defaults) is preserved exactly — see #1880. All that is
|
|
// added is knowing WHY the fallback fired, which is the whole defect.
|
|
let configFault: ConfigFault | null = null;
|
|
|
|
/**
|
|
* Stamp a fallback return with its reason. Every branch below reaches defaults
|
|
* (or the root config) — what differs is WHY, and before #1880 that was
|
|
* unrecoverable: a corrupt file and an absent one produced identical objects.
|
|
*
|
|
* An unusable file always wins and always sets `degraded:true`; genuine
|
|
* absence keeps whatever `degraded` the branch already decided, so the
|
|
* existing #1366 workstream-fallback semantics are untouched.
|
|
*/
|
|
const fallback = (r: Omit<ConfigResolution, 'reason'>): ConfigResolution => {
|
|
if (configFault) return { ...r, degraded: true, reason: configFault.reason };
|
|
return {
|
|
...r,
|
|
reason: r.degraded ? CONFIG_REASON.WORKSTREAM_FALLBACK : CONFIG_REASON.NOT_CONFIGURED,
|
|
};
|
|
};
|
|
|
|
let rootParsed: ParsedConfig | null = null;
|
|
if (ws) {
|
|
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
|
try {
|
|
const rootRead = _readConfigFile(rootConfigPath);
|
|
if (rootRead.kind === 'fault') {
|
|
configFault = rootRead.fault;
|
|
_warnUnusableConfig(rootRead.fault);
|
|
}
|
|
if (rootRead.kind !== 'ok') throw new Error('root config absent or unusable');
|
|
rootParsed = rootRead.data;
|
|
const { parsed: rootNormalized, normalizations: rootNorms, skipped: rootSkipped } = normalizeLegacyKeys(rootParsed);
|
|
if (rootSkipped.length > 0) {
|
|
_warnUnusableInput({ reason: _UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT, source: rootConfigPath });
|
|
}
|
|
if (rootNorms.length > 0) {
|
|
for (const norm of rootNorms as unknown as NormalizationEntry[]) {
|
|
if (norm.requiresFilesystem && !(rootNormalized as ParsedConfig).planning?.['sub_repos']) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
// #3760: `if (!planning) planning = {}` treated a non-empty STRING as an
|
|
// already-present section, and the next line then assigned onto a
|
|
// primitive — a strict-mode TypeError the enclosing catch swallowed,
|
|
// discarding the user's whole config. `requiresFilesystem` now only
|
|
// reaches here when the section is absent or an object (configuration.cts
|
|
// block 3 refuses otherwise and reports it via `skipped`), so this
|
|
// narrowing chooses between merge and create and never discards.
|
|
if (!isConfigSection((rootNormalized as ParsedConfig).planning)) {
|
|
(rootNormalized as ParsedConfig).planning = {};
|
|
}
|
|
(rootNormalized as ParsedConfig).planning!['sub_repos'] = detected;
|
|
(rootNormalized as ParsedConfig).planning!['commit_docs'] = false;
|
|
}
|
|
}
|
|
}
|
|
rootParsed = rootNormalized;
|
|
if (persist) {
|
|
try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch { /* ignore */ }
|
|
}
|
|
} else {
|
|
rootParsed = rootNormalized;
|
|
}
|
|
} catch {
|
|
// Root config missing or unparseable — workstream config stands alone
|
|
}
|
|
}
|
|
|
|
const configPath = path.join(planningDir(cwd, ws), 'config.json');
|
|
const defaults = CONFIG_DEFAULTS;
|
|
|
|
try {
|
|
const read = _readConfigFile(configPath);
|
|
if (read.kind === 'fault') {
|
|
// The workstream/root config that ACTUALLY governs this resolution is
|
|
// unusable. This outranks any earlier root-config fault for reporting.
|
|
configFault = read.fault;
|
|
_warnUnusableConfig(read.fault);
|
|
}
|
|
if (read.kind !== 'ok') throw new Error('config absent or unusable');
|
|
const fileData: ParsedConfig = read.data;
|
|
// Snapshot BEFORE normalizeLegacyKeys mutates fileData in place.
|
|
const fileHadKeys = Object.keys(read.data).length > 0;
|
|
|
|
let configDirty = false;
|
|
{
|
|
const { parsed: normalized, normalizations, skipped } = normalizeLegacyKeys(fileData);
|
|
if (skipped.length > 0) {
|
|
_warnUnusableInput({ reason: _UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT, source: configPath });
|
|
}
|
|
if (normalizations.length > 0) {
|
|
Object.keys(fileData).forEach(k => delete (fileData as Record<string, unknown>)[k]);
|
|
Object.assign(fileData, normalized);
|
|
configDirty = true;
|
|
for (const norm of normalizations as unknown as NormalizationEntry[]) {
|
|
if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
// #3760 — see the identical guard on the root-config path above.
|
|
if (!isConfigSection(fileData.planning)) fileData.planning = {};
|
|
fileData.planning['sub_repos'] = detected;
|
|
fileData.planning['commit_docs'] = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentSubRepos = (fileData.planning?.['sub_repos'] as string[] | undefined) || [];
|
|
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
const sorted = [...currentSubRepos].sort();
|
|
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
|
// #3760 — reachable only when `planning` already yielded a non-empty
|
|
// sub_repos array, so it is an object here; the narrowing keeps the
|
|
// assignment total rather than relying on that from three frames away.
|
|
if (!isConfigSection(fileData.planning)) fileData.planning = {};
|
|
fileData.planning['sub_repos'] = detected;
|
|
configDirty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (configDirty && persist) {
|
|
try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* ignore */ }
|
|
}
|
|
|
|
const parsed: ParsedConfig = rootParsed
|
|
? (_deepMergeConfig(rootParsed, fileData) as ParsedConfig ?? fileData)
|
|
: fileData;
|
|
|
|
const KNOWN_TOP_LEVEL = new Set([
|
|
...[...VALID_CONFIG_KEYS].map((k: string) => k.split('.')[0]),
|
|
...(DYNAMIC_KEY_PATTERNS as unknown as Array<{ topLevel: string }>).map(p => p.topLevel),
|
|
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
|
|
'depth', 'multiRepo', 'branching_strategy', 'research',
|
|
]);
|
|
|
|
let _preWarningFedValidKeys: string[] = [];
|
|
try {
|
|
const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd);
|
|
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
|
|
const _earlyOverlay = mergeFederatedConfig({
|
|
configSchema: _fedRegistrySchemaEarly,
|
|
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
|
userConfig: parsed,
|
|
});
|
|
_preWarningFedValidKeys = _earlyOverlay.validKeys;
|
|
for (const dottedKey of _preWarningFedValidKeys) {
|
|
const topKey = dottedKey.split('.')[0];
|
|
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
|
|
KNOWN_TOP_LEVEL.add(topKey);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Defensive
|
|
}
|
|
|
|
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
|
|
if (unknownKeys.length > 0) {
|
|
const warnKey = unknownKeys.join(',');
|
|
if (!_warnedUnknownConfigKeys.has(warnKey)) {
|
|
_warnedUnknownConfigKeys.add(warnKey);
|
|
process.stderr.write(
|
|
`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
|
|
);
|
|
}
|
|
}
|
|
|
|
_warnUnknownProfileOverrides(parsed, '.planning/config.json');
|
|
|
|
const get = (key: string, nested?: { section: string; field: string }): unknown =>
|
|
_getConfigValue(parsed, key, nested);
|
|
|
|
/**
|
|
* Nested-ONLY read — no top-level fallback (#3648).
|
|
*
|
|
* `get()`'s flat-then-nested order exists for keys that have a legacy flat
|
|
* spelling `normalizeLegacyKeys` migrates (`branching_strategy`,
|
|
* `base_branch`, …); for those, honouring the flat key is back-compat. A key
|
|
* introduced with no legacy form has nothing to be compatible WITH, so
|
|
* routing it through `get()` would invent an undocumented top-level alias
|
|
* that silently outranks the canonical nested key. Use this instead for new
|
|
* `<section>.<field>` keys (round-4 external review).
|
|
*/
|
|
const getNested = (section: string, field: string): unknown =>
|
|
_getConfigNested(parsed, section, field);
|
|
|
|
const parallelization = (() => {
|
|
const val = get('parallelization');
|
|
if (typeof val === 'boolean') return val;
|
|
if (typeof val === 'object' && val !== null && 'enabled' in (val)) return (val as Record<string, unknown>)['enabled'];
|
|
return defaults.parallelization;
|
|
})();
|
|
|
|
const _baseConfig: Record<string, unknown> = {
|
|
model_profile: get('model_profile') ?? defaults.model_profile,
|
|
commit_docs: (() => {
|
|
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
|
|
if (explicit !== undefined) return explicit;
|
|
if (isGitIgnored(cwd, '.planning/')) return false;
|
|
return defaults.commit_docs;
|
|
})(),
|
|
search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
|
|
branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
|
|
base_branch: get('base_branch', { section: 'git', field: 'base_branch' }),
|
|
protected_branches: getNested('git', 'protected_branches'),
|
|
phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
|
|
milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
|
|
quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
|
|
research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
|
|
plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
|
|
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
|
|
nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
|
|
post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps,
|
|
parallelization,
|
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
|
firecrawl: get('firecrawl') ?? defaults.firecrawl,
|
|
exa_search: get('exa_search') ?? defaults.exa_search,
|
|
mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
|
|
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
|
|
auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
|
|
_auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
|
|
mode: get('mode') ?? 'interactive',
|
|
sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
|
|
pr_strict: get('pr_strict', { section: 'planning', field: 'pr_strict' }) ?? defaults.pr_strict,
|
|
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
|
context_window: get('context_window') ?? defaults.context_window,
|
|
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
|
project_code: get('project_code') ?? defaults.project_code,
|
|
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
|
|
model_overrides: (parsed['model_overrides']) || null,
|
|
models: (parsed['models']) || null,
|
|
granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
|
|
granularities: (parsed['granularities']) || null,
|
|
planning: (parsed['planning']) || null,
|
|
dynamic_routing: (parsed['dynamic_routing']) || null,
|
|
runtime: (parsed['runtime']) || null,
|
|
model_profile_overrides: (parsed['model_profile_overrides']) || null,
|
|
model_policy: (parsed['model_policy']) || null,
|
|
effort: (parsed['effort']) || null,
|
|
fast_mode: (parsed['fast_mode']) || null,
|
|
agent_skills: (parsed['agent_skills']) || {},
|
|
agent_skills_security: (parsed['agent_skills_security']) || null,
|
|
// #3587: phase_commit_docs.<phase-id> — a dynamic-key family shaped like
|
|
// agent_skills above (`{ "<phase-id>": boolean }`). Must be threaded here
|
|
// explicitly: `_baseConfig` is a hand-maintained allowlist, so a key that
|
|
// is only in config-schema.manifest.json's dynamicKeyPatterns (and not
|
|
// projected here) is silently dropped on read — the exact `features`-key
|
|
// failure mode this module's own A3 test guards against.
|
|
phase_commit_docs: (parsed['phase_commit_docs']) || {},
|
|
manager: (parsed['manager']) || {},
|
|
response_language: get('response_language') || null,
|
|
claude_md_path: get('claude_md_path') || null,
|
|
claude_md_assembly: (parsed['claude_md_assembly']) || null,
|
|
phase_id_convention: get('phase_id_convention') ?? null,
|
|
// #3691: the documented central review key. Declared here (not federated —
|
|
// it is central, see config-schema.manifest.json validKeys) so the existing
|
|
// `review.*` per-lane keys the federated overlay below adds land as SIBLINGS
|
|
// on this same object rather than being clobbered by it.
|
|
review: {
|
|
max_prompt_tokens: get('max_prompt_tokens', { section: 'review', field: 'max_prompt_tokens' }) ?? defaults.max_prompt_tokens,
|
|
},
|
|
};
|
|
|
|
// ADR-857 phase 3b: federated config overlay
|
|
try {
|
|
if (_preWarningFedValidKeys.length > 0) {
|
|
const _fedRegistrySchema = _federatedConfigSchema(cwd);
|
|
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
|
|
const _fedOverlay = mergeFederatedConfig({
|
|
configSchema: _fedRegistrySchema,
|
|
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
|
userConfig: parsed,
|
|
});
|
|
_applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
|
|
}
|
|
}
|
|
} catch {
|
|
// Defensive: keep no-throw contract
|
|
}
|
|
|
|
// A1 vs A2: disambiguate by whether a real workstream was requested.
|
|
// Fix 4: empty-string ws ('') resolves the root path → source:'root'.
|
|
const source: ConfigSource = wsRequested ? 'workstream' : 'root';
|
|
|
|
// #3532 (10b): a parsed project config means Branch D never runs, so every
|
|
// key ~/.gsd/defaults.json sets that Branch D would honor is silently inert
|
|
// here. Observation only — one deduped stderr warning; precedence is
|
|
// untouched. Faults in the global file stay silent in this branch (the
|
|
// project config governs; the nearer file is the actionable one).
|
|
try {
|
|
const shadowHome = process.env['GSD_HOME'] || os.homedir();
|
|
const shadowPath = path.join(shadowHome, '.gsd', 'defaults.json');
|
|
const shadowRead = _readConfigFile(shadowPath);
|
|
if (shadowRead.kind === 'ok') {
|
|
_warnShadowedGlobalDefaults(shadowRead.data, shadowPath);
|
|
}
|
|
} catch {
|
|
// Observation only — never let the diagnostic perturb resolution.
|
|
}
|
|
|
|
// This config parsed — but a DIFFERENT file on the resolution path may not
|
|
// have. A workstream config that loads cleanly while the root config it
|
|
// inherits from is corrupt is still a degraded resolution: the root's
|
|
// settings were silently dropped. Reporting `resolved` here would reopen
|
|
// the exact hole this change closes, for the common case of a project that
|
|
// uses workstreams at all.
|
|
if (configFault) {
|
|
return { config: _baseConfig, source, degraded: true, reason: configFault.reason };
|
|
}
|
|
|
|
// Emptiness is judged on the FILE THAT WAS READ, not on `parsed` (the
|
|
// root+workstream merge). An empty workstream file inheriting a non-empty
|
|
// root would otherwise report `resolved` while carrying no settings of its
|
|
// own — the opposite of the not-configured/configured-empty distinction
|
|
// ADR-1411 rule 3 requires.
|
|
const reason = fileHadKeys
|
|
? CONFIG_REASON.RESOLVED
|
|
: CONFIG_REASON.CONFIGURED_EMPTY;
|
|
return { config: _baseConfig, source, degraded: false, reason };
|
|
|
|
} catch {
|
|
// Fix 2: Early intercept — workstream requested but ws config.json absent (or dir absent)
|
|
// AND root config was loaded. Covers BOTH "dir exists, no config.json" AND "dir absent".
|
|
// This delivers the #1366 acceptance criterion: nonexistent GSD_WORKSTREAM yields root, degraded.
|
|
//
|
|
// Both fallback recursions below forward `options` and override ONLY `workstream`.
|
|
// A bare `{ workstream: null }` silently dropped every other option, so a caller's
|
|
// `persist: false` was discarded on exactly this path and the root config was
|
|
// rewritten by a read (#3648, found by external review). The explicit
|
|
// `workstream: null` still wins the `hasOwnProperty` check at the top of this
|
|
// function, so spreading cannot let `workstreamContext` reintroduce a workstream.
|
|
if (wsRequested && rootParsed) {
|
|
const fb = loadConfigResolved(cwd, { ...options, workstream: null });
|
|
return fallback({ config: fb.config, source: 'root', degraded: true });
|
|
}
|
|
|
|
// Branch B, C, D, E
|
|
if (fs.existsSync(planningDir(cwd, ws))) {
|
|
if (rootParsed) {
|
|
// Branch B: workstream requested but ws config.json absent; root config present.
|
|
// (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
|
|
const fb = loadConfigResolved(cwd, { ...options, workstream: null });
|
|
return fallback({ config: fb.config, source: 'root', degraded: true });
|
|
}
|
|
// Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
|
|
try {
|
|
return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
|
|
} catch {
|
|
return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
|
|
}
|
|
}
|
|
// Branch D or E: no .planning/
|
|
try {
|
|
const home = process.env['GSD_HOME'] || os.homedir();
|
|
const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json');
|
|
const globalRead = _readConfigFile(globalDefaultsPath);
|
|
if (globalRead.kind === 'fault') {
|
|
// ~/.gsd/defaults.json is present but unusable. Only report it when the
|
|
// project config did not already fail — the nearer file is the one the
|
|
// user is most likely to be able to act on.
|
|
if (!configFault) configFault = globalRead.fault;
|
|
_warnUnusableConfig(globalRead.fault);
|
|
}
|
|
if (globalRead.kind !== 'ok') throw new Error('global defaults absent or unusable');
|
|
const globalDefaults = globalRead.data;
|
|
const _globalBaseCfg: Record<string, unknown> = {
|
|
...defaults,
|
|
model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
|
|
commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
|
|
research: (globalDefaults['research']) ?? defaults.research,
|
|
plan_checker: (globalDefaults['plan_checker']) ?? defaults.plan_checker,
|
|
verifier: (globalDefaults['verifier']) ?? defaults.verifier,
|
|
nyquist_validation: (globalDefaults['nyquist_validation']) ?? defaults.nyquist_validation,
|
|
post_planning_gaps: (globalDefaults['post_planning_gaps'])
|
|
?? (globalDefaults['workflow'] as Record<string, unknown> | undefined)?.['post_planning_gaps']
|
|
?? defaults.post_planning_gaps,
|
|
// #3894: same nested-alias shape as post_planning_gaps above — the key
|
|
// was silently dropped from global defaults, so it was unavailable at
|
|
// user scope AND inert at project scope on the /gsd-quick path.
|
|
research_before_questions: (globalDefaults['research_before_questions'])
|
|
?? (globalDefaults['workflow'] as Record<string, unknown> | undefined)?.['research_before_questions']
|
|
?? defaults.research_before_questions,
|
|
parallelization: (globalDefaults['parallelization']) ?? defaults.parallelization,
|
|
text_mode: (globalDefaults['text_mode']) ?? defaults.text_mode,
|
|
resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids,
|
|
context_window: (globalDefaults['context_window']) ?? defaults.context_window,
|
|
subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout,
|
|
model_overrides: (globalDefaults['model_overrides']) || null,
|
|
models: (globalDefaults['models']) || null,
|
|
granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null,
|
|
granularities: (globalDefaults['granularities']) || null,
|
|
planning: (globalDefaults['planning']) || null,
|
|
dynamic_routing: (globalDefaults['dynamic_routing']) || null,
|
|
effort: (globalDefaults['effort']) || null,
|
|
fast_mode: (globalDefaults['fast_mode']) || null,
|
|
agent_skills: (globalDefaults['agent_skills']) || {},
|
|
response_language: (globalDefaults['response_language']) || null,
|
|
// #2069: forward model_policy / model_profile_overrides / runtime so the global-defaults
|
|
// path is at parity with the project-config path (which forwards these three from
|
|
// parsed['…'] at the top of this function). Without these entries, ~/.gsd/defaults.json
|
|
// silently drops them — model_policy/provider/budget etc. are honored when set in a
|
|
// project but ignored when set globally.
|
|
runtime: (globalDefaults['runtime']) || null,
|
|
model_profile_overrides: (globalDefaults['model_profile_overrides']) || null,
|
|
model_policy: (globalDefaults['model_policy']) || null,
|
|
};
|
|
// Branch D: global-defaults
|
|
try {
|
|
return fallback({ config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false });
|
|
} catch {
|
|
return fallback({ config: _globalBaseCfg, source: 'global-defaults', degraded: false });
|
|
}
|
|
} catch {
|
|
// Branch E: no global defaults
|
|
try {
|
|
return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
|
|
} catch {
|
|
return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* loadConfig — backwards-compatible config loading, now a thin wrapper over loadConfigResolved.
|
|
* Returns the config object only; for provenance metadata use loadConfigResolved.
|
|
*/
|
|
function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return loadConfigResolved(cwd, options).config;
|
|
}
|
|
|
|
export = {
|
|
loadConfig,
|
|
loadConfigResolved,
|
|
CONFIG_REASON,
|
|
_warnedUnusableConfig,
|
|
isGitIgnored,
|
|
CONFIG_DEFAULTS,
|
|
_getConfigDefault,
|
|
_getNestedConfigDefault,
|
|
_getConfigValue,
|
|
_getConfigNested,
|
|
_deepMergeConfig,
|
|
_warnedUnknownConfigKeys,
|
|
_warnedShadowedGlobalKeys,
|
|
GLOBAL_DEFAULTS_RESOLUTION_KEYS,
|
|
_warnUnknownProfileOverrides,
|
|
_resetRuntimeWarningCacheForTests,
|
|
_warnedConfigKeys,
|
|
_gitIgnoredCache,
|
|
RUNTIME_OVERRIDE_TIERS,
|
|
_setFederatedRegistryForTests,
|
|
_resetFederatedRegistryForTests,
|
|
};
|