* docs(#1650): fix stale opencode install-path claim in core settings * feat(#1688): warn on stale model bake for static-frontmatter runtimes * chore(#1688): backfill changeset pr field with real PR number * test(#1688): make resolveAgentDir assertions use path.join for windows * docs(#1688): codify windows path-literal-in-assert anti-pattern + align test
This commit is contained in:
5
.changeset/1688-stale-bake-guard.md
Normal file
5
.changeset/1688-stale-bake-guard.md
Normal file
@@ -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 <runtime>`, 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.
|
||||||
@@ -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.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.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.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 <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> when a bypass is intentional`
|
`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 <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> 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`
|
`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`
|
`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.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.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/<not-in-allowlist>.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`
|
`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect=CI security lane (Prompt injection scan step) reports FAIL: tests/<not-in-allowlist>.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`
|
||||||
|
|||||||
@@ -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 |
|
| `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) |
|
| `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. |
|
| `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 <runtime>` 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.<runtime>.<tier>` | 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_profile_overrides.<runtime>.<tier>` | 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.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)) |
|
| `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)) |
|
||||||
|
|||||||
@@ -386,6 +386,7 @@
|
|||||||
"security.cjs",
|
"security.cjs",
|
||||||
"semver-compare.cjs",
|
"semver-compare.cjs",
|
||||||
"shell-command-projection.cjs",
|
"shell-command-projection.cjs",
|
||||||
|
"stale-bake-guard.cjs",
|
||||||
"state-command-router.cjs",
|
"state-command-router.cjs",
|
||||||
"state-document.cjs",
|
"state-document.cjs",
|
||||||
"state.cjs",
|
"state.cjs",
|
||||||
|
|||||||
@@ -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.
|
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`)
|
## Per-phase-type models (`models`)
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ const evalMod = require('./lib/eval.cjs');
|
|||||||
const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
|
const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
|
||||||
const verification = require('./lib/verification.cjs');
|
const verification = require('./lib/verification.cjs');
|
||||||
const { routeInitCommand } = require('./lib/init-command-router.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 loopResolver = require('./lib/loop-resolver.cjs');
|
||||||
const capabilityState = require('./lib/capability-state.cjs');
|
const capabilityState = require('./lib/capability-state.cjs');
|
||||||
const capabilityWriter = require('./lib/capability-writer.cjs');
|
const capabilityWriter = require('./lib/capability-writer.cjs');
|
||||||
@@ -1414,6 +1418,10 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'init': {
|
case 'init': {
|
||||||
|
// #1688: warn (at most once per process) if the user edited model_overrides
|
||||||
|
// without re-running `gsd install <runtime>` 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({
|
routeInitCommand({
|
||||||
init,
|
init,
|
||||||
args,
|
args,
|
||||||
|
|||||||
254
gsd-core/bin/lib/stale-bake-guard.cjs
Normal file
254
gsd-core/bin/lib/stale-bake-guard.cjs
Normal file
@@ -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 <runtime>` (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,
|
||||||
|
};
|
||||||
415
tests/stale-bake-guard.test.cjs
Normal file
415
tests/stale-bake-guard.test.cjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user