diff --git a/.changeset/1688-stale-bake-guard.md b/.changeset/1688-stale-bake-guard.md new file mode 100644 index 000000000..63e5af983 --- /dev/null +++ b/.changeset/1688-stale-bake-guard.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1692 +--- +**GSD now warns when model config changed without re-running the installer on static-frontmatter runtimes** — on `codex` and `opencode`, editing `model_overrides` or `model_profile_overrides` or `model_policy.runtime_tiers` in `.planning/config.json` or `~/.gsd/defaults.json` previously had no effect until the user re-ran `gsd install `, and the failure was silent: the sub-agent kept using the base model. Workflow entry points like `gsd-tools init *` now emit a one-line stderr warning naming the changed config file and the exact remediation command when they detect the config is newer than the baked agent files. The guard is read-only and warning-only by default, dedup'd per session, and skipped entirely on Claude Code because Claude Code resolves models at spawn time. Resolves #1688 as the structural follow-up to #1650. diff --git a/CONTEXT.md b/CONTEXT.md index 59e0a691a..17a8a6bc5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -726,7 +726,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file` `DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally` -`DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); test files that assert path.join result without normalizing to forward slashes` +`DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes` `DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done` `DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional` `DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` @@ -745,6 +745,12 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom=an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples=PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect=any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/hardcoded/posix/path') is the compliant form` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward=normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.` +`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention=run npm run lint:ci (lint-windows-test-portability) before push — enhancement TBD to extend that lint to flag the literal-vs-pathFn assertion shape mechanically; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` + `DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom=scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation` `DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples=PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control` `DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect=CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 544d3f988..d86fe7ccb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -145,7 +145,7 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new | `mode` | enum | `interactive`, `yolo` | `interactive` | `yolo` auto-approves decisions; `interactive` confirms at each step | | `granularity` | enum | `coarse`, `standard`, `fine` | `standard` | Controls phase count: `coarse` (2-4), `standard` (4-6), `fine` (6-10) | | `model_profile` | enum | `quality`, `balanced`, `budget`, `adaptive`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)). `adaptive` was added per [#1713](https://github.com/open-gsd/gsd-core/issues/1713) / [#1806](https://github.com/open-gsd/gsd-core/issues/1806) and resolves the same way as the other tiers under runtime-aware profiles. | -| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. Today only the Codex install path emits per-agent model IDs from this resolver; other runtimes (`opencode`, `gemini`, `qwen`, `copilot`, …) consume the resolver at spawn time and gain dedicated install-path support in [#2612](https://github.com/open-gsd/gsd-core/issues/2612). When unset (default), behavior is unchanged from prior versions. Added in v1.39 | +| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. The resolved ID is embedded into each agent's static frontmatter at install time on `codex` and `opencode` (whose `task` / `spawn_agent` interfaces do not accept an inline `model` parameter, so editing `model_overrides` requires re-running `gsd install ` to take effect — see [Per-Agent Overrides](#per-agent-overrides)); other runtimes consume the resolver at spawn time. When unset (default), behavior is unchanged from prior versions. Added in v1.39 | | `model_profile_overrides..` | string \| object | per-runtime tier override | (none) | Override the runtime-aware tier mapping for a specific `(runtime, tier)`. Tier is one of `opus`, `sonnet`, `haiku`. Value is either a model ID string (e.g. `"gpt-5-pro"`) or `{ model, reasoning_effort }`. See [Runtime-Aware Profiles](#runtime-aware-profiles-2517). Added in v1.39 | | `model_policy.provider` | string | `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`, `generic` | (none) | Declares the model provider. Known providers (`openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`) unlock catalog-backed presets. `generic` treats all model IDs as opaque strings — no prefix inference, no reasoning-effort defaults. `model_policy.runtime_tiers` resolves before legacy `model_profile_overrides`. See [Model Policy Presets](#model-policy-presets-model_policy--added-in-v142). Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) | | `model_policy.budget` | enum | `high`, `medium`, `low` | (none) | Selects a budget tier when using a known provider. GSD materializes the matching catalog preset into explicit tier mappings at resolve time. Ignored when `provider` is `generic` or `custom`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 8b26886a3..61245e35e 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -386,6 +386,7 @@ "security.cjs", "semver-compare.cjs", "shell-command-projection.cjs", + "stale-bake-guard.cjs", "state-command-router.cjs", "state-document.cjs", "state.cjs", diff --git a/docs/how-to/configure-model-profiles.md b/docs/how-to/configure-model-profiles.md index 94fbbd47b..5f71d0130 100644 --- a/docs/how-to/configure-model-profiles.md +++ b/docs/how-to/configure-model-profiles.md @@ -61,6 +61,8 @@ Valid values: `opus`, `sonnet`, `haiku`, `inherit`, or any fully-qualified model npx @opengsd/gsd-core@latest --codex --global # or --opencode, --kilo, etc. ``` +GSD will also warn you if you forget: workflow entry commands (`gsd init plan-phase`, `gsd init execute-phase`, etc.) detect when `.planning/config.json` or `~/.gsd/defaults.json` is newer than your installed agent files and print a one-line stderr reminder naming the changed file and the re-install command. The check is read-only and runs only on `codex` and `opencode`; Claude Code resolves models at spawn time and is unaffected. (#1688) + --- ## Per-phase-type models (`models`) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 83eadeb7b..4c9548a48 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -227,6 +227,10 @@ const evalMod = require('./lib/eval.cjs'); const { routeVerificationCommand } = require('./lib/verification-command-router.cjs'); const verification = require('./lib/verification.cjs'); const { routeInitCommand } = require('./lib/init-command-router.cjs'); +// Stale-bake guard (#1688): warns once when model config changed since agents +// were last baked on static-frontmatter runtimes (codex/opencode). Lazy-required +// here, invoked from case 'init' below. +const { warnIfStaleBake } = require('./lib/stale-bake-guard.cjs'); const loopResolver = require('./lib/loop-resolver.cjs'); const capabilityState = require('./lib/capability-state.cjs'); const capabilityWriter = require('./lib/capability-writer.cjs'); @@ -1414,6 +1418,10 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } case 'init': { + // #1688: warn (at most once per process) if the user edited model_overrides + // without re-running `gsd install ` on a static-frontmatter runtime. + // Best-effort, stderr-only, swallowed errors — never blocks the command. + try { warnIfStaleBake(cwd); } catch { /* guard must never break init */ } routeInitCommand({ init, args, diff --git a/gsd-core/bin/lib/stale-bake-guard.cjs b/gsd-core/bin/lib/stale-bake-guard.cjs new file mode 100644 index 000000000..4a299a873 --- /dev/null +++ b/gsd-core/bin/lib/stale-bake-guard.cjs @@ -0,0 +1,254 @@ +'use strict'; + +/** + * Stale-bake guard for static-frontmatter runtimes (#1688, follow-up to #1650). + * + * Runtimes `codex` and `opencode` bake the resolved model ID into each agent's + * static config at install time (bin/install.js ~5667-5767 for codex, + * ~10008-10026 for opencode). Their task/spawn_agent interfaces do not accept + * an inline `model` parameter, so editing `model_overrides` in + * `.planning/config.json` or `~/.gsd/defaults.json` has NO effect until the + * user re-runs `gsd install ` (or `gsd update`). The failure is + * silent — the sub-agent just uses the prior base model. This module detects + * that staleness at workflow entry and emits a single stderr warning. + * + * Design: pure decision + formatter (testable, no I/O) backed by fs probes + * that swallow every error (the guard must never break the CLI). Dedup'd per + * (runtime, cwd) within a process so a single `gsd-tools init *` invocation + * warns at most once even though multiple agents resolve models underneath. + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +/** + * Runtimes whose agent config is static frontmatter/TOML baked at install time. + * MUST stay in sync with the bake paths in bin/install.js. The parity test in + * tests/stale-bake-guard.test.cjs asserts this matches the runtimes that + * actually emit a baked model: line id #2256 (opencode) and #49/#2256 (codex). + */ +const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'opencode']); + +const _warnedKeys = new Set(); + +/** + * Pure: decide whether a stale-bake condition exists. + * + * Returns `{ stale: true, deltaMs }` when `configMtimeMs` is strictly newer + * than `agentMtimeMs` on a static-frontmatter runtime. Returns `null` when the + * guard does not apply (claude / other spawn-time runtime, missing or + * non-finite mtimes, or agents already at least as new as config). + */ +function detectStaleBake({ runtime, configMtimeMs, agentMtimeMs }) { + if (!runtime || !STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return null; + if (typeof configMtimeMs !== 'number' || typeof agentMtimeMs !== 'number') return null; + if (!Number.isFinite(configMtimeMs) || !Number.isFinite(agentMtimeMs)) return null; + if (configMtimeMs <= agentMtimeMs) return null; + return { stale: true, deltaMs: configMtimeMs - agentMtimeMs }; +} + +/** + * Pure: format the warning string. Returns `''` when no warning is warranted + * (delegates to detectStaleBake so the decision and the message cannot drift). + */ +function formatStaleBakeWarning({ runtime, configPath, configMtimeMs, agentMtimeMs }) { + const signal = detectStaleBake({ runtime, configMtimeMs, agentMtimeMs }); + if (!signal) return ''; + const configDate = new Date(configMtimeMs).toISOString(); + const installFlag = runtime === 'opencode' ? '--opencode' : '--codex'; + return [ + `gsd: model config in ${configPath} changed since agents were last baked (${configDate}).`, + ` Static-frontmatter runtime '${runtime}' ignores the new model_overrides`, + ` until you re-run: gsd install ${installFlag}`, + ` (or 'gsd update')`, + ].join('\n'); +} + +/** + * Pure: resolve the active runtime id from a parsed config object. + * Returns the runtime string, or `'claude'` when unset (the spawn-time default + * for which the guard is a no-op). + */ +function resolveRuntimeFromConfig(config) { + if (config && typeof config === 'object' + && typeof config.runtime === 'string' && config.runtime) { + return config.runtime; + } + return 'claude'; +} + +/** + * Resolve the install root for a runtime's agent files, honoring the same env + * vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR). Returns the + * absolute directory or `null` for unsupported runtimes. + */ +function resolveAgentDir(runtime, { env = process.env, homedir = os.homedir } = {}) { + if (runtime === 'opencode') { + const base = (env.OPENCODE_CONFIG_DIR && String(env.OPENCODE_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'opencode'); + return path.join(base, 'agent'); + } + if (runtime === 'codex') { + const base = (env.CODEX_HOME && String(env.CODEX_HOME).trim()) || path.join(homedir(), '.codex'); + return path.join(base, 'agents'); + } + return null; +} + +/** + * Find the newest mtime across config sources that exist. Mirrors the + * up-to-8-levels-up walk in readGsdEffectiveModelOverrides (bin/install.js) + * and includes the global ~/.gsd/defaults.json. Returns + * `{ mtimeMs, path }` of the newest existing config, or `null` if none exist. + * + * `homedir` is injectable so tests can point the global lookup at a fixture + * dir (otherwise the real ~/.gsd/defaults.json on the CI runner leaks in and + * skews the newest-config calculation — see warnIfStaleBake orchestrator). + */ +function findNewestConfigMtime(cwd, { fsStatSync = fs.statSync, homedir = os.homedir } = {}) { + const candidates = []; + let probe = path.resolve(cwd || '.'); + for (let i = 0; i < 8; i += 1) { + candidates.push(path.join(probe, '.planning', 'config.json')); + const parent = path.dirname(probe); + if (parent === probe) break; + probe = parent; + } + candidates.push(path.join(homedir(), '.gsd', 'defaults.json')); + + let newest = null; + for (const p of candidates) { + try { + const st = fsStatSync(p); + if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs) + && (!newest || st.mtimeMs > newest.mtimeMs)) { + newest = { mtimeMs: st.mtimeMs, path: p }; + } + } catch { + // not present / unreadable — skip + } + } + return newest; +} + +/** + * Find the oldest mtime across installed gsd-* agent files for the runtime. + * Returns `{ mtimeMs, dir }` or `null` if the agent dir is absent or holds no + * gsd-* files (e.g. not yet installed, or uninstalled). + */ +function findOldestAgentMtime(runtime, { env = process.env, homedir = os.homedir, fsStatSync = fs.statSync, fsReaddirSync = fs.readdirSync } = {}) { + const dir = resolveAgentDir(runtime, { env, homedir }); + if (!dir) return null; + let entries; + try { + entries = fsReaddirSync(dir, { withFileTypes: true }); + } catch { + return null; // dir missing — runtime not installed for this user + } + let oldest = null; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.startsWith('gsd-')) continue; + const isAgentFile = (runtime === 'opencode' && entry.name.endsWith('.md')) + || (runtime === 'codex' && (entry.name.endsWith('.toml') || entry.name.endsWith('.md'))); + if (!isAgentFile) continue; + try { + const st = fsStatSync(path.join(dir, entry.name)); + if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs) + && (!oldest || st.mtimeMs < oldest.mtimeMs)) { + oldest = { mtimeMs: st.mtimeMs, dir }; + } + } catch { + // unreadable — skip + } + } + return oldest; +} + +/** + * Orchestrator (side-effecting): probe fs, decide, write warning to stderr. + * + * - Silent on claude / other spawn-time runtimes (returns false). + * - Silent when agents are already at least as new as config. + * - Silent when config or agent dir is absent (nothing to compare). + * - Dedup'd per (runtime, cwd): a single process warns at most once per pair, + * so repeated `resolveModelInternal` calls under one `gsd-tools init *` do + * not repeat the warning. + * - Swallows every error: a warning helper must never break the CLI. + * + * Pass `config` to skip the internal JSON read (caller already loaded it). + * Returns `true` if a warning was written, `false` otherwise. + */ +function warnIfStaleBake(cwd, options = {}) { + const { + stderr = process.stderr, + config = null, + env = process.env, + homedir = os.homedir, + fsStatSync = fs.statSync, + fsReaddirSync = fs.readdirSync, + } = options; + try { + const resolvedConfig = config || _readRuntimeConfig(cwd, { fsStatSync }); + const runtime = resolveRuntimeFromConfig(resolvedConfig); + if (!STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return false; + + const dedupKey = `${runtime}::${path.resolve(cwd || '.')}`; + if (_warnedKeys.has(dedupKey)) return false; + + const newest = findNewestConfigMtime(cwd, { fsStatSync, homedir }); + const oldest = findOldestAgentMtime(runtime, { env, homedir, fsStatSync, fsReaddirSync }); + if (!newest || !oldest) return false; + + const warning = formatStaleBakeWarning({ + runtime, + configPath: newest.path, + configMtimeMs: newest.mtimeMs, + agentMtimeMs: oldest.mtimeMs, + }); + if (!warning) return false; + + stderr.write(warning + '\n'); + _warnedKeys.add(dedupKey); + return true; + } catch { + return false; + } +} + +/** Best-effort minimal read of `.planning/config.json` for the `runtime` key. */ +function _readRuntimeConfig(cwd, { fsStatSync = fs.statSync } = {}) { + let probe = path.resolve(cwd || '.'); + for (let i = 0; i < 8; i += 1) { + const candidate = path.join(probe, '.planning', 'config.json'); + try { + fsStatSync(candidate); + const raw = fs.readFileSync(candidate, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') return parsed; + return {}; + } catch { + // not present / unreadable / malformed — walk up + } + const parent = path.dirname(probe); + if (parent === probe) break; + probe = parent; + } + return {}; +} + +/** Test-only: reset the in-process dedup set between cases. */ +function _resetWarnedForTests() { + _warnedKeys.clear(); +} + +module.exports = { + STATIC_FRONTMATTER_RUNTIMES, + detectStaleBake, + formatStaleBakeWarning, + resolveRuntimeFromConfig, + resolveAgentDir, + findNewestConfigMtime, + findOldestAgentMtime, + warnIfStaleBake, + _resetWarnedForTests, +}; diff --git a/tests/stale-bake-guard.test.cjs b/tests/stale-bake-guard.test.cjs new file mode 100644 index 000000000..afe227f4c --- /dev/null +++ b/tests/stale-bake-guard.test.cjs @@ -0,0 +1,415 @@ +/** + * Stale-bake guard tests (#1688, follow-up to #1650). + * + * Regression contract: on a static-frontmatter runtime (codex/opencode), if + * `.planning/config.json` or `~/.gsd/defaults.json` was edited AFTER the + * installed agent files were baked, `warnIfStaleBake` MUST emit a single + * stderr warning naming the config path and the remediation command. Before + * this module existed, the same condition was silent — the sub-agent would + * keep using the base model with no signal (the #1650 failure mode). + * + * Conventions: behavioural assertions only (no readFileSync+.includes on + * source), boundary coverage at the mtime threshold (limit-1 / limit / + * limit+1), a fast-check property for the comparison contract, and a parity + * assertion that STATIC_FRONTMATTER_RUNTIMES stays in sync with the bake + * paths exposed by bin/install.js. + */ +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + STATIC_FRONTMATTER_RUNTIMES, + detectStaleBake, + formatStaleBakeWarning, + resolveRuntimeFromConfig, + resolveAgentDir, + warnIfStaleBake, + _resetWarnedForTests, +} = require('../gsd-core/bin/lib/stale-bake-guard.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); + +// --------------------------------------------------------------------------- +// Pure decision function — boundary coverage at the mtime threshold +// --------------------------------------------------------------------------- +describe('stale-bake-guard.detectStaleBake (pure decision)', () => { + const AGENT_MS = 1_700_000_000_000; + + test('claude runtime → null (spawn-time runtime, guard does not apply)', () => { + assert.equal(detectStaleBake({ runtime: 'claude', configMtimeMs: AGENT_MS + 1000, agentMtimeMs: AGENT_MS }), null); + }); + + test('unknown runtime → null', () => { + assert.equal(detectStaleBake({ runtime: 'gemini', configMtimeMs: AGENT_MS + 1000, agentMtimeMs: AGENT_MS }), null); + }); + + test('limit-1: config strictly OLDER than agents → null (not stale)', () => { + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: AGENT_MS - 1, agentMtimeMs: AGENT_MS }), null); + }); + + test('limit: config EQUAL to agents → null (boundary, not stale)', () => { + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: AGENT_MS, agentMtimeMs: AGENT_MS }), null); + }); + + test('limit+1: config strictly NEWER than agents → stale (the #1650 condition)', () => { + assert.deepEqual( + detectStaleBake({ runtime: 'opencode', configMtimeMs: AGENT_MS + 1, agentMtimeMs: AGENT_MS }), + { stale: true, deltaMs: 1 }, + ); + }); + + test('codex runtime honored symmetrically with opencode', () => { + assert.deepEqual( + detectStaleBake({ runtime: 'codex', configMtimeMs: AGENT_MS + 5000, agentMtimeMs: AGENT_MS }), + { stale: true, deltaMs: 5000 }, + ); + }); + + test('non-finite mtimes rejected (NaN / Infinity)', () => { + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: NaN, agentMtimeMs: AGENT_MS }), null); + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: Infinity, agentMtimeMs: AGENT_MS }), null); + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: AGENT_MS, agentMtimeMs: -Infinity }), null); + }); + + test('non-number mtimes rejected', () => { + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: '1700', agentMtimeMs: AGENT_MS }), null); + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: undefined, agentMtimeMs: AGENT_MS }), null); + assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: null, agentMtimeMs: AGENT_MS }), null); + }); +}); + +// --------------------------------------------------------------------------- +// Pure formatter +// --------------------------------------------------------------------------- +describe('stale-bake-guard.formatStaleBakeWarning (pure formatter)', () => { + test('empty string when not stale (decision delegated to detectStaleBake)', () => { + assert.equal( + formatStaleBakeWarning({ runtime: 'opencode', configPath: '/x', configMtimeMs: 100, agentMtimeMs: 200 }), + '', + ); + }); + + test('opencode warning includes path, ISO date, runtime name, --opencode flag, and gsd update', () => { + const w = formatStaleBakeWarning({ + runtime: 'opencode', + configPath: '/home/u/proj/.planning/config.json', + configMtimeMs: 1_700_000_000_000, + agentMtimeMs: 1_699_999_999_000, + }); + assert.match(w, /\/home\/u\/proj\/\.planning\/config\.json/); + assert.match(w, /2023-11-14T22:13:20\.000Z/); // ISO rendering of the config mtime + assert.match(w, /'opencode'/); + assert.match(w, /gsd install --opencode/); + assert.match(w, /gsd update/); + }); + + test('codex warning uses --codex flag (not --opencode)', () => { + const w = formatStaleBakeWarning({ runtime: 'codex', configPath: '/x', configMtimeMs: 1_700_000_000_000, agentMtimeMs: 1 }); + assert.match(w, /gsd install --codex/); + assert.doesNotMatch(w, /--opencode/); + }); +}); + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- +describe('stale-bake-guard.resolveRuntimeFromConfig', () => { + test('returns runtime string when set', () => { + assert.equal(resolveRuntimeFromConfig({ runtime: 'opencode' }), 'opencode'); + }); + test('defaults to claude when unset / null / undefined', () => { + assert.equal(resolveRuntimeFromConfig({}), 'claude'); + assert.equal(resolveRuntimeFromConfig(null), 'claude'); + assert.equal(resolveRuntimeFromConfig(undefined), 'claude'); + }); + test('ignores non-string runtime', () => { + assert.equal(resolveRuntimeFromConfig({ runtime: 42 }), 'claude'); + assert.equal(resolveRuntimeFromConfig({ runtime: '' }), 'claude'); + }); +}); + +describe('stale-bake-guard.resolveAgentDir (env-var aware)', () => { + // Per DEFECT.WINDOWS-TEST-PORTABILITY: normalize the path-returning fn's + // result to POSIX forward slashes via .replace(/\\/g, '/') and compare + // against a POSIX literal. This is stronger than path.join-ing both sides + // (which would mask a malformed backslash-on-POSIX return) and stays + // green on every platform. Do NOT hardcode a forward-slash literal against + // the raw return — that fails windows-latest CI. + const posix = (p) => String(p).replace(/\\/g, '/'); + + test('opencode default lands under ~/.config/opencode/agent', () => { + assert.equal(posix(resolveAgentDir('opencode', { env: {}, homedir: () => '/H' })), '/H/.config/opencode/agent'); + }); + test('opencode honors OPENCODE_CONFIG_DIR', () => { + assert.equal(posix(resolveAgentDir('opencode', { env: { OPENCODE_CONFIG_DIR: '/custom/oc' }, homedir: () => '/H' })), '/custom/oc/agent'); + }); + test('codex default lands under ~/.codex/agents', () => { + assert.equal(posix(resolveAgentDir('codex', { env: {}, homedir: () => '/H' })), '/H/.codex/agents'); + }); + test('codex honors CODEX_HOME', () => { + assert.equal(posix(resolveAgentDir('codex', { env: { CODEX_HOME: '/custom/cx' }, homedir: () => '/H' })), '/custom/cx/agents'); + }); + test('unsupported runtime → null', () => { + assert.equal(resolveAgentDir('gemini', { env: {}, homedir: () => '/H' }), null); + }); +}); + +// --------------------------------------------------------------------------- +// Orchestrator with fixtures +// --------------------------------------------------------------------------- +describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => { + let tmpRoot; + let chunks; + let stderrStub; + + beforeEach(() => { + _resetWarnedForTests(); + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stalebake-')); + chunks = []; + stderrStub = { write: (s) => { chunks.push(String(s)); } }; + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + function setMtime(p, ms) { + const t = new Date(ms); + fs.utimesSync(p, t, t); + } + + function setupProject({ runtime, configMtime, agentDir, agentMtime, agentFiles = ['gsd-executor.md'] }) { + fs.mkdirSync(path.join(tmpRoot, '.planning'), { recursive: true }); + const cfgPath = path.join(tmpRoot, '.planning', 'config.json'); + fs.writeFileSync(cfgPath, JSON.stringify({ runtime })); + if (configMtime != null) setMtime(cfgPath, configMtime); + if (agentDir) { + fs.mkdirSync(agentDir, { recursive: true }); + for (const f of agentFiles) { + const p = path.join(agentDir, f); + fs.writeFileSync(p, '---\nname: test\n---\n'); + if (agentMtime != null) setMtime(p, agentMtime); + } + } + } + + // Use ms in the recent-past range that every filesystem accepts reliably + // (avoid APFS/2038+/year-2128 edge cases that some platforms round oddly). + const NEWER = Date.parse('2026-06-24T12:00:00Z'); + const OLDER = Date.parse('2026-05-01T08:00:00Z'); + + function ocEnv(agentParentDir) { + return { OPENCODE_CONFIG_DIR: agentParentDir }; + } + function cxEnv(agentParentDir) { + return { CODEX_HOME: agentParentDir }; + } + + test('claude runtime → no warning even when config is newer than agents', () => { + // OpenCode agent dir shape so the runtime path resolves, but runtime is claude. + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'claude', configMtime: NEWER, agentDir, agentMtime: OLDER }); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + assert.equal(wrote, false); + assert.deepEqual(chunks, []); + }); + + test('opencode: config NEWER than agents → warning written (the #1650 failure mode)', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER }); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + assert.equal(wrote, true); + assert.equal(chunks.length, 1); + assert.match(chunks[0], /model config in .*config\.json changed since agents were last baked/); + assert.match(chunks[0], /'opencode'/); + assert.match(chunks[0], /gsd install --opencode/); + }); + + test('codex: config NEWER than agents → warning written with --codex flag', () => { + const agentParent = path.join(tmpRoot, 'cx-home'); + const agentDir = path.join(agentParent, 'agents'); + setupProject({ runtime: 'codex', configMtime: NEWER, agentDir, agentMtime: OLDER, agentFiles: ['gsd-executor.toml'] }); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: cxEnv(agentParent) }); + assert.equal(wrote, true); + assert.match(chunks[0], /gsd install --codex/); + }); + + test('opencode: config OLDER than agents → no warning (boundary limit-1)', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'opencode', configMtime: OLDER, agentDir, agentMtime: NEWER }); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + assert.equal(wrote, false); + assert.deepEqual(chunks, []); + }); + + test('opencode: config EQUAL to agents → no warning (boundary limit)', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: NEWER }); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + assert.equal(wrote, false); + }); + + test('opencode: agent dir missing (runtime not installed) → no warning, no throw', () => { + setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir: null }); + const wrote = warnIfStaleBake(tmpRoot, { + stderr: stderrStub, + homedir: () => tmpRoot, + env: ocEnv(path.join(tmpRoot, 'nonexistent-oc')), + }); + assert.equal(wrote, false); + assert.deepEqual(chunks, []); + }); + + test('opencode: agent dir present but no gsd-* files → no warning', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER, agentFiles: ['some-other-agent.md'] }); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + assert.equal(wrote, false); + }); + + test('dedup: second call with same (runtime, cwd) → no repeat warning', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER }); + const opts = { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }; + const w1 = warnIfStaleBake(tmpRoot, opts); + const w2 = warnIfStaleBake(tmpRoot, opts); + assert.equal(w1, true); + assert.equal(w2, false); + assert.equal(chunks.length, 1); + }); + + test('global ~/.gsd/defaults.json (in tmp homedir) NEWER than agents → warning', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + fs.mkdirSync(agentDir, { recursive: true }); + const ap = path.join(agentDir, 'gsd-executor.md'); + fs.writeFileSync(ap, '---\n---\n'); + setMtime(ap, OLDER); + // project config older than agents, but GLOBAL config newer → warning + fs.mkdirSync(path.join(tmpRoot, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, '.planning', 'config.json'), JSON.stringify({ runtime: 'opencode' })); + setMtime(path.join(tmpRoot, '.planning', 'config.json'), OLDER - 1000); + fs.mkdirSync(path.join(tmpRoot, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, '.gsd', 'defaults.json'), JSON.stringify({})); + setMtime(path.join(tmpRoot, '.gsd', 'defaults.json'), NEWER); + const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + assert.equal(wrote, true); + assert.match(chunks[0], /defaults\.json/); + }); + + test('guard never throws — stderr.write failure is swallowed', () => { + const agentParent = path.join(tmpRoot, 'oc-config'); + const agentDir = path.join(agentParent, 'agent'); + setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER }); + const throwingStderr = { write: () => { throw new Error('boom'); } }; + let threw = false; + try { + warnIfStaleBake(tmpRoot, { stderr: throwingStderr, homedir: () => tmpRoot, env: ocEnv(agentParent) }); + } catch { + threw = true; + } + assert.equal(threw, false); + }); +}); + +// --------------------------------------------------------------------------- +// Property tests for the comparison contract (RULESET.TESTS.property-based-testing) +// --------------------------------------------------------------------------- +describe('stale-bake-guard property tests (fast-check)', () => { + let fc; + try { + fc = require('fast-check'); + } catch { + test('fast-check not installed — property tests skipped', { skip: true }, () => {}); + return; + } + + test('detectStaleBake is threshold-monotonic across the agent-mtime boundary', () => { + fc.assert(fc.property( + fc.record({ + runtime: fc.constantFrom('opencode', 'codex'), + agentMs: fc.integer({ min: 1, max: Number.MAX_SAFE_INTEGER - 1 }), + }), + ({ runtime, agentMs }) => { + const below = detectStaleBake({ runtime, configMtimeMs: agentMs - 1, agentMtimeMs: agentMs }); + const at = detectStaleBake({ runtime, configMtimeMs: agentMs, agentMtimeMs: agentMs }); + const above = detectStaleBake({ runtime, configMtimeMs: agentMs + 1, agentMtimeMs: agentMs }); + assert.equal(below, null, 'config older than agents must not be stale'); + assert.equal(at, null, 'config equal to agents must not be stale'); + assert.deepEqual(above, { stale: true, deltaMs: 1 }, 'config strictly newer must be stale with deltaMs=1'); + return true; + }, + ), { numRuns: 200 }); + }); + + test('claude runtime is always null regardless of mtimes', () => { + fc.assert(fc.property( + fc.integer(), fc.integer(), + (c, a) => detectStaleBake({ runtime: 'claude', configMtimeMs: c, agentMtimeMs: a }) === null, + ), { numRuns: 200 }); + }); +}); + +// --------------------------------------------------------------------------- +// Parity assertion (DEFECT.GENERATIVE-FIX): STATIC_FRONTMATTER_RUNTIMES must +// stay in sync with the bake paths in bin/install.js. Behavioural: we call the +// exported converter + resolver and assert each listed runtime actually wires a +// baked model. Catches drift if someone adds a runtime to the set without a +// matching bake path (or breaks the opencode bake the whole guard rests on). +// --------------------------------------------------------------------------- +describe('stale-bake-guard parity with bin/install.js bake paths', () => { + let install; + try { + install = require(path.join(REPO_ROOT, 'bin', 'install.js')); + } catch { + test('bin/install.js not loadable in this env — parity test skipped', { skip: true }, () => {}); + return; + } + + test('STATIC_FRONTMATTER_RUNTIMES is exactly codex + opencode (no silent drift)', () => { + assert.deepEqual([...STATIC_FRONTMATTER_RUNTIMES].sort(), ['codex', 'opencode']); + }); + + test('opencode converter bakes a model: line when modelOverride is provided', () => { + const sample = '---\nname: gsd-executor\ndescription: x\nmodel: sonnet\ntools: Read\n---\nbody\n'; + const out = install.convertClaudeToOpencodeFrontmatter(sample, { isAgent: true, modelOverride: 'opencode-go/deepseek-v4-flash' }); + assert.ok( + typeof out === 'string' && out.includes('model: opencode-go/deepseek-v4-flash'), + 'opencode converter no longer bakes modelOverride — STATIC_FRONTMATTER_RUNTIMES is stale vs bin/install.js', + ); + }); + + test('opencode converter omits model: when no override (stale-bake fallback shape)', () => { + const sample = '---\nname: gsd-executor\ndescription: x\nmodel: sonnet\ntools: Read\n---\nbody\n'; + const out = install.convertClaudeToOpencodeFrontmatter(sample, { isAgent: true }); + assert.ok(typeof out === 'string', 'converter must return a string'); + assert.doesNotMatch(out, /^model:/m, 'no-override path should not emit a model: line'); + }); + + test('readGsdEffectiveModelOverrides resolves codex + opencode overrides from .planning/config.json', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-')); + try { + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ model_overrides: { 'gsd-executor': 'opencode-go/flash', 'gsd-planner': 'openai/gpt-5' } }), + ); + const resolved = install.readGsdEffectiveModelOverrides(tmp); + assert.deepEqual(resolved, { 'gsd-executor': 'opencode-go/flash', 'gsd-planner': 'openai/gpt-5' }); + } finally { + cleanup(tmp); + } + }); +});